feat(fsd): 허브·스페이스 중심 UI 목업 구조로 재편
맥락: - 기존 라우트/컴포넌트 구조를 FSD 기준으로 정리하고, /app 허브와 /space 집중 화면 중심의 목업 흐름을 구성하기 위해 변경사항: - App Router 구조를 /landing, /app, /space, /stats, /settings 중심으로 재배치 - entities/session/room/user 더미 데이터와 타입 정의 추가 - features(커스텀 입장, 룸 선택, 체크인, 리액션, 30초 리스타트 등) 단위로 로직 분리 - widgets(허브, 룸 갤러리, 타이머 HUD, 툴 도크 등) 조합형 UI 추가 - shared 공용 UI(Button/Chip/Modal/Toast 등) 및 유틸(cn/useReducedMotion) 정비 - 로그인 후 이동 경로를 /dashboard 에서 /app 으로 변경 - README를 현재 프로젝트 구조/라우트/구현 범위 기준으로 갱신 검증: - npx tsc --noEmit 세션-상태: 허브·스페이스 목업이 FSD 레이어로 동작 가능하도록 정리됨 세션-다음: /space 상단 및 도크의 인원 수 카피를 분위기형 카피로 후속 정리 세션-리스크: build는 네트워크 환경에서 Google Fonts fetch 실패 가능
This commit is contained in:
1
src/widgets/app-hub/index.ts
Normal file
1
src/widgets/app-hub/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/AppHubWidget';
|
||||
147
src/widgets/app-hub/ui/AppHubWidget.tsx
Normal file
147
src/widgets/app-hub/ui/AppHubWidget.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getRoomBackgroundStyle, ROOM_THEMES } from '@/entities/room';
|
||||
import {
|
||||
GOAL_CHIPS,
|
||||
SOUND_PRESETS,
|
||||
TIMER_PRESETS,
|
||||
TODAY_ONE_LINER,
|
||||
type GoalChip,
|
||||
} from '@/entities/session';
|
||||
import { MOCK_VIEWER } from '@/entities/user';
|
||||
import { type CustomEntrySelection } from '@/features/custom-entry-modal';
|
||||
import { useRoomSelection } from '@/features/room-select';
|
||||
import { useToast } from '@/shared/ui';
|
||||
import { AppTopBar } from '@/widgets/app-top-bar/ui/AppTopBar';
|
||||
import { CustomEntryWidget } from '@/widgets/custom-entry-widget/ui/CustomEntryWidget';
|
||||
import { RoomsGalleryWidget } from '@/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget';
|
||||
import { StartRitualWidget } from '@/widgets/start-ritual-widget/ui/StartRitualWidget';
|
||||
|
||||
const buildSpaceQuery = (
|
||||
roomId: string,
|
||||
goalInput: string,
|
||||
soundPresetId: string,
|
||||
timerLabel: string,
|
||||
) => {
|
||||
const params = new URLSearchParams({
|
||||
room: roomId,
|
||||
sound: soundPresetId,
|
||||
timer: timerLabel,
|
||||
});
|
||||
|
||||
const normalizedGoal = goalInput.trim();
|
||||
|
||||
if (normalizedGoal) {
|
||||
params.set('goal', normalizedGoal);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
};
|
||||
|
||||
export const AppHubWidget = () => {
|
||||
const router = useRouter();
|
||||
const { pushToast } = useToast();
|
||||
const { selectedRoom, selectedRoomId, selectRoom } = useRoomSelection();
|
||||
|
||||
const [goalInput, setGoalInput] = useState('');
|
||||
const [selectedGoalId, setSelectedGoalId] = useState<string | null>(null);
|
||||
const [isCustomEntryOpen, setCustomEntryOpen] = useState(false);
|
||||
|
||||
const enterSpace = (soundPresetId: string, timerLabel: string) => {
|
||||
const query = buildSpaceQuery(
|
||||
selectedRoomId,
|
||||
goalInput,
|
||||
soundPresetId,
|
||||
timerLabel,
|
||||
);
|
||||
|
||||
router.push(`/space?${query}`);
|
||||
};
|
||||
|
||||
const handleGoalChipSelect = (chip: GoalChip) => {
|
||||
setSelectedGoalId(chip.id);
|
||||
setGoalInput(chip.label);
|
||||
};
|
||||
|
||||
const handleGoalInputChange = (value: string) => {
|
||||
setGoalInput(value);
|
||||
|
||||
if (selectedGoalId && value.trim() === '') {
|
||||
setSelectedGoalId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickEnter = () => {
|
||||
enterSpace(SOUND_PRESETS[0].id, TIMER_PRESETS[0].label);
|
||||
};
|
||||
|
||||
const handleCustomEnter = (selection: CustomEntrySelection) => {
|
||||
setCustomEntryOpen(false);
|
||||
enterSpace(selection.soundId, selection.timerLabel);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
pushToast({
|
||||
title: '로그아웃은 목업에서만 동작해요',
|
||||
description: '실제 인증 로직은 연결하지 않았습니다.',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0"
|
||||
style={getRoomBackgroundStyle(selectedRoom)}
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 bg-slate-950/60" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 opacity-40"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 2px)",
|
||||
mixBlendMode: 'soft-light',
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col">
|
||||
<AppTopBar
|
||||
user={MOCK_VIEWER}
|
||||
oneLiner={TODAY_ONE_LINER}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<main className="mx-auto w-full max-w-7xl flex-1 px-4 pb-8 pt-6 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(320px,420px),1fr]">
|
||||
<StartRitualWidget
|
||||
goalInput={goalInput}
|
||||
selectedGoalId={selectedGoalId}
|
||||
goalChips={GOAL_CHIPS}
|
||||
onGoalInputChange={handleGoalInputChange}
|
||||
onGoalChipSelect={handleGoalChipSelect}
|
||||
onQuickEnter={handleQuickEnter}
|
||||
onOpenCustomEntry={() => setCustomEntryOpen(true)}
|
||||
/>
|
||||
|
||||
<RoomsGalleryWidget
|
||||
rooms={ROOM_THEMES}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onRoomSelect={selectRoom}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CustomEntryWidget
|
||||
isOpen={isCustomEntryOpen}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={selectRoom}
|
||||
onClose={() => setCustomEntryOpen(false)}
|
||||
onEnter={handleCustomEnter}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/widgets/app-top-bar/index.ts
Normal file
1
src/widgets/app-top-bar/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/AppTopBar';
|
||||
27
src/widgets/app-top-bar/ui/AppTopBar.tsx
Normal file
27
src/widgets/app-top-bar/ui/AppTopBar.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { ViewerProfile } from '@/entities/user';
|
||||
import { ProfileMenu } from '@/features/profile-menu';
|
||||
|
||||
interface AppTopBarProps {
|
||||
user: ViewerProfile;
|
||||
oneLiner: string;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const AppTopBar = ({ user, oneLiner, onLogout }: AppTopBarProps) => {
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-white/10 bg-slate-950/35 px-4 py-3 backdrop-blur-lg sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-7xl items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/30 bg-white/10 text-xs font-semibold text-white">
|
||||
V
|
||||
</span>
|
||||
<p className="text-sm font-semibold tracking-tight text-white">VibeRoom</p>
|
||||
</div>
|
||||
|
||||
<p className="hidden text-center text-sm text-white/75 md:block">{oneLiner}</p>
|
||||
|
||||
<ProfileMenu user={user} onLogout={onLogout} />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
1
src/widgets/custom-entry-widget/index.ts
Normal file
1
src/widgets/custom-entry-widget/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/CustomEntryWidget';
|
||||
30
src/widgets/custom-entry-widget/ui/CustomEntryWidget.tsx
Normal file
30
src/widgets/custom-entry-widget/ui/CustomEntryWidget.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
type CustomEntrySelection,
|
||||
CustomEntryModal,
|
||||
} from '@/features/custom-entry-modal';
|
||||
|
||||
interface CustomEntryWidgetProps {
|
||||
isOpen: boolean;
|
||||
selectedRoomId: string;
|
||||
onSelectRoom: (roomId: string) => void;
|
||||
onClose: () => void;
|
||||
onEnter: (selection: CustomEntrySelection) => void;
|
||||
}
|
||||
|
||||
export const CustomEntryWidget = ({
|
||||
isOpen,
|
||||
selectedRoomId,
|
||||
onSelectRoom,
|
||||
onClose,
|
||||
onEnter,
|
||||
}: CustomEntryWidgetProps) => {
|
||||
return (
|
||||
<CustomEntryModal
|
||||
isOpen={isOpen}
|
||||
selectedRoomId={selectedRoomId}
|
||||
onSelectRoom={onSelectRoom}
|
||||
onClose={onClose}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
1
src/widgets/notes-sheet/index.ts
Normal file
1
src/widgets/notes-sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/NotesSheetWidget';
|
||||
37
src/widgets/notes-sheet/ui/NotesSheetWidget.tsx
Normal file
37
src/widgets/notes-sheet/ui/NotesSheetWidget.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { DistractionDumpNotesContent } from '@/features/distraction-dump';
|
||||
|
||||
interface NotesSheetWidgetProps {
|
||||
onClose: () => void;
|
||||
onNoteAdded?: (note: string) => void;
|
||||
onNoteRemoved?: () => void;
|
||||
}
|
||||
|
||||
export const NotesSheetWidget = ({
|
||||
onClose,
|
||||
onNoteAdded,
|
||||
onNoteRemoved,
|
||||
}: NotesSheetWidgetProps) => {
|
||||
return (
|
||||
<aside className="fixed bottom-4 right-16 top-4 z-40 w-[min(92vw,340px)] animate-[sheet-in_220ms_ease-out] motion-reduce:animate-none">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/20 bg-slate-950/72 shadow-[0_18px_60px_rgba(2,6,23,0.52)] backdrop-blur-xl">
|
||||
<header className="flex items-center justify-between border-b border-white/12 px-4 py-4">
|
||||
<h2 className="text-base font-semibold text-white">떠오른 생각</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/20 px-2 py-1 text-xs text-white/75 transition hover:bg-white/12 hover:text-white"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<DistractionDumpNotesContent
|
||||
onNoteAdded={onNoteAdded}
|
||||
onNoteRemoved={onNoteRemoved}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
1
src/widgets/quick-sheet/index.ts
Normal file
1
src/widgets/quick-sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/QuickSheetWidget';
|
||||
57
src/widgets/quick-sheet/ui/QuickSheetWidget.tsx
Normal file
57
src/widgets/quick-sheet/ui/QuickSheetWidget.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
interface QuickSheetWidgetProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const QuickSheetWidget = ({ onClose }: QuickSheetWidgetProps) => {
|
||||
const [immersionMode, setImmersionMode] = useState(false);
|
||||
const [minimalNotice, setMinimalNotice] = useState(false);
|
||||
|
||||
return (
|
||||
<aside className="fixed bottom-4 right-16 top-4 z-40 w-[min(92vw,320px)] animate-[sheet-in_220ms_ease-out] motion-reduce:animate-none">
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/20 bg-slate-950/72 shadow-[0_18px_60px_rgba(2,6,23,0.52)] backdrop-blur-xl">
|
||||
<header className="flex items-center justify-between border-b border-white/12 px-4 py-4">
|
||||
<h2 className="text-base font-semibold text-white">Quick</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/20 px-2 py-1 text-xs text-white/75 transition hover:bg-white/12 hover:text-white"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="space-y-3 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={immersionMode}
|
||||
onClick={() => setImmersionMode((current) => !current)}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2 text-sm text-white/86"
|
||||
>
|
||||
<span>몰입 모드</span>
|
||||
<span>{immersionMode ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={minimalNotice}
|
||||
onClick={() => setMinimalNotice((current) => !current)}
|
||||
className="flex w-full items-center justify-between rounded-xl border border-white/16 bg-white/6 px-3 py-2 text-sm text-white/86"
|
||||
>
|
||||
<span>알림 최소화</span>
|
||||
<span>{minimalNotice ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-white/58">
|
||||
빠른 옵션 UI 목업입니다. 실제 동작은 연결하지 않았습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
1
src/widgets/room-sheet/index.ts
Normal file
1
src/widgets/room-sheet/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/RoomSheetWidget';
|
||||
98
src/widgets/room-sheet/ui/RoomSheetWidget.tsx
Normal file
98
src/widgets/room-sheet/ui/RoomSheetWidget.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { RoomPresence } from '@/entities/room';
|
||||
import type { CheckInPhrase, ReactionOption } from '@/entities/session';
|
||||
import { CompactCheckInChips } from '@/features/check-in';
|
||||
import { ReactionIconRow } from '@/features/reactions';
|
||||
import { Chip } from '@/shared/ui';
|
||||
|
||||
interface RoomSheetWidgetProps {
|
||||
roomName: string;
|
||||
activeMembers: number;
|
||||
presence: RoomPresence;
|
||||
checkInPhrases: CheckInPhrase[];
|
||||
reactions: ReactionOption[];
|
||||
lastCheckIn: string | null;
|
||||
onClose: () => void;
|
||||
onCheckIn: (message: string) => void;
|
||||
onReaction: (reaction: ReactionOption) => void;
|
||||
}
|
||||
|
||||
export const RoomSheetWidget = ({
|
||||
roomName,
|
||||
activeMembers,
|
||||
presence,
|
||||
checkInPhrases,
|
||||
reactions,
|
||||
lastCheckIn,
|
||||
onClose,
|
||||
onCheckIn,
|
||||
onReaction,
|
||||
}: RoomSheetWidgetProps) => {
|
||||
return (
|
||||
<aside
|
||||
className="fixed bottom-4 right-16 top-4 z-40 w-[min(92vw,340px)] animate-[sheet-in_220ms_ease-out] motion-reduce:animate-none"
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-2xl border border-white/20 bg-slate-950/72 shadow-[0_18px_60px_rgba(2,6,23,0.52)] backdrop-blur-xl">
|
||||
<header className="space-y-3 border-b border-white/12 px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">{roomName}</h2>
|
||||
<p className="text-xs text-white/70">현재 {activeMembers}명 집중 중</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-white/20 px-2 py-1 text-xs text-white/75 transition hover:bg-white/12 hover:text-white"
|
||||
>
|
||||
X
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Chip tone="accent" className="!cursor-default !px-2 !py-1 text-[11px]">
|
||||
Focus {presence.focus}
|
||||
</Chip>
|
||||
<Chip className="!cursor-default !px-2 !py-1 text-[11px]">
|
||||
Break {presence.break}
|
||||
</Chip>
|
||||
<Chip className="!cursor-default !px-2 !py-1 text-[11px]">
|
||||
Away {presence.away}
|
||||
</Chip>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-white/12 bg-slate-900/45 p-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-white/58">
|
||||
Check-in
|
||||
</p>
|
||||
<CompactCheckInChips
|
||||
phrases={checkInPhrases}
|
||||
onCheckIn={onCheckIn}
|
||||
collapsedCount={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-[11px] font-medium uppercase tracking-[0.1em] text-white/58">
|
||||
Reactions
|
||||
</p>
|
||||
<ReactionIconRow reactions={reactions} onReact={onReaction} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 px-4 py-4">
|
||||
<p className="text-xs text-white/70">
|
||||
마지막 체크인:{' '}
|
||||
<span className="font-medium text-white">
|
||||
{lastCheckIn ?? '아직 체크인이 없습니다'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-white/55">
|
||||
채팅/사용자 리스트는 표시하지 않는 집중형 존재감 시트입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
1
src/widgets/rooms-gallery-widget/index.ts
Normal file
1
src/widgets/rooms-gallery-widget/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/RoomsGalleryWidget';
|
||||
35
src/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget.tsx
Normal file
35
src/widgets/rooms-gallery-widget/ui/RoomsGalleryWidget.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { RoomTheme } from '@/entities/room';
|
||||
import { RoomPreviewCard } from '@/features/room-select';
|
||||
import { GlassCard } from '@/shared/ui';
|
||||
|
||||
interface RoomsGalleryWidgetProps {
|
||||
rooms: RoomTheme[];
|
||||
selectedRoomId: string;
|
||||
onRoomSelect: (roomId: string) => void;
|
||||
}
|
||||
|
||||
export const RoomsGalleryWidget = ({
|
||||
rooms,
|
||||
selectedRoomId,
|
||||
onRoomSelect,
|
||||
}: RoomsGalleryWidgetProps) => {
|
||||
return (
|
||||
<GlassCard elevated className="space-y-5 p-5 sm:p-6">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-xl font-semibold text-white">오늘의 공간</h2>
|
||||
<p className="text-sm text-white/70">감정에 맞는 분위기 하나만 고르면 충분해요.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{rooms.map((room) => (
|
||||
<RoomPreviewCard
|
||||
key={room.id}
|
||||
room={room}
|
||||
selected={room.id === selectedRoomId}
|
||||
onSelect={onRoomSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
1
src/widgets/settings-panel/index.ts
Normal file
1
src/widgets/settings-panel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SettingsPanelWidget';
|
||||
110
src/widgets/settings-panel/ui/SettingsPanelWidget.tsx
Normal file
110
src/widgets/settings-panel/ui/SettingsPanelWidget.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DEFAULT_PRESET_OPTIONS,
|
||||
NOTIFICATION_INTENSITY_OPTIONS,
|
||||
} from '@/shared/config/settingsOptions';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
|
||||
export const SettingsPanelWidget = () => {
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
const [notificationIntensity, setNotificationIntensity] =
|
||||
useState<(typeof NOTIFICATION_INTENSITY_OPTIONS)[number]>('기본');
|
||||
const [defaultPresetId, setDefaultPresetId] = useState<
|
||||
(typeof DEFAULT_PRESET_OPTIONS)[number]['id']
|
||||
>(DEFAULT_PRESET_OPTIONS[0].id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_80%_0%,rgba(34,211,238,0.16),transparent_48%),linear-gradient(170deg,#020617_0%,#111827_54%,#0f172a_100%)] text-white">
|
||||
<div className="mx-auto w-full max-w-4xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between rounded-xl border border-white/12 bg-white/6 px-4 py-3">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-white/24 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/10"
|
||||
>
|
||||
허브로
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="rounded-xl border border-white/15 bg-white/8 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-white">Reduce Motion</h2>
|
||||
<p className="mt-1 text-sm text-white/70">
|
||||
전환 애니메이션을 최소화합니다. (UI 토글 목업)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={reduceMotion}
|
||||
onClick={() => setReduceMotion((current) => !current)}
|
||||
className={cn(
|
||||
'inline-flex w-16 items-center rounded-full border px-1 py-1 transition-colors',
|
||||
reduceMotion
|
||||
? 'border-sky-200/70 bg-sky-300/28'
|
||||
: 'border-white/30 bg-white/10',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-full bg-white transition-transform duration-200 motion-reduce:transition-none',
|
||||
reduceMotion ? 'translate-x-9' : 'translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/15 bg-white/8 p-4">
|
||||
<h2 className="text-base font-semibold text-white">알림 강도</h2>
|
||||
<p className="mt-1 text-sm text-white/70">집중 시작/종료 신호의 존재감을 선택합니다.</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{NOTIFICATION_INTENSITY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setNotificationIntensity(option)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
||||
notificationIntensity === option
|
||||
? 'border-sky-200/80 bg-sky-300/25 text-sky-50'
|
||||
: 'border-white/24 bg-white/8 text-white/82 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-white/15 bg-white/8 p-4">
|
||||
<h2 className="text-base font-semibold text-white">기본 프리셋</h2>
|
||||
<p className="mt-1 text-sm text-white/70">입장 시 자동 선택될 추천 세트를 고릅니다.</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{DEFAULT_PRESET_OPTIONS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => setDefaultPresetId(preset.id)}
|
||||
className={cn(
|
||||
'w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors',
|
||||
defaultPresetId === preset.id
|
||||
? 'border-sky-200/75 bg-sky-300/20 text-sky-50'
|
||||
: 'border-white/18 bg-white/5 text-white/85 hover:bg-white/10',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/widgets/space-shell/index.ts
Normal file
1
src/widgets/space-shell/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SpaceSkeletonWidget';
|
||||
106
src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx
Normal file
106
src/widgets/space-shell/ui/SpaceSkeletonWidget.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { getRoomBackgroundStyle, getRoomById, ROOM_THEMES } from '@/entities/room';
|
||||
import { SOUND_PRESETS } from '@/entities/session';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { SpaceTimerHudWidget } from '@/widgets/space-timer-hud';
|
||||
import { SpaceToolsDockWidget } from '@/widgets/space-tools-dock';
|
||||
|
||||
export const SpaceSkeletonWidget = () => {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const roomId = searchParams.get('room') ?? ROOM_THEMES[0].id;
|
||||
const goal = searchParams.get('goal') ?? '오늘은 한 조각만 집중해요';
|
||||
const timerLabel = searchParams.get('timer') ?? '25/5';
|
||||
const soundFromQuery = searchParams.get('sound');
|
||||
|
||||
const room = useMemo(() => getRoomById(roomId) ?? ROOM_THEMES[0], [roomId]);
|
||||
|
||||
const defaultSoundId =
|
||||
SOUND_PRESETS.find((preset) => preset.id === soundFromQuery)?.id ??
|
||||
SOUND_PRESETS[0].id;
|
||||
|
||||
const [selectedSoundId, setSelectedSoundId] = useState(defaultSoundId);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSoundId(defaultSoundId);
|
||||
}, [defaultSoundId]);
|
||||
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-x-hidden overflow-y-hidden text-white">
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full"
|
||||
style={getRoomBackgroundStyle(room)}
|
||||
/>
|
||||
<div aria-hidden className="absolute inset-0 w-full bg-slate-950/62" />
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 w-full opacity-35"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url('/textures/grain.png'), repeating-linear-gradient(0deg, rgba(255,255,255,0.045) 0 1px, transparent 1px 2px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col pr-14">
|
||||
<header className="flex items-center justify-between border-b border-white/12 bg-slate-950/35 px-4 py-3 backdrop-blur-lg sm:px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/30 bg-white/10 text-xs font-semibold">
|
||||
V
|
||||
</span>
|
||||
<p className="text-sm font-semibold tracking-tight">VibeRoom</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-white/30 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/10"
|
||||
>
|
||||
허브로 돌아가기
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 px-4 pt-6 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-white/65">Current Room</p>
|
||||
<p className="text-lg font-semibold text-white">{room.name}</p>
|
||||
<p className="text-xs text-white/70">현재 {room.activeMembers}명 함께 집중 중</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer className="border-t border-white/12 bg-slate-950/42 px-4 py-3 backdrop-blur-md sm:px-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-wrap gap-2">
|
||||
{SOUND_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSoundId(preset.id)}
|
||||
className={cn(
|
||||
'rounded-full border px-3 py-1.5 text-xs transition-colors',
|
||||
selectedSoundId === preset.id
|
||||
? 'border-sky-200/75 bg-sky-300/25 text-sky-50'
|
||||
: 'border-white/24 bg-white/8 text-white/82 hover:bg-white/14',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<SpaceTimerHudWidget timerLabel={timerLabel} goal={goal} />
|
||||
|
||||
<SpaceToolsDockWidget
|
||||
roomName={room.name}
|
||||
activeMembers={room.activeMembers}
|
||||
presence={room.presence}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/widgets/space-timer-hud/index.ts
Normal file
1
src/widgets/space-timer-hud/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SpaceTimerHudWidget';
|
||||
56
src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx
Normal file
56
src/widgets/space-timer-hud/ui/SpaceTimerHudWidget.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { Restart30sAction } from '@/features/restart-30s';
|
||||
|
||||
interface SpaceTimerHudWidgetProps {
|
||||
timerLabel: string;
|
||||
goal: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const HUD_ACTIONS = [
|
||||
{ id: 'start', label: '시작', icon: '▶' },
|
||||
{ id: 'pause', label: '일시정지', icon: '⏸' },
|
||||
{ id: 'reset', label: '리셋', icon: '↺' },
|
||||
] as const;
|
||||
|
||||
export const SpaceTimerHudWidget = ({
|
||||
timerLabel,
|
||||
goal,
|
||||
className,
|
||||
}: SpaceTimerHudWidgetProps) => {
|
||||
return (
|
||||
<div className={cn('pointer-events-none fixed inset-x-0 bottom-[4.7rem] z-20 px-4 pr-16 sm:px-6', className)}>
|
||||
<div className="mx-auto w-full max-w-xl pointer-events-auto">
|
||||
<section className="flex h-16 items-center justify-between gap-3 rounded-2xl border border-white/10 bg-black/25 px-3.5 backdrop-blur-sm">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/62">
|
||||
Focus
|
||||
</span>
|
||||
<span className="text-2xl font-semibold tracking-tight text-white">25:00</span>
|
||||
<span className="text-[11px] text-white/62">{timerLabel}</span>
|
||||
</div>
|
||||
<p className="truncate text-[11px] text-white/58">목표: {goal}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{HUD_ACTIONS.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
title={action.label}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/15 bg-white/8 text-sm text-white/82 transition-colors hover:bg-white/14 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-200/80"
|
||||
>
|
||||
<span aria-hidden>{action.icon}</span>
|
||||
<span className="sr-only">{action.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Restart30sAction />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/widgets/space-tools-dock/index.ts
Normal file
1
src/widgets/space-tools-dock/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/SpaceToolsDockWidget';
|
||||
23
src/widgets/space-tools-dock/model/useSpaceToolsDock.ts
Normal file
23
src/widgets/space-tools-dock/model/useSpaceToolsDock.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export type SpaceToolPanel = 'room' | 'notes' | 'quick' | null;
|
||||
|
||||
export const useSpaceToolsDock = () => {
|
||||
const [activePanel, setActivePanel] = useState<SpaceToolPanel>(null);
|
||||
|
||||
const togglePanel = (panel: Exclude<SpaceToolPanel, null>) => {
|
||||
setActivePanel((current) => (current === panel ? null : panel));
|
||||
};
|
||||
|
||||
const closePanel = () => {
|
||||
setActivePanel(null);
|
||||
};
|
||||
|
||||
return {
|
||||
activePanel,
|
||||
togglePanel,
|
||||
closePanel,
|
||||
};
|
||||
};
|
||||
105
src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
Normal file
105
src/widgets/space-tools-dock/ui/SpaceToolsDockWidget.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import type { RoomPresence } from '@/entities/room';
|
||||
import { CHECK_IN_PHRASES, REACTION_OPTIONS } from '@/entities/session';
|
||||
import { useCheckIn } from '@/features/check-in';
|
||||
import { useToast } from '@/shared/ui';
|
||||
import { cn } from '@/shared/lib/cn';
|
||||
import { NotesSheetWidget } from '@/widgets/notes-sheet';
|
||||
import { QuickSheetWidget } from '@/widgets/quick-sheet';
|
||||
import { RoomSheetWidget } from '@/widgets/room-sheet';
|
||||
import { useSpaceToolsDock } from '../model/useSpaceToolsDock';
|
||||
|
||||
interface SpaceToolsDockWidgetProps {
|
||||
roomName: string;
|
||||
activeMembers: number;
|
||||
presence: RoomPresence;
|
||||
}
|
||||
|
||||
const TOOL_BUTTONS: Array<{
|
||||
id: 'room' | 'notes' | 'quick';
|
||||
icon: string;
|
||||
label: string;
|
||||
}> = [
|
||||
{ id: 'room', icon: '👥', label: 'Room' },
|
||||
{ id: 'notes', icon: '📝', label: 'Notes' },
|
||||
{ id: 'quick', icon: '⚙️', label: 'Quick' },
|
||||
];
|
||||
|
||||
export const SpaceToolsDockWidget = ({
|
||||
roomName,
|
||||
activeMembers,
|
||||
presence,
|
||||
}: SpaceToolsDockWidgetProps) => {
|
||||
const { pushToast } = useToast();
|
||||
const { lastCheckIn, recordCheckIn } = useCheckIn();
|
||||
const { activePanel, closePanel, togglePanel } = useSpaceToolsDock();
|
||||
|
||||
const handleCheckIn = (message: string) => {
|
||||
recordCheckIn(message);
|
||||
pushToast({ title: `체크인: ${message}` });
|
||||
};
|
||||
|
||||
const handleReaction = (emoji: string) => {
|
||||
pushToast({ title: `리액션: ${emoji}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{activePanel ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="시트 닫기"
|
||||
onClick={closePanel}
|
||||
className="fixed inset-0 z-30 bg-slate-950/10"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="fixed right-2 top-1/2 z-50 -translate-y-1/2">
|
||||
<div className="flex w-12 flex-col items-center gap-2 rounded-2xl border border-white/20 bg-slate-950/66 py-2 shadow-lg shadow-slate-950/60 backdrop-blur-xl">
|
||||
{TOOL_BUTTONS.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
type="button"
|
||||
title={tool.label}
|
||||
onClick={() => togglePanel(tool.id)}
|
||||
className={cn(
|
||||
'inline-flex h-9 w-9 items-center justify-center rounded-xl border text-base transition-colors',
|
||||
activePanel === tool.id
|
||||
? 'border-sky-200/75 bg-sky-300/28'
|
||||
: 'border-white/20 bg-white/8 hover:bg-white/15',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden>{tool.icon}</span>
|
||||
<span className="sr-only">{tool.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activePanel === 'room' ? (
|
||||
<RoomSheetWidget
|
||||
roomName={roomName}
|
||||
activeMembers={activeMembers}
|
||||
presence={presence}
|
||||
checkInPhrases={CHECK_IN_PHRASES}
|
||||
reactions={REACTION_OPTIONS}
|
||||
lastCheckIn={lastCheckIn}
|
||||
onClose={closePanel}
|
||||
onCheckIn={handleCheckIn}
|
||||
onReaction={(reaction) => handleReaction(reaction.emoji)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activePanel === 'notes' ? (
|
||||
<NotesSheetWidget
|
||||
onClose={closePanel}
|
||||
onNoteAdded={(note) => pushToast({ title: `노트 추가: ${note}` })}
|
||||
onNoteRemoved={() => pushToast({ title: '노트를 정리했어요' })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activePanel === 'quick' ? <QuickSheetWidget onClose={closePanel} /> : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
src/widgets/start-ritual-widget/index.ts
Normal file
1
src/widgets/start-ritual-widget/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/StartRitualWidget';
|
||||
93
src/widgets/start-ritual-widget/ui/StartRitualWidget.tsx
Normal file
93
src/widgets/start-ritual-widget/ui/StartRitualWidget.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import type { GoalChip } from '@/entities/session';
|
||||
import { Button, Chip, GlassCard } from '@/shared/ui';
|
||||
|
||||
interface StartRitualWidgetProps {
|
||||
goalInput: string;
|
||||
selectedGoalId: string | null;
|
||||
goalChips: GoalChip[];
|
||||
onGoalInputChange: (value: string) => void;
|
||||
onGoalChipSelect: (chip: GoalChip) => void;
|
||||
onQuickEnter: () => void;
|
||||
onOpenCustomEntry: () => void;
|
||||
}
|
||||
|
||||
export const StartRitualWidget = ({
|
||||
goalInput,
|
||||
selectedGoalId,
|
||||
goalChips,
|
||||
onGoalInputChange,
|
||||
onGoalChipSelect,
|
||||
onQuickEnter,
|
||||
onOpenCustomEntry,
|
||||
}: StartRitualWidgetProps) => {
|
||||
const [isGoalOpen, setGoalOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<GlassCard elevated className="space-y-5 p-5 sm:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-white">지금, 몰입을 시작해요</h1>
|
||||
<p className="mt-2 text-sm leading-relaxed text-white/72">
|
||||
공간은 들어가서 바꿔도 괜찮아요. 오늘은 한 조각만.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-medium uppercase tracking-[0.13em] text-white/65">
|
||||
한 줄 메모(선택)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setGoalOpen((current) => !current)}
|
||||
className="text-xs text-white/62 transition hover:text-white"
|
||||
>
|
||||
{isGoalOpen ? '접기' : '펼치기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isGoalOpen ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={goalInput}
|
||||
onChange={(event) => onGoalInputChange(event.target.value)}
|
||||
placeholder="이번 세션 딱 1가지만 (예: 견적서 1페이지)"
|
||||
className="w-full rounded-xl border border-white/20 bg-slate-950/55 px-3.5 py-3 text-sm text-white placeholder:text-white/45 focus:border-sky-200/60 focus:outline-none"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{goalChips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.id}
|
||||
active={selectedGoalId === chip.id}
|
||||
onClick={() => onGoalChipSelect(chip)}
|
||||
>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||
<Button
|
||||
className="w-full px-6 sm:w-auto sm:min-w-[180px]"
|
||||
onClick={onQuickEnter}
|
||||
>
|
||||
바로 입장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto sm:min-w-[152px] !bg-white/6 !text-white/84 !ring-white/24 hover:!bg-white/12"
|
||||
onClick={onOpenCustomEntry}
|
||||
>
|
||||
<span aria-hidden>⚙</span>
|
||||
설정하고 입장
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</GlassCard>
|
||||
);
|
||||
};
|
||||
1
src/widgets/stats-overview/index.ts
Normal file
1
src/widgets/stats-overview/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './ui/StatsOverviewWidget';
|
||||
59
src/widgets/stats-overview/ui/StatsOverviewWidget.tsx
Normal file
59
src/widgets/stats-overview/ui/StatsOverviewWidget.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Link from 'next/link';
|
||||
import { TODAY_STATS, WEEKLY_STATS } from '@/entities/session';
|
||||
|
||||
const StatSection = ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: Array<{ id: string; label: string; value: string; delta: string }>;
|
||||
}) => {
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className="rounded-xl border border-white/16 bg-white/8 p-4"
|
||||
>
|
||||
<p className="text-xs text-white/62">{item.label}</p>
|
||||
<p className="mt-2 text-xl font-semibold text-white">{item.value}</p>
|
||||
<p className="mt-1 text-xs text-sky-200">{item.delta}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatsOverviewWidget = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_20%_0%,rgba(56,189,248,0.18),transparent_45%),linear-gradient(170deg,#020617_0%,#0f172a_52%,#111827_100%)] text-white">
|
||||
<div className="mx-auto w-full max-w-6xl px-4 pb-10 pt-6 sm:px-6">
|
||||
<header className="mb-6 flex items-center justify-between rounded-xl border border-white/12 bg-white/6 px-4 py-3">
|
||||
<h1 className="text-xl font-semibold">Stats</h1>
|
||||
<Link
|
||||
href="/app"
|
||||
className="rounded-lg border border-white/24 px-3 py-1.5 text-xs text-white/85 transition hover:bg-white/10"
|
||||
>
|
||||
허브로
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<StatSection title="오늘" items={TODAY_STATS} />
|
||||
<StatSection title="최근 7일" items={WEEKLY_STATS} />
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-white">집중 흐름 그래프</h2>
|
||||
<div className="rounded-xl border border-dashed border-white/25 bg-white/5 p-5">
|
||||
<div className="h-52 rounded-lg border border-white/10 bg-[linear-gradient(180deg,rgba(148,163,184,0.08),rgba(148,163,184,0.02))]" />
|
||||
<p className="mt-3 text-xs text-white/60">더미 그래프 플레이스홀더</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user