refactor(control-center): Quick Controls 재디자인 및 플랜/잠금 결제 동선 정리
This commit is contained in:
2
src/entities/plan/index.ts
Normal file
2
src/entities/plan/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './model/mockPlan';
|
||||||
|
export * from './model/types';
|
||||||
23
src/entities/plan/model/mockPlan.ts
Normal file
23
src/entities/plan/model/mockPlan.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { PlanLockedPack } from './types';
|
||||||
|
|
||||||
|
export const PRO_LOCKED_ROOM_IDS = ['outer-space', 'snow-mountain'];
|
||||||
|
export const PRO_LOCKED_TIMER_LABELS = ['90/20'];
|
||||||
|
export const PRO_LOCKED_SOUND_IDS = ['cafe-work', 'fireplace'];
|
||||||
|
|
||||||
|
export const PRO_PRESET_PACKS: PlanLockedPack[] = [
|
||||||
|
{
|
||||||
|
id: 'deep-work',
|
||||||
|
name: 'Deep Work',
|
||||||
|
description: '긴 몰입 세션을 위한 무드 묶음',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gentle',
|
||||||
|
name: 'Gentle',
|
||||||
|
description: '저자극 휴식 중심 프리셋',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cafe',
|
||||||
|
name: 'Cafe',
|
||||||
|
description: '카페톤 배경과 사운드 조합',
|
||||||
|
},
|
||||||
|
];
|
||||||
7
src/entities/plan/model/types.ts
Normal file
7
src/entities/plan/model/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type PlanTier = 'normal' | 'pro';
|
||||||
|
|
||||||
|
export interface PlanLockedPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
@@ -38,4 +38,5 @@ export interface RecentThought {
|
|||||||
text: string;
|
text: string;
|
||||||
roomName: string;
|
roomName: string;
|
||||||
capturedAt: string;
|
capturedAt: string;
|
||||||
|
isCompleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ const readStoredThoughts = () => {
|
|||||||
typeof thought.id === 'string' &&
|
typeof thought.id === 'string' &&
|
||||||
typeof thought.text === 'string' &&
|
typeof thought.text === 'string' &&
|
||||||
typeof thought.roomName === 'string' &&
|
typeof thought.roomName === 'string' &&
|
||||||
typeof thought.capturedAt === 'string'
|
typeof thought.capturedAt === 'string' &&
|
||||||
|
(typeof thought.isCompleted === 'undefined' || typeof thought.isCompleted === 'boolean')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -73,26 +74,89 @@ export const useThoughtInbox = () => {
|
|||||||
const trimmedText = text.trim();
|
const trimmedText = text.trim();
|
||||||
|
|
||||||
if (!trimmedText) {
|
if (!trimmedText) {
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thought: RecentThought = {
|
||||||
|
id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
||||||
|
text: trimmedText,
|
||||||
|
roomName,
|
||||||
|
capturedAt: '방금 전',
|
||||||
|
isCompleted: false,
|
||||||
|
};
|
||||||
|
|
||||||
setThoughts((current) => {
|
setThoughts((current) => {
|
||||||
const next: RecentThought[] = [
|
const next: RecentThought[] = [thought, ...current].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
{
|
|
||||||
id: `thought-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
||||||
text: trimmedText,
|
|
||||||
roomName,
|
|
||||||
capturedAt: '방금 전',
|
|
||||||
},
|
|
||||||
...current,
|
|
||||||
].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return thought;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeThought = useCallback((thoughtId: string) => {
|
||||||
|
let removedThought: RecentThought | null = null;
|
||||||
|
|
||||||
|
setThoughts((current) => {
|
||||||
|
const target = current.find((thought) => thought.id === thoughtId);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
removedThought = target;
|
||||||
|
|
||||||
|
return current.filter((thought) => thought.id !== thoughtId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return removedThought;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearThoughts = useCallback(() => {
|
const clearThoughts = useCallback(() => {
|
||||||
setThoughts([]);
|
let snapshot: RecentThought[] = [];
|
||||||
|
|
||||||
|
setThoughts((current) => {
|
||||||
|
snapshot = current;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreThought = useCallback((thought: RecentThought) => {
|
||||||
|
setThoughts((current) => {
|
||||||
|
const withoutDuplicate = current.filter((currentThought) => currentThought.id !== thought.id);
|
||||||
|
return [thought, ...withoutDuplicate].slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreThoughts = useCallback((snapshot: RecentThought[]) => {
|
||||||
|
setThoughts(() => {
|
||||||
|
return snapshot.slice(0, MAX_THOUGHT_INBOX_ITEMS);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setThoughtCompleted = useCallback((thoughtId: string, isCompleted: boolean) => {
|
||||||
|
let previousThought: RecentThought | null = null;
|
||||||
|
|
||||||
|
setThoughts((current) => {
|
||||||
|
const targetIndex = current.findIndex((thought) => thought.id === thoughtId);
|
||||||
|
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousThought = current[targetIndex];
|
||||||
|
const next = [...current];
|
||||||
|
next[targetIndex] = {
|
||||||
|
...current[targetIndex],
|
||||||
|
isCompleted,
|
||||||
|
};
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
return previousThought;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const recentThoughts = useMemo(() => thoughts.slice(0, 3), [thoughts]);
|
const recentThoughts = useMemo(() => thoughts.slice(0, 3), [thoughts]);
|
||||||
@@ -102,6 +166,10 @@ export const useThoughtInbox = () => {
|
|||||||
recentThoughts,
|
recentThoughts,
|
||||||
thoughtCount: thoughts.length,
|
thoughtCount: thoughts.length,
|
||||||
addThought,
|
addThought,
|
||||||
|
removeThought,
|
||||||
clearThoughts,
|
clearThoughts,
|
||||||
|
restoreThought,
|
||||||
|
restoreThoughts,
|
||||||
|
setThoughtCompleted,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { cn } from '@/shared/lib/cn';
|
|||||||
|
|
||||||
interface InboxListProps {
|
interface InboxListProps {
|
||||||
thoughts: RecentThought[];
|
thoughts: RecentThought[];
|
||||||
|
onCompleteThought: (thought: RecentThought) => void;
|
||||||
|
onDeleteThought: (thought: RecentThought) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InboxList = ({ thoughts, className }: InboxListProps) => {
|
export const InboxList = ({ thoughts, onCompleteThought, onDeleteThought, className }: InboxListProps) => {
|
||||||
if (thoughts.length === 0) {
|
if (thoughts.length === 0) {
|
||||||
return (
|
return (
|
||||||
<p
|
<p
|
||||||
@@ -25,12 +27,40 @@ export const InboxList = ({ thoughts, className }: InboxListProps) => {
|
|||||||
{thoughts.slice(0, 10).map((thought) => (
|
{thoughts.slice(0, 10).map((thought) => (
|
||||||
<li
|
<li
|
||||||
key={thought.id}
|
key={thought.id}
|
||||||
className="rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3"
|
className="group relative rounded-2xl border border-white/14 bg-white/7 px-3.5 py-3 pr-24"
|
||||||
>
|
>
|
||||||
<p className="text-sm leading-relaxed text-white/88">{thought.text}</p>
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-sm leading-relaxed',
|
||||||
|
thought.isCompleted ? 'text-white/58 line-through' : 'text-white/88',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{thought.text}
|
||||||
|
</p>
|
||||||
<p className="mt-1.5 text-[11px] text-white/54">
|
<p className="mt-1.5 text-[11px] text-white/54">
|
||||||
{thought.roomName} · {thought.capturedAt}
|
{thought.roomName} · {thought.capturedAt}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="absolute right-2 top-2 flex items-center gap-1 opacity-100 transition-opacity sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCompleteThought(thought)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-6 items-center rounded-full border px-2 text-[10px] transition-colors',
|
||||||
|
thought.isCompleted
|
||||||
|
? 'border-emerald-200/36 bg-emerald-200/18 text-emerald-100'
|
||||||
|
: 'border-white/20 bg-white/8 text-white/76 hover:bg-white/14',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{thought.isCompleted ? '완료됨' : '완료'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDeleteThought(thought)}
|
||||||
|
className="inline-flex h-6 items-center rounded-full border border-rose-200/30 bg-rose-200/10 px-2 text-[10px] text-rose-100/88 transition-colors hover:bg-rose-200/18"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
2
src/features/paywall-sheet/index.ts
Normal file
2
src/features/paywall-sheet/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './ui/ManagePlanSheetContent';
|
||||||
|
export * from './ui/PaywallSheetContent';
|
||||||
47
src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx
Normal file
47
src/features/paywall-sheet/ui/ManagePlanSheetContent.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
interface ManagePlanSheetContentProps {
|
||||||
|
onClose: () => void;
|
||||||
|
onManage: () => void;
|
||||||
|
onRestore: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ManagePlanSheetContent = ({
|
||||||
|
onClose,
|
||||||
|
onManage,
|
||||||
|
onRestore,
|
||||||
|
}: ManagePlanSheetContentProps) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight text-white">PRO 관리</h3>
|
||||||
|
<p className="text-xs text-white/62">결제/복원은 더미 동작이며 실제 연동은 하지 않아요.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onManage}
|
||||||
|
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||||
|
>
|
||||||
|
구독 관리 열기
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onRestore}
|
||||||
|
className="w-full rounded-xl border border-white/18 bg-white/[0.04] px-3 py-2 text-left text-sm text-white/84 transition-colors hover:bg-white/[0.1]"
|
||||||
|
>
|
||||||
|
구매 복원
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
86
src/features/paywall-sheet/ui/PaywallSheetContent.tsx
Normal file
86
src/features/paywall-sheet/ui/PaywallSheetContent.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface PaywallSheetContentProps {
|
||||||
|
onStartPro: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BillingCycle = 'monthly' | 'yearly';
|
||||||
|
|
||||||
|
const BILLING_OPTIONS: Array<{ id: BillingCycle; label: string; caption: string }> = [
|
||||||
|
{ id: 'monthly', label: '월간', caption: '월 9,900원 (더미)' },
|
||||||
|
{ id: 'yearly', label: '연간', caption: '연 79,000원 (더미)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALUE_POINTS = [
|
||||||
|
'더 많은 공간 / 고화질 배경',
|
||||||
|
'작업용 BGM / 사운드 확장',
|
||||||
|
'프리셋 팩 / 고급 타이머',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PaywallSheetContent = ({ onStartPro, onClose }: PaywallSheetContentProps) => {
|
||||||
|
const [cycle, setCycle] = useState<BillingCycle>('monthly');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<header className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold tracking-tight text-white">PRO로 더 깊게</h3>
|
||||||
|
<p className="text-xs text-white/62">필요할 때만 잠금 해제하고, 무대는 그대로 유지해요.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{VALUE_POINTS.map((point) => (
|
||||||
|
<li key={point} className="rounded-xl border border-white/14 bg-white/[0.04] px-3 py-2 text-sm text-white/86">
|
||||||
|
{point}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-white/14 bg-white/[0.04] p-3">
|
||||||
|
<p className="text-xs text-white/60">가격</p>
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
{BILLING_OPTIONS.map((option) => {
|
||||||
|
const selected = option.id === cycle;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCycle(option.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border px-2.5 py-2 text-left text-xs transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-sky-200/40 bg-sky-200/14 text-white'
|
||||||
|
: 'border-white/16 bg-white/[0.03] text-white/72 hover:bg-white/[0.08]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{option.label}</p>
|
||||||
|
<p className="mt-0.5 text-[10px] text-white/56">{option.caption}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-full border border-white/18 bg-white/[0.05] px-3 py-1.5 text-xs text-white/74 transition-colors hover:bg-white/[0.11]"
|
||||||
|
>
|
||||||
|
나중에
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onStartPro}
|
||||||
|
className="rounded-full border border-sky-200/44 bg-sky-300/84 px-3.5 py-1.5 text-xs font-semibold text-slate-900 transition-colors hover:bg-sky-300"
|
||||||
|
>
|
||||||
|
PRO 시작하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/features/plan-pill/index.ts
Normal file
1
src/features/plan-pill/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './ui/PlanPill';
|
||||||
26
src/features/plan-pill/ui/PlanPill.tsx
Normal file
26
src/features/plan-pill/ui/PlanPill.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { PlanTier } from '@/entities/plan';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface PlanPillProps {
|
||||||
|
plan: PlanTier;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PlanPill = ({ plan, onClick }: PlanPillProps) => {
|
||||||
|
const isPro = plan === 'pro';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.08em] uppercase transition-colors',
|
||||||
|
isPro
|
||||||
|
? 'border-amber-200/46 bg-amber-200/14 text-amber-100 hover:bg-amber-200/24'
|
||||||
|
: 'border-white/20 bg-white/8 text-white/82 hover:bg-white/14',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isPro ? 'PRO' : 'Normal'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,11 @@ import { cn } from '@/shared/lib/cn';
|
|||||||
interface ToastPayload {
|
interface ToastPayload {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
durationMs?: number;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToastItem extends ToastPayload {
|
interface ToastItem extends ToastPayload {
|
||||||
@@ -22,15 +27,20 @@ const ToastContext = createContext<ToastContextValue | null>(null);
|
|||||||
export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const removeToast = useCallback((id: number) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const pushToast = useCallback((payload: ToastPayload) => {
|
const pushToast = useCallback((payload: ToastPayload) => {
|
||||||
const id = Date.now() + Math.floor(Math.random() * 10000);
|
const id = Date.now() + Math.floor(Math.random() * 10000);
|
||||||
|
const durationMs = payload.durationMs ?? 2400;
|
||||||
|
|
||||||
setToasts((current) => [...current, { id, ...payload }]);
|
setToasts((current) => [...current, { id, ...payload }]);
|
||||||
|
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setToasts((current) => current.filter((toast) => toast.id !== id));
|
removeToast(id);
|
||||||
}, 2400);
|
}, durationMs);
|
||||||
}, []);
|
}, [removeToast]);
|
||||||
|
|
||||||
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
const value = useMemo(() => ({ pushToast }), [pushToast]);
|
||||||
|
|
||||||
@@ -42,7 +52,7 @@ export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
<div
|
<div
|
||||||
key={toast.id}
|
key={toast.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl border border-white/15 bg-slate-950/92 px-4 py-3 text-sm text-white shadow-lg shadow-slate-950/60',
|
'pointer-events-auto rounded-xl border border-white/15 bg-slate-950/92 px-4 py-3 text-sm text-white shadow-lg shadow-slate-950/60',
|
||||||
'animate-[toast-in_180ms_ease-out] motion-reduce:animate-none',
|
'animate-[toast-in_180ms_ease-out] motion-reduce:animate-none',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -50,6 +60,20 @@ export const ToastProvider = ({ children }: { children: ReactNode }) => {
|
|||||||
{toast.description ? (
|
{toast.description ? (
|
||||||
<p className="mt-1 text-xs text-white/70">{toast.description}</p>
|
<p className="mt-1 text-xs text-white/70">{toast.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{toast.action ? (
|
||||||
|
<div className="mt-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
toast.action?.onClick();
|
||||||
|
removeToast(toast.id);
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-white/25 bg-white/[0.08] px-2.5 py-1 text-[11px] font-medium text-white/88 transition-colors hover:bg-white/[0.16]"
|
||||||
|
>
|
||||||
|
{toast.action.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
src/widgets/control-center-sheet/index.ts
Normal file
1
src/widgets/control-center-sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './ui/ControlCenterSheetWidget';
|
||||||
237
src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx
Normal file
237
src/widgets/control-center-sheet/ui/ControlCenterSheetWidget.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { PlanTier } from '@/entities/plan';
|
||||||
|
import {
|
||||||
|
PRO_LOCKED_ROOM_IDS,
|
||||||
|
PRO_LOCKED_SOUND_IDS,
|
||||||
|
PRO_LOCKED_TIMER_LABELS,
|
||||||
|
} from '@/entities/plan';
|
||||||
|
import { getRoomCardBackgroundStyle, type RoomTheme } from '@/entities/room';
|
||||||
|
import type { SoundPreset, TimerPreset } from '@/entities/session';
|
||||||
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
|
||||||
|
interface ControlCenterSheetWidgetProps {
|
||||||
|
plan: PlanTier;
|
||||||
|
rooms: RoomTheme[];
|
||||||
|
selectedRoomId: string;
|
||||||
|
selectedTimerLabel: string;
|
||||||
|
selectedSoundPresetId: string;
|
||||||
|
timerPresets: TimerPreset[];
|
||||||
|
soundPresets: SoundPreset[];
|
||||||
|
onSelectRoom: (roomId: string) => void;
|
||||||
|
onSelectTimer: (timerLabel: string) => void;
|
||||||
|
onSelectSound: (soundPresetId: string) => void;
|
||||||
|
onApplyPack: (packId: QuickPackId) => void;
|
||||||
|
onLockedClick: (source: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuickPackId = 'balanced' | 'deep-work' | 'gentle';
|
||||||
|
|
||||||
|
interface QuickPack {
|
||||||
|
id: QuickPackId;
|
||||||
|
name: string;
|
||||||
|
combo: string;
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_PACKS: QuickPack[] = [
|
||||||
|
{
|
||||||
|
id: 'balanced',
|
||||||
|
name: 'Balanced',
|
||||||
|
combo: '25/5 + Rain Focus',
|
||||||
|
locked: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deep-work',
|
||||||
|
name: 'Deep Work',
|
||||||
|
combo: '50/10 + Deep White',
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gentle',
|
||||||
|
name: 'Gentle',
|
||||||
|
combo: '25/5 + Silent',
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LockBadge = () => {
|
||||||
|
return (
|
||||||
|
<span className="absolute right-2 top-2 rounded-full border border-white/20 bg-black/46 px-1.5 py-0.5 text-[9px] font-semibold tracking-[0.08em] text-white/86">
|
||||||
|
LOCK PRO
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SectionTitle = ({ title, description }: { title: string; description: string }) => {
|
||||||
|
return (
|
||||||
|
<header className="flex items-end justify-between gap-2">
|
||||||
|
<h4 className="text-sm font-semibold text-white/90">{title}</h4>
|
||||||
|
<p className="text-[11px] text-white/56">{description}</p>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ControlCenterSheetWidget = ({
|
||||||
|
plan,
|
||||||
|
rooms,
|
||||||
|
selectedRoomId,
|
||||||
|
selectedTimerLabel,
|
||||||
|
selectedSoundPresetId,
|
||||||
|
timerPresets,
|
||||||
|
soundPresets,
|
||||||
|
onSelectRoom,
|
||||||
|
onSelectTimer,
|
||||||
|
onSelectSound,
|
||||||
|
onApplyPack,
|
||||||
|
onLockedClick,
|
||||||
|
}: ControlCenterSheetWidgetProps) => {
|
||||||
|
const isPro = plan === 'pro';
|
||||||
|
|
||||||
|
const selectedRoom = useMemo(() => {
|
||||||
|
return rooms.find((room) => room.id === selectedRoomId) ?? rooms[0];
|
||||||
|
}, [rooms, selectedRoomId]);
|
||||||
|
|
||||||
|
const selectedSound = useMemo(() => {
|
||||||
|
return soundPresets.find((preset) => preset.id === selectedSoundPresetId) ?? soundPresets[0];
|
||||||
|
}, [selectedSoundPresetId, soundPresets]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||||
|
<SectionTitle title="Scene" description={selectedRoom?.name ?? '공간'} />
|
||||||
|
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||||
|
{rooms.slice(0, 6).map((room) => {
|
||||||
|
const selected = room.id === selectedRoomId;
|
||||||
|
const locked = !isPro && PRO_LOCKED_ROOM_IDS.includes(room.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={room.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onLockedClick(`공간: ${room.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectRoom(room.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative h-24 w-[130px] shrink-0 overflow-hidden rounded-xl border text-left transition-transform hover:-translate-y-0.5',
|
||||||
|
selected ? 'border-sky-200/44 shadow-[0_8px_16px_rgba(56,189,248,0.18)]' : 'border-white/16',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div aria-hidden className="absolute inset-0 bg-cover bg-center" style={getRoomCardBackgroundStyle(room)} />
|
||||||
|
<div aria-hidden className="absolute inset-0 bg-gradient-to-t from-black/56 via-black/18 to-black/6" />
|
||||||
|
{locked ? <LockBadge /> : null}
|
||||||
|
<div className="absolute inset-x-2 bottom-2 min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-white/90">{room.name}</p>
|
||||||
|
<p className="truncate text-[11px] text-white/66">{room.vibeLabel}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||||
|
<SectionTitle title="Time" description={selectedTimerLabel} />
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{timerPresets.slice(0, 3).map((preset) => {
|
||||||
|
const selected = preset.label === selectedTimerLabel;
|
||||||
|
const locked = !isPro && PRO_LOCKED_TIMER_LABELS.includes(preset.label);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onLockedClick(`타이머: ${preset.label}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectTimer(preset.label);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative rounded-xl border px-2 py-2 text-xs transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
||||||
|
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
{locked ? <span className="ml-1 text-[9px] text-white/66">LOCK PRO</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||||
|
<SectionTitle title="Sound" description={selectedSound?.label ?? '기본'} />
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{soundPresets.slice(0, 6).map((preset) => {
|
||||||
|
const selected = preset.id === selectedSoundPresetId;
|
||||||
|
const locked = !isPro && PRO_LOCKED_SOUND_IDS.includes(preset.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={preset.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onLockedClick(`사운드: ${preset.label}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelectSound(preset.id);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
||||||
|
selected
|
||||||
|
? 'border-sky-200/42 bg-sky-200/16 text-white'
|
||||||
|
: 'border-white/18 bg-white/[0.04] text-white/74 hover:bg-white/[0.1]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
{locked ? <span className="ml-1.5 text-[10px] text-white/66">LOCK PRO</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2 rounded-2xl border border-white/14 bg-white/[0.035] p-3">
|
||||||
|
<SectionTitle title="Preset Packs" description="원탭 조합" />
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{QUICK_PACKS.map((pack) => {
|
||||||
|
const locked = !isPro && pack.locked;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={pack.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (locked) {
|
||||||
|
onLockedClick(`프리셋 팩: ${pack.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onApplyPack(pack.id);
|
||||||
|
}}
|
||||||
|
className="relative rounded-xl border border-white/16 bg-white/[0.04] px-3 py-2 text-left transition-colors hover:bg-white/[0.1]"
|
||||||
|
>
|
||||||
|
{locked ? <LockBadge /> : null}
|
||||||
|
<p className="text-sm font-medium text-white/90">{pack.name}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-white/62">{pack.combo}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -14,6 +14,7 @@ interface SpaceSideSheetProps {
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
widthClassName?: string;
|
widthClassName?: string;
|
||||||
dismissible?: boolean;
|
dismissible?: boolean;
|
||||||
|
headerAction?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SpaceSideSheet = ({
|
export const SpaceSideSheet = ({
|
||||||
@@ -25,6 +26,7 @@ export const SpaceSideSheet = ({
|
|||||||
footer,
|
footer,
|
||||||
widthClassName,
|
widthClassName,
|
||||||
dismissible = true,
|
dismissible = true,
|
||||||
|
headerAction,
|
||||||
}: SpaceSideSheetProps) => {
|
}: SpaceSideSheetProps) => {
|
||||||
const closeTimerRef = useRef<number | null>(null);
|
const closeTimerRef = useRef<number | null>(null);
|
||||||
const [shouldRender, setShouldRender] = useState(open);
|
const [shouldRender, setShouldRender] = useState(open);
|
||||||
@@ -123,16 +125,19 @@ export const SpaceSideSheet = ({
|
|||||||
<h2 className="text-[1.05rem] font-semibold tracking-tight text-white">{title}</h2>
|
<h2 className="text-[1.05rem] font-semibold tracking-tight text-white">{title}</h2>
|
||||||
{subtitle ? <p className="mt-1 text-[11px] text-white/56">{subtitle}</p> : null}
|
{subtitle ? <p className="mt-1 text-[11px] text-white/56">{subtitle}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
{dismissible ? (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{headerAction}
|
||||||
type="button"
|
{dismissible ? (
|
||||||
onClick={onClose}
|
<button
|
||||||
aria-label="닫기"
|
type="button"
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
|
onClick={onClose}
|
||||||
>
|
aria-label="닫기"
|
||||||
✕
|
className="inline-flex h-7 w-7 items-center justify-center rounded-full border border-white/14 bg-white/6 text-[12px] text-white/72 transition-colors hover:bg-white/12 hover:text-white"
|
||||||
</button>
|
>
|
||||||
) : null}
|
✕
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5 sm:px-5">{children}</div>
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3.5 sm:px-5">{children}</div>
|
||||||
|
|||||||
31
src/widgets/space-tools-dock/model/applyQuickPack.ts
Normal file
31
src/widgets/space-tools-dock/model/applyQuickPack.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
interface ApplyQuickPackParams {
|
||||||
|
packId: 'balanced' | 'deep-work' | 'gentle';
|
||||||
|
onTimerSelect: (timerLabel: string) => void;
|
||||||
|
onSelectPreset: (presetId: string) => void;
|
||||||
|
pushToast: (payload: { title: string; description?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyQuickPack = ({
|
||||||
|
packId,
|
||||||
|
onTimerSelect,
|
||||||
|
onSelectPreset,
|
||||||
|
pushToast,
|
||||||
|
}: ApplyQuickPackParams) => {
|
||||||
|
if (packId === 'balanced') {
|
||||||
|
onTimerSelect('25/5');
|
||||||
|
onSelectPreset('rain-focus');
|
||||||
|
pushToast({ title: 'Balanced 팩을 적용했어요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packId === 'deep-work') {
|
||||||
|
onTimerSelect('50/10');
|
||||||
|
onSelectPreset('deep-white');
|
||||||
|
pushToast({ title: 'Deep Work 팩을 적용했어요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onTimerSelect('25/5');
|
||||||
|
onSelectPreset('silent');
|
||||||
|
pushToast({ title: 'Gentle 팩을 적용했어요.' });
|
||||||
|
};
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
export type SpaceAnchorPopoverId = 'sound' | 'notes';
|
export type SpaceAnchorPopoverId = 'sound' | 'notes';
|
||||||
export type SpaceUtilityPanelId = 'settings' | 'inbox' | 'stats';
|
export type SpaceUtilityPanelId = 'control-center' | 'inbox' | 'manage-plan' | 'paywall';
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
import { useEffect, useMemo, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import type { PlanTier } from '@/entities/plan';
|
||||||
import type { RoomTheme } from '@/entities/room';
|
import type { RoomTheme } from '@/entities/room';
|
||||||
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
import { SOUND_PRESETS, type RecentThought, type TimerPreset } from '@/entities/session';
|
||||||
import { ExitHoldButton } from '@/features/exit-hold';
|
import { ExitHoldButton } from '@/features/exit-hold';
|
||||||
|
import { ManagePlanSheetContent, PaywallSheetContent } from '@/features/paywall-sheet';
|
||||||
|
import { PlanPill } from '@/features/plan-pill';
|
||||||
import { useToast } from '@/shared/ui';
|
import { useToast } from '@/shared/ui';
|
||||||
import { cn } from '@/shared/lib/cn';
|
import { cn } from '@/shared/lib/cn';
|
||||||
|
import { ControlCenterSheetWidget } from '@/widgets/control-center-sheet';
|
||||||
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
import { SpaceSideSheet } from '@/widgets/space-sheet-shell';
|
||||||
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
|
import type { SpaceAnchorPopoverId, SpaceUtilityPanelId } from '../model/types';
|
||||||
|
import { applyQuickPack } from '../model/applyQuickPack';
|
||||||
|
import { ANCHOR_ICON, formatThoughtCount, RAIL_ICON, UTILITY_PANEL_TITLE } from './constants';
|
||||||
import { InboxToolPanel } from './panels/InboxToolPanel';
|
import { InboxToolPanel } from './panels/InboxToolPanel';
|
||||||
import { SettingsToolPanel } from './panels/SettingsToolPanel';
|
|
||||||
import { StatsToolPanel } from './panels/StatsToolPanel';
|
|
||||||
|
|
||||||
interface SpaceToolsDockWidgetProps {
|
interface SpaceToolsDockWidgetProps {
|
||||||
isFocusMode: boolean;
|
isFocusMode: boolean;
|
||||||
rooms: RoomTheme[];
|
rooms: RoomTheme[];
|
||||||
@@ -24,61 +26,15 @@ interface SpaceToolsDockWidgetProps {
|
|||||||
onRoomSelect: (roomId: string) => void;
|
onRoomSelect: (roomId: string) => void;
|
||||||
onTimerSelect: (timerLabel: string) => void;
|
onTimerSelect: (timerLabel: string) => void;
|
||||||
onSelectPreset: (presetId: string) => void;
|
onSelectPreset: (presetId: string) => void;
|
||||||
onCaptureThought: (note: string) => void;
|
onCaptureThought: (note: string) => RecentThought | null;
|
||||||
onClearInbox: () => void;
|
onDeleteThought: (thoughtId: string) => RecentThought | null;
|
||||||
|
onSetThoughtCompleted: (thoughtId: string, isCompleted: boolean) => RecentThought | null;
|
||||||
|
onRestoreThought: (thought: RecentThought) => void;
|
||||||
|
onRestoreThoughts: (thoughts: RecentThought[]) => void;
|
||||||
|
onClearInbox: () => RecentThought[];
|
||||||
onExitRequested: () => void;
|
onExitRequested: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANCHOR_ICON = {
|
|
||||||
sound: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="h-4 w-4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
|
|
||||||
<path d="M16 9a4 4 0 0 1 0 6" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
notes: (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
className="h-4 w-4"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.8"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M6 4h9l3 3v13H6z" />
|
|
||||||
<path d="M15 4v4h4" />
|
|
||||||
<path d="M9 12h6M9 16h4" />
|
|
||||||
</svg>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
|
|
||||||
inbox: '인박스',
|
|
||||||
stats: '집중 요약',
|
|
||||||
settings: '설정',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatThoughtCount = (count: number) => {
|
|
||||||
if (count < 1) {
|
|
||||||
return '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (count > 9) {
|
|
||||||
return '9+';
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(count);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SpaceToolsDockWidget = ({
|
export const SpaceToolsDockWidget = ({
|
||||||
isFocusMode,
|
isFocusMode,
|
||||||
rooms,
|
rooms,
|
||||||
@@ -92,6 +48,10 @@ export const SpaceToolsDockWidget = ({
|
|||||||
onTimerSelect,
|
onTimerSelect,
|
||||||
onSelectPreset,
|
onSelectPreset,
|
||||||
onCaptureThought,
|
onCaptureThought,
|
||||||
|
onDeleteThought,
|
||||||
|
onSetThoughtCompleted,
|
||||||
|
onRestoreThought,
|
||||||
|
onRestoreThoughts,
|
||||||
onClearInbox,
|
onClearInbox,
|
||||||
onExitRequested,
|
onExitRequested,
|
||||||
}: SpaceToolsDockWidgetProps) => {
|
}: SpaceToolsDockWidgetProps) => {
|
||||||
@@ -99,6 +59,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
const [openPopover, setOpenPopover] = useState<SpaceAnchorPopoverId | null>(null);
|
||||||
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
const [utilityPanel, setUtilityPanel] = useState<SpaceUtilityPanelId | null>(null);
|
||||||
const [noteDraft, setNoteDraft] = useState('');
|
const [noteDraft, setNoteDraft] = useState('');
|
||||||
|
const [plan, setPlan] = useState<PlanTier>('normal');
|
||||||
const [isIdle, setIdle] = useState(false);
|
const [isIdle, setIdle] = useState(false);
|
||||||
|
|
||||||
const selectedSoundLabel = useMemo(() => {
|
const selectedSoundLabel = useMemo(() => {
|
||||||
@@ -112,7 +73,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEscape = (event: KeyboardEvent) => {
|
const handleEscape = (event: globalThis.KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setOpenPopover(null);
|
setOpenPopover(null);
|
||||||
}
|
}
|
||||||
@@ -176,11 +137,126 @@ export const SpaceToolsDockWidget = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onCaptureThought(trimmedNote);
|
const addedThought = onCaptureThought(trimmedNote);
|
||||||
|
|
||||||
|
if (!addedThought) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setNoteDraft('');
|
setNoteDraft('');
|
||||||
pushToast({ title: '메모를 잠깐 주차했어요.' });
|
pushToast({
|
||||||
|
title: '인박스에 저장됨',
|
||||||
|
durationMs: 4200,
|
||||||
|
action: {
|
||||||
|
label: '실행취소',
|
||||||
|
onClick: () => {
|
||||||
|
const removed = onDeleteThought(addedThought.id);
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({ title: '저장 취소됨' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNoteKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key !== 'Enter') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleNoteSubmit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxComplete = (thought: RecentThought) => {
|
||||||
|
const previousThought = onSetThoughtCompleted(thought.id, !thought.isCompleted);
|
||||||
|
|
||||||
|
if (!previousThought) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const willBeCompleted = !thought.isCompleted;
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
title: willBeCompleted ? '완료 처리됨' : '완료 해제됨',
|
||||||
|
durationMs: 4200,
|
||||||
|
action: {
|
||||||
|
label: '실행취소',
|
||||||
|
onClick: () => {
|
||||||
|
onRestoreThought(previousThought);
|
||||||
|
pushToast({ title: willBeCompleted ? '완료 처리를 취소했어요.' : '완료 해제를 취소했어요.' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxDelete = (thought: RecentThought) => {
|
||||||
|
const removedThought = onDeleteThought(thought.id);
|
||||||
|
|
||||||
|
if (!removedThought) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
title: '삭제됨',
|
||||||
|
durationMs: 4200,
|
||||||
|
action: {
|
||||||
|
label: '실행취소',
|
||||||
|
onClick: () => {
|
||||||
|
onRestoreThought(removedThought);
|
||||||
|
pushToast({ title: '삭제를 취소했어요.' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInboxClear = () => {
|
||||||
|
const snapshot = onClearInbox();
|
||||||
|
|
||||||
|
if (snapshot.length === 0) {
|
||||||
|
pushToast({ title: '인박스가 비어 있어요.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pushToast({
|
||||||
|
title: '모두 비워짐',
|
||||||
|
durationMs: 4200,
|
||||||
|
action: {
|
||||||
|
label: '실행취소',
|
||||||
|
onClick: () => {
|
||||||
|
onRestoreThoughts(snapshot);
|
||||||
|
pushToast({ title: '인박스를 복구했어요.' });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlanPillClick = () => {
|
||||||
|
if (plan === 'pro') {
|
||||||
|
openUtilityPanel('manage-plan');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openUtilityPanel('paywall');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLockedClick = (source: string) => {
|
||||||
|
pushToast({ title: `${source}은(는) PRO 기능이에요.` });
|
||||||
|
openUtilityPanel('paywall');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartPro = () => {
|
||||||
|
setPlan('pro');
|
||||||
|
pushToast({ title: '결제(더미)' });
|
||||||
|
openUtilityPanel('control-center');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyPack = (packId: 'balanced' | 'deep-work' | 'gentle') =>
|
||||||
|
applyQuickPack({ packId, onTimerSelect, onSelectPreset, pushToast });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{openPopover ? (
|
{openPopover ? (
|
||||||
@@ -206,6 +282,41 @@ export const SpaceToolsDockWidget = ({
|
|||||||
|
|
||||||
{isFocusMode ? (
|
{isFocusMode ? (
|
||||||
<>
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed z-30 transition-opacity right-[calc(env(safe-area-inset-right,0px)+0.75rem)] top-1/2 -translate-y-1/2',
|
||||||
|
isIdle ? 'opacity-34' : 'opacity-78',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="rounded-2xl border border-white/14 bg-black/22 p-1.5 backdrop-blur-md">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="인박스 열기"
|
||||||
|
title="인박스"
|
||||||
|
onClick={() => openUtilityPanel('inbox')}
|
||||||
|
className="relative inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{RAIL_ICON.inbox}
|
||||||
|
{thoughtCount > 0 ? (
|
||||||
|
<span className="absolute -right-1 -top-1 inline-flex min-w-[0.95rem] items-center justify-center rounded-full bg-sky-200/28 px-1 py-0.5 text-[8px] font-semibold text-sky-50">
|
||||||
|
{formatThoughtCount(thoughtCount)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Quick Controls 열기"
|
||||||
|
title="Quick Controls"
|
||||||
|
onClick={() => openUtilityPanel('control-center')}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-xl border border-white/12 bg-white/[0.03] text-white/82 transition-colors hover:bg-white/10"
|
||||||
|
>
|
||||||
|
{RAIL_ICON.controlCenter}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed z-30 transition-opacity left-[calc(env(safe-area-inset-left,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
|
'fixed z-30 transition-opacity left-[calc(env(safe-area-inset-left,0px)+0.75rem)] bottom-[calc(env(safe-area-inset-bottom,0px)+5.25rem)] sm:bottom-[calc(env(safe-area-inset-bottom,0px)+0.75rem)]',
|
||||||
@@ -237,7 +348,8 @@ export const SpaceToolsDockWidget = ({
|
|||||||
<input
|
<input
|
||||||
value={noteDraft}
|
value={noteDraft}
|
||||||
onChange={(event) => setNoteDraft(event.target.value)}
|
onChange={(event) => setNoteDraft(event.target.value)}
|
||||||
placeholder="한 줄 메모"
|
onKeyDown={handleNoteKeyDown}
|
||||||
|
placeholder="떠오른 생각을 잠깐 주차…"
|
||||||
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
|
className="h-8 min-w-0 flex-1 rounded-lg border border-white/14 bg-white/[0.04] px-2.5 text-xs text-white placeholder:text-white/38 focus:border-sky-200/42 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -248,44 +360,7 @@ export const SpaceToolsDockWidget = ({
|
|||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-2 space-y-1.5">
|
<p className="mt-2 text-[11px] text-white/52">나중에 인박스에서 정리해요.</p>
|
||||||
{thoughts.slice(0, 3).map((thought) => (
|
|
||||||
<li
|
|
||||||
key={thought.id}
|
|
||||||
className="truncate rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-[11px] text-white/74"
|
|
||||||
>
|
|
||||||
{thought.text}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{thoughts.length === 0 ? (
|
|
||||||
<li className="rounded-lg border border-white/10 bg-white/[0.03] px-2 py-1.5 text-[11px] text-white/56">
|
|
||||||
아직 메모가 없어요.
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
<div className="mt-2 flex items-center justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openUtilityPanel('inbox')}
|
|
||||||
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
|
|
||||||
>
|
|
||||||
인박스
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openUtilityPanel('stats')}
|
|
||||||
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
|
|
||||||
>
|
|
||||||
통계
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openUtilityPanel('settings')}
|
|
||||||
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
|
|
||||||
>
|
|
||||||
설정
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -345,10 +420,10 @@ export const SpaceToolsDockWidget = ({
|
|||||||
<div className="mt-2 flex justify-end">
|
<div className="mt-2 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openUtilityPanel('settings')}
|
onClick={() => openUtilityPanel('control-center')}
|
||||||
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
|
className="text-[11px] text-white/62 transition-colors hover:text-white/88"
|
||||||
>
|
>
|
||||||
고급 옵션
|
Control Center
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,25 +436,23 @@ export const SpaceToolsDockWidget = ({
|
|||||||
<SpaceSideSheet
|
<SpaceSideSheet
|
||||||
open={utilityPanel !== null}
|
open={utilityPanel !== null}
|
||||||
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
|
title={utilityPanel ? UTILITY_PANEL_TITLE[utilityPanel] : ''}
|
||||||
|
subtitle={utilityPanel === 'control-center' ? '배경 · 타이머 · 사운드를 그 자리에서 바꿔요.' : undefined}
|
||||||
|
headerAction={
|
||||||
|
utilityPanel === 'control-center' ? (
|
||||||
|
<PlanPill plan={plan} onClick={handlePlanPillClick} />
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
onClose={() => setUtilityPanel(null)}
|
onClose={() => setUtilityPanel(null)}
|
||||||
>
|
>
|
||||||
{utilityPanel === 'inbox' ? (
|
{utilityPanel === 'control-center' ? (
|
||||||
<InboxToolPanel
|
<ControlCenterSheetWidget
|
||||||
thoughts={thoughts}
|
plan={plan}
|
||||||
onClear={() => {
|
|
||||||
onClearInbox();
|
|
||||||
pushToast({ title: '인박스를 비웠어요 (더미)' });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{utilityPanel === 'stats' ? <StatsToolPanel /> : null}
|
|
||||||
{utilityPanel === 'settings' ? (
|
|
||||||
<SettingsToolPanel
|
|
||||||
rooms={rooms}
|
rooms={rooms}
|
||||||
selectedRoomId={selectedRoomId}
|
selectedRoomId={selectedRoomId}
|
||||||
selectedTimerLabel={selectedTimerLabel}
|
selectedTimerLabel={selectedTimerLabel}
|
||||||
|
selectedSoundPresetId={selectedPresetId}
|
||||||
timerPresets={timerPresets}
|
timerPresets={timerPresets}
|
||||||
|
soundPresets={SOUND_PRESETS}
|
||||||
onSelectRoom={(roomId) => {
|
onSelectRoom={(roomId) => {
|
||||||
onRoomSelect(roomId);
|
onRoomSelect(roomId);
|
||||||
pushToast({ title: '공간을 바꿨어요.' });
|
pushToast({ title: '공간을 바꿨어요.' });
|
||||||
@@ -388,6 +461,36 @@ export const SpaceToolsDockWidget = ({
|
|||||||
onTimerSelect(label);
|
onTimerSelect(label);
|
||||||
pushToast({ title: `타이머를 ${label}로 바꿨어요.` });
|
pushToast({ title: `타이머를 ${label}로 바꿨어요.` });
|
||||||
}}
|
}}
|
||||||
|
onSelectSound={(presetId) => {
|
||||||
|
onSelectPreset(presetId);
|
||||||
|
pushToast({ title: '사운드를 바꿨어요.' });
|
||||||
|
}}
|
||||||
|
onApplyPack={handleApplyPack}
|
||||||
|
onLockedClick={handleLockedClick}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{utilityPanel === 'inbox' ? (
|
||||||
|
<InboxToolPanel
|
||||||
|
thoughts={thoughts}
|
||||||
|
onCompleteThought={handleInboxComplete}
|
||||||
|
onDeleteThought={handleInboxDelete}
|
||||||
|
onClear={handleInboxClear}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{utilityPanel === 'paywall' ? (
|
||||||
|
<PaywallSheetContent
|
||||||
|
onStartPro={handleStartPro}
|
||||||
|
onClose={() => setUtilityPanel(null)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{utilityPanel === 'manage-plan' ? (
|
||||||
|
<ManagePlanSheetContent
|
||||||
|
onClose={() => setUtilityPanel(null)}
|
||||||
|
onManage={() => pushToast({ title: '구독 관리(더미)' })}
|
||||||
|
onRestore={() => pushToast({ title: '구매 복원(더미)' })}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</SpaceSideSheet>
|
</SpaceSideSheet>
|
||||||
|
|||||||
85
src/widgets/space-tools-dock/ui/constants.tsx
Normal file
85
src/widgets/space-tools-dock/ui/constants.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type { SpaceUtilityPanelId } from '../model/types';
|
||||||
|
|
||||||
|
export const ANCHOR_ICON = {
|
||||||
|
sound: (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="h-4 w-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M4 13V11a2 2 0 0 1 2-2h2l3-3h2v12h-2l-3-3H6a2 2 0 0 1-2-2Z" />
|
||||||
|
<path d="M16 9a4 4 0 0 1 0 6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
notes: (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="h-4 w-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M6 4h9l3 3v13H6z" />
|
||||||
|
<path d="M15 4v4h4" />
|
||||||
|
<path d="M9 12h6M9 16h4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RAIL_ICON = {
|
||||||
|
controlCenter: (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="h-4 w-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M4 7h16M4 12h16M4 17h16" />
|
||||||
|
<circle cx="8" cy="7" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="16" cy="12" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
<circle cx="11" cy="17" r="1.2" fill="currentColor" stroke="none" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
inbox: (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className="h-4 w-4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect x="4" y="6" width="16" height="12" rx="2.5" />
|
||||||
|
<path d="m5 8 7 5 7-5" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UTILITY_PANEL_TITLE: Record<SpaceUtilityPanelId, string> = {
|
||||||
|
'control-center': 'Quick Controls',
|
||||||
|
inbox: '인박스',
|
||||||
|
paywall: 'PRO',
|
||||||
|
'manage-plan': '플랜 관리',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatThoughtCount = (count: number) => {
|
||||||
|
if (count < 1) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 9) {
|
||||||
|
return '9+';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(count);
|
||||||
|
};
|
||||||
@@ -1,25 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import type { RecentThought } from '@/entities/session';
|
import type { RecentThought } from '@/entities/session';
|
||||||
import { InboxList } from '@/features/inbox';
|
import { InboxList } from '@/features/inbox';
|
||||||
|
|
||||||
interface InboxToolPanelProps {
|
interface InboxToolPanelProps {
|
||||||
thoughts: RecentThought[];
|
thoughts: RecentThought[];
|
||||||
|
onCompleteThought: (thought: RecentThought) => void;
|
||||||
|
onDeleteThought: (thought: RecentThought) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InboxToolPanel = ({ thoughts, onClear }: InboxToolPanelProps) => {
|
export const InboxToolPanel = ({
|
||||||
|
thoughts,
|
||||||
|
onCompleteThought,
|
||||||
|
onDeleteThought,
|
||||||
|
onClear,
|
||||||
|
}: InboxToolPanelProps) => {
|
||||||
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3.5">
|
<div className="relative space-y-3.5">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-xs text-white/58">나중에 모아보는 읽기 전용 인박스</p>
|
<p className="text-xs text-white/58">나중에 모아보는 읽기 전용 인박스</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClear}
|
onClick={() => setConfirmOpen(true)}
|
||||||
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
||||||
>
|
>
|
||||||
모두 비우기
|
모두 비우기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<InboxList thoughts={thoughts} />
|
<InboxList
|
||||||
|
thoughts={thoughts}
|
||||||
|
onCompleteThought={onCompleteThought}
|
||||||
|
onDeleteThought={onDeleteThought}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{confirmOpen ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl bg-slate-950/70 px-3 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-[272px] rounded-2xl border border-white/14 bg-slate-900/92 p-3.5 shadow-xl shadow-slate-950/45">
|
||||||
|
<p className="text-sm font-medium text-white/92">정말 인박스를 비울까요?</p>
|
||||||
|
<p className="mt-1 text-[11px] text-white/60">실수라면 토스트에서 실행취소할 수 있어요.</p>
|
||||||
|
<div className="mt-3 flex justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmOpen(false)}
|
||||||
|
className="rounded-full border border-white/20 bg-white/8 px-2.5 py-1 text-[11px] text-white/74 transition-colors hover:bg-white/14 hover:text-white"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onClear();
|
||||||
|
setConfirmOpen(false);
|
||||||
|
}}
|
||||||
|
className="rounded-full border border-rose-200/34 bg-rose-200/16 px-2.5 py-1 text-[11px] text-rose-100/92 transition-colors hover:bg-rose-200/24"
|
||||||
|
>
|
||||||
|
비우기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -55,7 +55,16 @@ const resolveInitialTimerLabel = (timerLabelFromQuery: string | null) => {
|
|||||||
export const SpaceWorkspaceWidget = () => {
|
export const SpaceWorkspaceWidget = () => {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const { thoughts, thoughtCount, addThought, clearThoughts } = useThoughtInbox();
|
const {
|
||||||
|
thoughts,
|
||||||
|
thoughtCount,
|
||||||
|
addThought,
|
||||||
|
removeThought,
|
||||||
|
clearThoughts,
|
||||||
|
restoreThought,
|
||||||
|
restoreThoughts,
|
||||||
|
setThoughtCompleted,
|
||||||
|
} = useThoughtInbox();
|
||||||
|
|
||||||
const initialRoomId = resolveInitialRoomId(searchParams.get('room'));
|
const initialRoomId = resolveInitialRoomId(searchParams.get('room'));
|
||||||
const initialGoal = searchParams.get('goal')?.trim() ?? '';
|
const initialGoal = searchParams.get('goal')?.trim() ?? '';
|
||||||
@@ -204,6 +213,10 @@ export const SpaceWorkspaceWidget = () => {
|
|||||||
onTimerSelect={setSelectedTimerLabel}
|
onTimerSelect={setSelectedTimerLabel}
|
||||||
onSelectPreset={setSelectedPresetId}
|
onSelectPreset={setSelectedPresetId}
|
||||||
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
|
onCaptureThought={(note) => addThought(note, selectedRoom.name)}
|
||||||
|
onDeleteThought={removeThought}
|
||||||
|
onSetThoughtCompleted={setThoughtCompleted}
|
||||||
|
onRestoreThought={restoreThought}
|
||||||
|
onRestoreThoughts={restoreThoughts}
|
||||||
onClearInbox={clearThoughts}
|
onClearInbox={clearThoughts}
|
||||||
onExitRequested={handleExitRequested}
|
onExitRequested={handleExitRequested}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user