Compare commits
9 Commits
9cde0e927a
...
bb1a6fbdab
| Author | SHA1 | Date | |
|---|---|---|---|
| bb1a6fbdab | |||
| 35188c7b52 | |||
| 751b34c39f | |||
| e9bd08c75d | |||
| 15c2100ba2 | |||
| c37678ca01 | |||
| 1fd357cf95 | |||
| 73654788da | |||
| 751c99ebe6 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,3 +40,4 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.idea
|
||||
.cli
|
||||
|
||||
89
src/app/boarding/page.tsx
Normal file
89
src/app/boarding/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ROUTES } from '@/lib/constants';
|
||||
import { saveCurrentVoyage } from '@/lib/store';
|
||||
import { Voyage } from '@/types';
|
||||
|
||||
function BoardingContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const routeId = searchParams.get('routeId');
|
||||
const route = ROUTES.find(r => r.id === routeId) || ROUTES[0];
|
||||
|
||||
const [mission, setMission] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const handleDocking = () => {
|
||||
if (!mission.trim()) return;
|
||||
|
||||
const newVoyage: Voyage = {
|
||||
id: crypto.randomUUID(),
|
||||
routeId: route.id,
|
||||
routeName: route.name,
|
||||
durationMinutes: route.durationMinutes,
|
||||
startedAt: Date.now(),
|
||||
status: 'in_progress',
|
||||
missionText: mission,
|
||||
notes: notes,
|
||||
};
|
||||
|
||||
saveCurrentVoyage(newVoyage);
|
||||
router.push('/flight');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm text-indigo-400 font-semibold mb-1 uppercase tracking-widest">Boarding Check</h2>
|
||||
<h1 className="text-3xl font-bold text-white">{route.name} 항로 탑승</h1>
|
||||
</div>
|
||||
|
||||
<div className="space-y-8 flex-1">
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-300">
|
||||
이번 항해의 핵심 목표
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mission}
|
||||
onChange={(e) => setMission(e.target.value)}
|
||||
placeholder="예: 서론 3문단 완성하기"
|
||||
className="w-full bg-slate-900/50 border-b-2 border-slate-700 focus:border-indigo-500 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-sm font-medium text-slate-400">
|
||||
메모 (선택)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="오늘의 컨디션이나 제약사항"
|
||||
className="w-full bg-transparent border-b border-slate-800 focus:border-slate-500 px-0 py-2 text-base outline-none transition-colors placeholder:text-slate-700 text-slate-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDocking}
|
||||
disabled={!mission.trim()}
|
||||
className="w-full py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold text-lg rounded-xl transition-all shadow-lg shadow-indigo-900/30 mt-8"
|
||||
>
|
||||
도킹 완료 (출항)
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6">Loading...</div>}>
|
||||
<BoardingContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
118
src/app/debrief/page.tsx
Normal file
118
src/app/debrief/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrentVoyage, saveToHistory, saveCurrentVoyage } from '@/lib/store';
|
||||
import { Voyage, VoyageStatus } from '@/types';
|
||||
|
||||
export default function DebriefPage() {
|
||||
const router = useRouter();
|
||||
const [voyage, setVoyage] = useState<Voyage | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [nextAction, setNextAction] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const current = getCurrentVoyage();
|
||||
if (!current) {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setVoyage(current);
|
||||
}, [router]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!voyage || !status) return;
|
||||
|
||||
const finalVoyage: Voyage = {
|
||||
...voyage,
|
||||
status: status,
|
||||
debriefProgress: progress,
|
||||
nextAction: nextAction,
|
||||
endedAt: voyage.endedAt || Date.now(), // Fallback if missed in flight
|
||||
};
|
||||
|
||||
saveToHistory(finalVoyage);
|
||||
saveCurrentVoyage(null);
|
||||
router.push('/log');
|
||||
};
|
||||
|
||||
if (!voyage) return null;
|
||||
|
||||
const statusOptions: { value: VoyageStatus; label: string; desc: string }[] = [
|
||||
{ value: 'completed', label: '✅ 계획대로', desc: '목표를 달성했습니다' },
|
||||
{ value: 'partial', label: '🌓 부분 진행', desc: '절반의 성공입니다' },
|
||||
{ value: 'reoriented', label: '🧭 방향 재설정', desc: '새로운 발견을 했습니다' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6 max-w-2xl mx-auto w-full animate-in zoom-in-95 duration-500">
|
||||
<header className="mb-8 text-center">
|
||||
<h1 className="text-2xl font-bold text-white mb-2">무사히 궤도에 도착했습니다</h1>
|
||||
<p className="text-slate-400">이번 항해를 짧게 기록하고 마무리하세요.</p>
|
||||
</header>
|
||||
|
||||
<div className="space-y-8 flex-1">
|
||||
{/* Question 1: Status */}
|
||||
<section>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-3">
|
||||
항해 결과
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
{statusOptions.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setStatus(opt.value)}
|
||||
className={`p-4 rounded-xl border text-left transition-all ${
|
||||
status === opt.value
|
||||
? 'bg-indigo-900/40 border-indigo-500 ring-1 ring-indigo-500'
|
||||
: 'bg-slate-900/50 border-slate-800 hover:bg-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="font-bold text-slate-200 mb-1">{opt.label}</div>
|
||||
<div className="text-xs text-slate-500">{opt.desc}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Question 2: Secured */}
|
||||
<section>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
이번 항해에서 확보한 것 (What)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={progress}
|
||||
onChange={(e) => setProgress(e.target.value)}
|
||||
placeholder="예: 기획안 목차 구성 완료"
|
||||
className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Question 3: Next Action */}
|
||||
<section>
|
||||
<label className="block text-sm font-medium text-slate-300 mb-2">
|
||||
다음 항해의 첫 행동 (Next)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nextAction}
|
||||
onChange={(e) => setNextAction(e.target.value)}
|
||||
placeholder="예: 본문 1챕터 초안 쓰기"
|
||||
className="w-full bg-slate-900/30 border border-slate-800 rounded-lg px-4 py-3 text-slate-200 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 outline-none transition-all"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!status}
|
||||
className="w-full mt-10 py-4 bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/20"
|
||||
>
|
||||
항해일지 저장
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/app/flight/page.tsx
Normal file
118
src/app/flight/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getCurrentVoyage, saveCurrentVoyage, getPreferences } from '@/lib/store';
|
||||
import { Voyage } from '@/types';
|
||||
import FlightBackground from '@/components/FlightBackground';
|
||||
|
||||
export default function FlightPage() {
|
||||
const router = useRouter();
|
||||
const [voyage, setVoyage] = useState<Voyage | null>(null);
|
||||
const [timeLeft, setTimeLeft] = useState(0); // seconds
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [hideSeconds, setHideSeconds] = useState(false);
|
||||
|
||||
const endTimeRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
const current = getCurrentVoyage();
|
||||
if (!current || current.status !== 'in_progress') {
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setVoyage(current);
|
||||
|
||||
const now = Date.now();
|
||||
const target = current.startedAt + (current.durationMinutes * 60 * 1000);
|
||||
endTimeRef.current = target;
|
||||
|
||||
const remainingMs = target - now;
|
||||
setTimeLeft(Math.max(0, Math.ceil(remainingMs / 1000)));
|
||||
|
||||
const prefs = getPreferences();
|
||||
setHideSeconds(prefs.hideSeconds);
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!voyage || isPaused) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const diff = endTimeRef.current - now;
|
||||
|
||||
if (diff <= 0) {
|
||||
setTimeLeft(0);
|
||||
clearInterval(interval);
|
||||
} else {
|
||||
setTimeLeft(Math.ceil(diff / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [voyage, isPaused]);
|
||||
|
||||
const handlePauseToggle = () => {
|
||||
if (isPaused) {
|
||||
endTimeRef.current = Date.now() + (timeLeft * 1000);
|
||||
setIsPaused(false);
|
||||
} else {
|
||||
setIsPaused(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFinish = () => {
|
||||
if (!voyage) return;
|
||||
const ended: Voyage = { ...voyage, endedAt: Date.now() };
|
||||
saveCurrentVoyage(ended);
|
||||
router.push('/debrief');
|
||||
};
|
||||
|
||||
if (!voyage) return null;
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (hideSeconds) return `${m}m`;
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center p-6 text-white relative overflow-hidden min-h-[calc(100vh-64px)]">
|
||||
<FlightBackground vanishYOffset={-68} centerProtectRadius={200} />
|
||||
|
||||
{/* UI Element 1: Label */}
|
||||
<div className="absolute top-8 text-center z-10">
|
||||
<span className="text-xs font-medium text-indigo-300 uppercase tracking-widest border border-indigo-500/30 bg-indigo-950/50 backdrop-blur px-4 py-1.5 rounded-full shadow-[0_0_15px_rgba(99,102,241,0.3)]">
|
||||
{voyage.routeName} · {isPaused ? '일시정지' : '순항 중'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* UI Element 2: Timer */}
|
||||
<div className={`font-mono font-light tracking-tighter tabular-nums text-7xl md:text-9xl my-12 relative z-10 drop-shadow-2xl transition-opacity duration-300 ${isPaused ? 'opacity-50' : 'opacity-100'}`}>
|
||||
{formatTime(timeLeft)}
|
||||
</div>
|
||||
|
||||
{/* UI Element 3: Mission */}
|
||||
<div className="text-xl md:text-2xl text-slate-200 text-center max-w-2xl mb-24 font-medium leading-relaxed relative z-10 drop-shadow-md px-4">
|
||||
“{voyage.missionText}”
|
||||
</div>
|
||||
|
||||
{/* UI Element 4 & 5: Controls */}
|
||||
<div className="flex gap-6 absolute bottom-12 z-10">
|
||||
<button
|
||||
onClick={handlePauseToggle}
|
||||
className="px-8 py-3 rounded-full border border-slate-600 bg-slate-900/50 backdrop-blur text-slate-300 hover:text-white hover:border-slate-400 hover:bg-slate-800/80 transition-all text-sm font-bold uppercase tracking-wide"
|
||||
>
|
||||
{isPaused ? '다시 시작' : '일시정지'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
className="px-8 py-3 rounded-full bg-slate-100 text-slate-900 hover:bg-indigo-500 hover:text-white transition-all text-sm font-bold uppercase tracking-wide shadow-lg shadow-white/10"
|
||||
>
|
||||
{timeLeft === 0 ? '도착 (회고)' : '항해 종료'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,3 +123,122 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes star-core-pulse {
|
||||
0%,
|
||||
38%,
|
||||
100% {
|
||||
opacity: var(--core-low, 0.25);
|
||||
}
|
||||
52% {
|
||||
opacity: var(--core-high, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes star-glint-pulse {
|
||||
0%,
|
||||
35%,
|
||||
100% {
|
||||
opacity: var(--glint-base, 0.02);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--glint-peak, 0.82);
|
||||
}
|
||||
60% {
|
||||
opacity: calc(var(--glint-peak, 0.82) * 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes star-bloom-pulse {
|
||||
0%,
|
||||
35%,
|
||||
100% {
|
||||
opacity: var(--bloom-base, 0.01);
|
||||
}
|
||||
50% {
|
||||
opacity: var(--bloom-peak, 0.18);
|
||||
}
|
||||
60% {
|
||||
opacity: calc(var(--bloom-peak, 0.18) * 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-twinkle {
|
||||
animation: twinkle 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.star-core {
|
||||
animation-name: star-core-pulse;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: var(--core-low, 0.25);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.star-core-bloom {
|
||||
animation-name: star-bloom-pulse;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: var(--bloom-base, 0.01);
|
||||
fill: currentColor;
|
||||
filter: blur(0.7px);
|
||||
}
|
||||
|
||||
.star-glint {
|
||||
animation-name: star-glint-pulse;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: var(--glint-base, 0.02);
|
||||
stroke-width: 0.62;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.star-glint-bloom {
|
||||
animation-name: star-bloom-pulse;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
opacity: var(--bloom-base, 0.01);
|
||||
stroke-width: 0.9;
|
||||
stroke-linecap: round;
|
||||
filter: blur(0.65px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-twinkle {
|
||||
animation: none;
|
||||
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.star-core {
|
||||
animation: none;
|
||||
opacity: var(--core-reduced, 0.34);
|
||||
}
|
||||
|
||||
.star-core-bloom {
|
||||
animation: none;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.star-glint {
|
||||
animation: none;
|
||||
opacity: 0.08;
|
||||
}
|
||||
|
||||
.star-glint-bloom {
|
||||
animation: none;
|
||||
opacity: 0.04;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import Link from "next/link";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Focustella",
|
||||
description: "Space-themed focus timer",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +13,23 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="ko" className="dark">
|
||||
<body className="bg-slate-950 text-slate-100 min-h-screen font-sans selection:bg-indigo-500/30 overflow-x-hidden">
|
||||
{/* Layout Container */}
|
||||
<div className="relative z-10 min-h-screen flex flex-col max-w-6xl mx-auto">
|
||||
<header className="flex items-center justify-between px-6 py-4">
|
||||
<Link href="/" className="font-bold text-lg tracking-wider text-indigo-400 hover:text-indigo-300 transition-colors z-50">
|
||||
FOCUSTELLA
|
||||
</Link>
|
||||
<nav className="flex gap-4 text-sm font-medium text-slate-400 z-50">
|
||||
<Link href="/log" className="hover:text-slate-200 transition-colors">항해일지</Link>
|
||||
<Link href="/settings" className="hover:text-slate-200 transition-colors">설정</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 relative flex flex-col w-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
68
src/app/log/[id]/page.tsx
Normal file
68
src/app/log/[id]/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getHistory } from '@/lib/store';
|
||||
import { Voyage } from '@/types';
|
||||
|
||||
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
// Next.js 15: params is a Promise
|
||||
const resolvedParams = use(params);
|
||||
const router = useRouter();
|
||||
const [log, setLog] = useState<Voyage | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const history = getHistory();
|
||||
const found = history.find(item => item.id === resolvedParams.id);
|
||||
if (found) setLog(found);
|
||||
}, [resolvedParams.id]);
|
||||
|
||||
if (!log) return <div className="p-6 text-slate-500">Loading or Not Found...</div>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300">
|
||||
<header className="border-b border-slate-800 pb-4">
|
||||
<Link href="/log" className="text-sm text-indigo-400 hover:text-indigo-300 mb-4 inline-block">← 목록으로</Link>
|
||||
<h1 className="text-2xl font-bold text-white">{log.missionText}</h1>
|
||||
<div className="flex gap-3 mt-2 text-sm text-slate-500">
|
||||
<span>{new Date(log.startedAt).toLocaleString()}</span>
|
||||
<span>•</span>
|
||||
<span>{log.routeName} ({log.durationMinutes}m)</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-slate-900/30 p-4 rounded-lg border border-slate-800/50">
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">결과 상태</h3>
|
||||
<p className="text-lg text-indigo-100">
|
||||
{log.status === 'completed' && '✅ 계획대로'}
|
||||
{log.status === 'partial' && '🌓 부분 진행'}
|
||||
{log.status === 'reoriented' && '🧭 방향 재설정'}
|
||||
{log.status === 'aborted' && '🚨 조기 귀환'}
|
||||
{log.status === 'in_progress' && '진행 중'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">확보한 것</h3>
|
||||
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">다음 행동</h3>
|
||||
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.nextAction || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.notes && (
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">초기 메모</h3>
|
||||
<p className="text-slate-400 text-sm italic">"{log.notes}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/app/log/page.tsx
Normal file
62
src/app/log/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { getHistory } from '@/lib/store';
|
||||
import { Voyage, VoyageStatus } from '@/types';
|
||||
|
||||
export default function LogListPage() {
|
||||
const [logs, setLogs] = useState<Voyage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLogs(getHistory());
|
||||
}, []);
|
||||
|
||||
const getStatusLabel = (s: VoyageStatus) => {
|
||||
switch(s) {
|
||||
case 'completed': return '✅ 계획대로';
|
||||
case 'partial': return '🌓 부분 진행';
|
||||
case 'reoriented': return '🧭 방향 재설정';
|
||||
case 'aborted': return '🚨 조기 귀환';
|
||||
default: return '진행 중';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6">
|
||||
<h1 className="text-xl font-bold text-slate-100 mb-6">나의 항해 기록</h1>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center flex-1 py-20 text-slate-500 border border-dashed border-slate-800 rounded-xl">
|
||||
<p className="mb-4">아직 기록된 항해가 없습니다.</p>
|
||||
<Link href="/" className="text-indigo-400 hover:text-indigo-300 underline">
|
||||
첫 항해 떠나기
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{logs.map((log) => (
|
||||
<Link
|
||||
key={log.id}
|
||||
href={`/log/${log.id}`}
|
||||
className="block bg-slate-900/50 border border-slate-800 hover:border-slate-600 rounded-lg p-4 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(log.startedAt).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
|
||||
{getStatusLabel(log.status)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-200 truncate mb-1">
|
||||
{log.missionText}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">{log.routeName} · {log.durationMinutes}min</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
582
src/app/page.tsx
582
src/app/page.tsx
@@ -1,266 +1,372 @@
|
||||
// app/page.tsx
|
||||
"use client";
|
||||
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import { getCurrentVoyage } from "@/lib/store";
|
||||
import { Route } from "@/types";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { CSSProperties, useEffect } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
type Star = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
r: number;
|
||||
armScale?: number;
|
||||
};
|
||||
|
||||
type Mode = "freeflow" | "sprint" | "deepwork";
|
||||
type Segment = {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
};
|
||||
|
||||
const PRIMARY = "#2F6FED";
|
||||
const PRIMARY_HOVER = "#295FD1";
|
||||
type Constellation = {
|
||||
key: "orion" | "auriga" | "ursaMajor";
|
||||
className: string;
|
||||
viewBox: string;
|
||||
colorClass: string;
|
||||
stars: Star[];
|
||||
segments: Segment[];
|
||||
};
|
||||
|
||||
function modeLabel(mode: Mode) {
|
||||
switch (mode) {
|
||||
case "freeflow":
|
||||
return "프리플로우";
|
||||
case "sprint":
|
||||
return "스프린트";
|
||||
case "deepwork":
|
||||
return "딥워크";
|
||||
}
|
||||
const CONSTELLATIONS: Constellation[] = [
|
||||
{
|
||||
key: "orion",
|
||||
className: "absolute bottom-10 left-5 w-72 h-72 opacity-30",
|
||||
viewBox: "0 0 100 100",
|
||||
colorClass: "text-indigo-200",
|
||||
stars: [
|
||||
{ cx: 25, cy: 15, r: 0.82 },
|
||||
{ cx: 75, cy: 25, r: 0.96, armScale: 1.08 },
|
||||
{ cx: 45, cy: 48, r: 0.58 },
|
||||
{ cx: 50, cy: 50, r: 0.54 },
|
||||
{ cx: 55, cy: 52, r: 0.6 },
|
||||
{ cx: 30, cy: 85, r: 0.86 },
|
||||
{ cx: 70, cy: 80, r: 0.98, armScale: 1.08 },
|
||||
],
|
||||
segments: [
|
||||
{ x1: 25, y1: 15, x2: 45, y2: 48 },
|
||||
{ x1: 75, y1: 25, x2: 55, y2: 52 },
|
||||
{ x1: 45, y1: 48, x2: 30, y2: 85 },
|
||||
{ x1: 55, y1: 52, x2: 70, y2: 80 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "auriga",
|
||||
className: "absolute top-10 right-10 w-64 h-64 opacity-25",
|
||||
viewBox: "0 0 100 100",
|
||||
colorClass: "text-blue-200",
|
||||
stars: [
|
||||
{ cx: 50, cy: 15, r: 1.06, armScale: 1.1 },
|
||||
{ cx: 20, cy: 35, r: 0.67 },
|
||||
{ cx: 25, cy: 75, r: 0.65 },
|
||||
{ cx: 75, cy: 75, r: 0.66 },
|
||||
{ cx: 85, cy: 35, r: 0.76 },
|
||||
],
|
||||
segments: [
|
||||
{ x1: 50, y1: 15, x2: 20, y2: 35 },
|
||||
{ x1: 20, y1: 35, x2: 25, y2: 75 },
|
||||
{ x1: 25, y1: 75, x2: 75, y2: 75 },
|
||||
{ x1: 75, y1: 75, x2: 85, y2: 35 },
|
||||
{ x1: 85, y1: 35, x2: 50, y2: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "ursaMajor",
|
||||
className: "absolute top-20 left-10 w-80 h-48 opacity-25",
|
||||
viewBox: "0 0 100 60",
|
||||
colorClass: "text-slate-200",
|
||||
stars: [
|
||||
{ cx: 10, cy: 20, r: 0.64 },
|
||||
{ cx: 25, cy: 25, r: 0.67 },
|
||||
{ cx: 40, cy: 35, r: 0.69 },
|
||||
{ cx: 55, cy: 45, r: 0.99 },
|
||||
{ cx: 75, cy: 45, r: 0.98 },
|
||||
{ cx: 80, cy: 15, r: 0.93 },
|
||||
{ cx: 60, cy: 10, r: 0.96 },
|
||||
],
|
||||
segments: [
|
||||
{ x1: 10, y1: 20, x2: 25, y2: 25 },
|
||||
{ x1: 25, y1: 25, x2: 40, y2: 35 },
|
||||
{ x1: 40, y1: 35, x2: 55, y2: 45 },
|
||||
{ x1: 55, y1: 45, x2: 75, y2: 45 },
|
||||
{ x1: 75, y1: 45, x2: 80, y2: 15 },
|
||||
{ x1: 80, y1: 15, x2: 60, y2: 10 },
|
||||
{ x1: 60, y1: 10, x2: 55, y2: 45 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const STAR_STYLES = [
|
||||
{ duration: 2.3, delay: 0.1 },
|
||||
{ duration: 3.1, delay: 1.2 },
|
||||
{ duration: 4.8, delay: 0.8 },
|
||||
{ duration: 2.7, delay: 2.1 },
|
||||
{ duration: 5.2, delay: 1.7 },
|
||||
{ duration: 3.9, delay: 0.4 },
|
||||
{ duration: 4.4, delay: 2.6 },
|
||||
{ duration: 2.1, delay: 1.3 },
|
||||
{ duration: 5.8, delay: 0.2 },
|
||||
{ duration: 3.3, delay: 2.4 },
|
||||
{ duration: 4.0, delay: 1.1 },
|
||||
{ duration: 2.9, delay: 1.9 },
|
||||
] as const;
|
||||
|
||||
function StarGlint({ starIndex, star }: { starIndex: number; star: Star }) {
|
||||
const timing = STAR_STYLES[starIndex % STAR_STYLES.length];
|
||||
const strengthTier =
|
||||
star.r >= 0.95 ? "bright" : star.r >= 0.72 ? "mid" : "faint";
|
||||
const glintPeak =
|
||||
strengthTier === "bright" ? 0.7 : strengthTier === "mid" ? 0.61 : 0.52;
|
||||
const bloomPeak =
|
||||
strengthTier === "bright" ? 0.16 : strengthTier === "mid" ? 0.12 : 0.08;
|
||||
const coreLow =
|
||||
strengthTier === "bright" ? 0.9 : strengthTier === "mid" ? 0.73 : 0.6;
|
||||
const coreHigh =
|
||||
strengthTier === "bright" ? 1 : strengthTier === "mid" ? 0.9 : 0.82;
|
||||
const coreReduced =
|
||||
strengthTier === "bright" ? 0.9 : strengthTier === "mid" ? 0.76 : 0.66;
|
||||
const coreStyle = {
|
||||
animationDuration: `${timing.duration}s`,
|
||||
animationDelay: `${timing.delay}s`,
|
||||
"--core-low": `${coreLow}`,
|
||||
"--core-high": `${coreHigh}`,
|
||||
"--core-reduced": `${coreReduced}`,
|
||||
} as CSSProperties;
|
||||
const glintStyle = {
|
||||
animationDuration: `${timing.duration}s`,
|
||||
animationDelay: `${timing.delay + 0.12}s`,
|
||||
"--glint-peak": `${glintPeak}`,
|
||||
"--glint-base": "0.02",
|
||||
"--bloom-peak": `${bloomPeak}`,
|
||||
"--bloom-base": "0.01",
|
||||
} as CSSProperties;
|
||||
const glintLength = star.r * 4.4 * (star.armScale ?? 1);
|
||||
const gradientXId = `glint-x-${starIndex}`;
|
||||
const gradientYId = `glint-y-${starIndex}`;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={gradientXId}
|
||||
x1={star.cx - glintLength}
|
||||
y1={star.cy}
|
||||
x2={star.cx + glintLength}
|
||||
y2={star.cy}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id={gradientYId}
|
||||
x1={star.cx}
|
||||
y1={star.cy - glintLength}
|
||||
x2={star.cx}
|
||||
y2={star.cy + glintLength}
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="currentColor" stopOpacity="0" />
|
||||
<stop offset="50%" stopColor="currentColor" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="currentColor" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<circle
|
||||
cx={star.cx}
|
||||
cy={star.cy}
|
||||
r={star.r}
|
||||
className="star-core"
|
||||
style={coreStyle}
|
||||
/>
|
||||
<circle
|
||||
cx={star.cx}
|
||||
cy={star.cy}
|
||||
r={star.r * 1.9}
|
||||
className="star-core-bloom"
|
||||
style={coreStyle}
|
||||
/>
|
||||
|
||||
<line
|
||||
x1={star.cx - glintLength}
|
||||
y1={star.cy}
|
||||
x2={star.cx + glintLength}
|
||||
y2={star.cy}
|
||||
className="star-glint-bloom"
|
||||
stroke={`url(#${gradientXId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
<line
|
||||
x1={star.cx}
|
||||
y1={star.cy - glintLength}
|
||||
x2={star.cx}
|
||||
y2={star.cy + glintLength}
|
||||
className="star-glint-bloom"
|
||||
stroke={`url(#${gradientYId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
<line
|
||||
x1={star.cx - glintLength}
|
||||
y1={star.cy}
|
||||
x2={star.cx + glintLength}
|
||||
y2={star.cy}
|
||||
className="star-glint"
|
||||
stroke={`url(#${gradientXId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
<line
|
||||
x1={star.cx}
|
||||
y1={star.cy - glintLength}
|
||||
x2={star.cx}
|
||||
y2={star.cy + glintLength}
|
||||
className="star-glint"
|
||||
stroke={`url(#${gradientYId})`}
|
||||
style={glintStyle}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function modeMeta(mode: Mode) {
|
||||
if (mode === "freeflow") return "무제한";
|
||||
if (mode === "sprint") return "25분";
|
||||
return "90분";
|
||||
function LobbyBackground() {
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
|
||||
{CONSTELLATIONS.map((constellation, constellationIndex) => (
|
||||
<div key={constellation.key} className={constellation.className}>
|
||||
<svg
|
||||
viewBox={constellation.viewBox}
|
||||
className={`w-full h-full fill-current ${constellation.colorClass}`}
|
||||
>
|
||||
{constellation.segments.map((segment, segmentIndex) => (
|
||||
<line
|
||||
key={`${constellation.key}-segment-${segmentIndex}`}
|
||||
x1={segment.x1}
|
||||
y1={segment.y1}
|
||||
x2={segment.x2}
|
||||
y2={segment.y2}
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
))}
|
||||
|
||||
{constellation.stars.map((star, starIndex) => {
|
||||
const globalStarIndex =
|
||||
CONSTELLATIONS.slice(0, constellationIndex).reduce(
|
||||
(sum, item) => sum + item.stars.length,
|
||||
0,
|
||||
) + starIndex;
|
||||
|
||||
return (
|
||||
<StarGlint
|
||||
key={`${constellation.key}-star-${globalStarIndex}`}
|
||||
starIndex={globalStarIndex}
|
||||
star={star}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function startLabel(mode: Mode) {
|
||||
if (mode === "freeflow") return "집중 시작";
|
||||
return `집중 시작 (${modeMeta(mode)})`;
|
||||
function RouteCard({
|
||||
route,
|
||||
isCTA = false,
|
||||
}: {
|
||||
route: Route;
|
||||
isCTA?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`group relative bg-slate-900/60 backdrop-blur-md hover:bg-slate-800/80 border border-slate-800 hover:border-indigo-500/50 rounded-2xl p-6 transition-all duration-300 flex flex-col h-full shadow-lg hover:shadow-indigo-900/20 ${isCTA ? "min-h-[200px] justify-center items-center text-center" : ""}`}
|
||||
>
|
||||
<div
|
||||
className={`flex w-full relative z-10 ${isCTA ? "flex-col items-center gap-4" : "justify-between items-start mb-4"}`}
|
||||
>
|
||||
<div className={isCTA ? "flex flex-col items-center" : ""}>
|
||||
<h3
|
||||
className={`font-bold text-indigo-100 group-hover:text-white transition-colors ${isCTA ? "text-3xl" : "text-xl"}`}
|
||||
>
|
||||
{route.name}
|
||||
</h3>
|
||||
<span className="text-xs font-medium text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded-full border border-slate-800 mt-2 inline-block">
|
||||
{route.tag}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-light text-slate-300 group-hover:text-white transition-colors ${isCTA ? "text-4xl mt-2" : "text-3xl"}`}
|
||||
>
|
||||
{route.durationMinutes === 0 ? "∞" : route.durationMinutes}
|
||||
<span className="text-sm ml-1 text-slate-500">
|
||||
{route.durationMinutes === 0 ? "" : "min"}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isCTA && (
|
||||
<p className="text-sm text-slate-400 mb-8 flex-1 leading-relaxed relative z-10 text-left w-full">
|
||||
{route.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isCTA && (
|
||||
<p className="text-base text-slate-400 mb-6 relative z-10 max-w-lg">
|
||||
{route.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/boarding?routeId=${route.id}`}
|
||||
className={`flex items-center justify-center bg-indigo-600 hover:bg-indigo-500 text-white font-bold rounded-xl transition-all shadow-lg shadow-indigo-900/30 active:scale-[0.98] relative z-10 ${isCTA ? "w-full max-w-md py-4 text-lg" : "w-full py-4"}`}
|
||||
>
|
||||
{isCTA ? "정거장 진입 (대기)" : "바로 출항"}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<Mode | null>(null);
|
||||
const [goal, setGoal] = useState("");
|
||||
useEffect(() => {
|
||||
const current = getCurrentVoyage();
|
||||
if (current && current.status === "in_progress") {
|
||||
router.replace("/flight");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const meta = useMemo(() => (mode ? modeMeta(mode) : ""), [mode]);
|
||||
|
||||
const go = useCallback(
|
||||
(m: Mode, g?: string) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("mode", m);
|
||||
|
||||
if (g && g.trim())
|
||||
localStorage.setItem("hushroom:session-goal", g.trim());
|
||||
else localStorage.removeItem("hushroom:session-goal");
|
||||
|
||||
// nextAction은 사용하지 않음
|
||||
localStorage.removeItem("hushroom:session-nextAction");
|
||||
|
||||
localStorage.setItem("hushroom:last-mode", m);
|
||||
router.push(`/session?${params.toString()}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const openDialog = (m: Mode) => {
|
||||
setMode(m);
|
||||
setGoal("");
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (!mode) return;
|
||||
setOpen(false);
|
||||
go(mode, goal);
|
||||
};
|
||||
const stationRoute = ROUTES[0];
|
||||
const normalRoutes = ROUTES.slice(1);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen w-full bg-[#E9EEF6]">
|
||||
<header className="px-5 pt-6">
|
||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||
hushroom
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-slate-600">
|
||||
딱 한 가지 목표. 바로 시작.
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-col flex-1 relative animate-in fade-in duration-500">
|
||||
<LobbyBackground />
|
||||
|
||||
<section className="mx-auto flex min-h-[calc(100vh-64px)] max-w-lg flex-col justify-center px-5 pb-10">
|
||||
{/* ✅ 파란 CTA = 프리플로우 */}
|
||||
<div className="mb-4">
|
||||
<div className="text-sm font-semibold text-slate-600">자유 세션</div>
|
||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||
시간 제한 없이, 원할 때 종료 (60분마다 가볍게 노크)
|
||||
</div>
|
||||
<div className="relative z-10 flex flex-col items-center justify-center min-h-[calc(100vh-80px)] p-6 md:p-12">
|
||||
<div className="space-y-4 text-center mb-12 max-w-2xl mx-auto backdrop-blur-sm bg-slate-950/30 p-6 rounded-3xl border border-slate-800/30">
|
||||
<h1 className="text-3xl md:text-5xl font-bold text-slate-100 tracking-tight">
|
||||
어느 별자리로 출항할까요?
|
||||
</h1>
|
||||
<p className="text-slate-300 text-lg">몰입하기 좋은 궤도입니다.</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => openDialog("freeflow")}
|
||||
className="h-auto w-full items-start justify-start whitespace-normal rounded-3xl bg-[#2F6FED]
|
||||
px-8 py-6 text-left text-white shadow-sm transition active:scale-[0.99] hover:bg-[#295FD1]"
|
||||
>
|
||||
<div className="flex flex-col gap-6 w-full max-w-4xl mx-auto">
|
||||
<div className="w-full">
|
||||
<div className="text-2xl font-semibold leading-none">
|
||||
프리플로우
|
||||
</div>
|
||||
<div className="mt-2 text-lg opacity-90">무제한</div>
|
||||
<RouteCard route={stationRoute} isCTA={true} />
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="my-8 flex items-center gap-3">
|
||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
||||
<div className="text-sm font-semibold text-slate-600">몰입 블록</div>
|
||||
<Separator className="flex-1 bg-[#D7E0EE]" />
|
||||
</div>
|
||||
|
||||
{/* ✅ 버튼 위에 설명 (버튼 안에 설명 X) */}
|
||||
<div className="-mt-2 mb-5">
|
||||
<div className="text-sm font-semibold text-slate-600">
|
||||
시간 고정 세션
|
||||
</div>
|
||||
<div className="mt-1 text-base leading-relaxed text-slate-700">
|
||||
한 번 실행되고 끝나면 요약으로 이동
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 w-full">
|
||||
{normalRoutes.map((route) => (
|
||||
<RouteCard key={route.id} route={route} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ✅ row(2열) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<ModeTile
|
||||
title="스프린트"
|
||||
meta="25분"
|
||||
onClick={() => openDialog("sprint")}
|
||||
/>
|
||||
<ModeTile
|
||||
title="딥워크"
|
||||
meta="90분"
|
||||
onClick={() => openDialog("deepwork")}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<SessionGoalDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
mode={mode}
|
||||
meta={meta}
|
||||
goal={goal}
|
||||
setGoal={setGoal}
|
||||
onStart={start}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeTile({
|
||||
title,
|
||||
meta,
|
||||
onClick,
|
||||
}: {
|
||||
title: string;
|
||||
meta: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="rounded-3xl border border-[#C9D7F5] bg-white shadow-sm transition hover:bg-[#F1F5FF]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
className="h-auto w-full rounded-3xl px-7 py-5 text-left active:scale-[0.99] hover:bg-transparent"
|
||||
>
|
||||
<div className="flex w-full items-baseline justify-between">
|
||||
<div className="text-xl font-semibold text-slate-900">{title}</div>
|
||||
<div className="text-lg font-semibold text-blue-700">{meta}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function SessionGoalDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
meta,
|
||||
goal,
|
||||
setGoal,
|
||||
onStart,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
mode: Mode | null;
|
||||
meta: string;
|
||||
goal: string;
|
||||
setGoal: (v: string) => void;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const title = mode ? `${modeLabel(mode)} · ${meta}` : "세션";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md rounded-2xl">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onStart();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">세션 목표 설정</DialogTitle>
|
||||
<div className="text-sm text-slate-600">{title}</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Input
|
||||
value={goal}
|
||||
onChange={(e) => setGoal(e.target.value)}
|
||||
placeholder="지금 할 한 가지를 한 줄로 적어주세요 (선택)"
|
||||
className="text-lg focus-visible:ring-2"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.nativeEvent as any).isComposing) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="text-xs text-slate-500">
|
||||
짧게 적을수록 좋아요. 끝이 보이게.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6 gap-2 sm:gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl"
|
||||
style={{ backgroundColor: PRIMARY }}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY_HOVER)
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.backgroundColor = PRIMARY)
|
||||
}
|
||||
>
|
||||
{mode ? startLabel(mode) : "집중 시작"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ function SessionEndInner() {
|
||||
<main className="min-h-screen w-full" style={{ backgroundColor: BG }}>
|
||||
<header className="px-5 pt-6">
|
||||
<div className="select-none text-xl font-bold tracking-tight leading-none text-slate-800">
|
||||
hushroom
|
||||
QuietSprint
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
15
src/app/session/layout.tsx
Normal file
15
src/app/session/layout.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
// app/session/layout.tsx
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Session",
|
||||
description: "Focus session in progress.",
|
||||
};
|
||||
|
||||
export default function SessionLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -270,12 +270,12 @@ function SessionInner() {
|
||||
const [goal, setGoal] = useState("");
|
||||
const [nextAction, setNextAction] = useState("");
|
||||
|
||||
// ✅ startedAt/now 기반 시간
|
||||
// startedAt/now 기반 시간
|
||||
const [startedAt, setStartedAt] = useState<number>(() => Date.now());
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
const endedRef = useRef(false);
|
||||
|
||||
// ✅ away: 누적 ms + (현재 away면 now-startedAt)
|
||||
// away: 누적 ms + (현재 away면 now-startedAt)
|
||||
const [isAway, setIsAway] = useState(false);
|
||||
const awayTotalMsRef = useRef(0);
|
||||
const awayStartedAtRef = useRef<number | null>(null);
|
||||
@@ -435,7 +435,7 @@ function SessionInner() {
|
||||
return formatHHMMSS(remaining);
|
||||
}, [elapsed, remaining, mode]);
|
||||
|
||||
// ✅ 타임아웃 종료(점프 포함)
|
||||
// 타임아웃 종료(점프 포함)
|
||||
useEffect(() => {
|
||||
if (!duration) return;
|
||||
if (endedRef.current) return;
|
||||
|
||||
40
src/app/settings/page.tsx
Normal file
40
src/app/settings/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPreferences, savePreferences } from '@/lib/store';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [hideSeconds, setHideSeconds] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const prefs = getPreferences();
|
||||
setHideSeconds(prefs.hideSeconds);
|
||||
}, []);
|
||||
|
||||
const handleToggle = () => {
|
||||
const newValue = !hideSeconds;
|
||||
setHideSeconds(newValue);
|
||||
savePreferences({ hideSeconds: newValue });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-xl font-bold text-slate-100 mb-8">설정</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between bg-slate-900/40 p-4 rounded-xl border border-slate-800">
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-200">초 단위 숨기기</h3>
|
||||
<p className="text-xs text-slate-500">타이머에서 분 단위만 표시하여 불안감을 줄입니다.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`w-12 h-6 rounded-full transition-colors relative ${hideSeconds ? 'bg-indigo-600' : 'bg-slate-700'}`}
|
||||
>
|
||||
<div className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${hideSeconds ? 'left-7' : 'left-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
src/components/FlightBackground.tsx
Normal file
312
src/components/FlightBackground.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type FlightBackgroundProps = {
|
||||
vanishYOffset?: number;
|
||||
centerProtectRadius?: number;
|
||||
};
|
||||
|
||||
type Star = {
|
||||
wx: number;
|
||||
wy: number;
|
||||
z: number;
|
||||
speed: number;
|
||||
radius: number;
|
||||
alpha: number;
|
||||
tailLength: number;
|
||||
};
|
||||
|
||||
const clamp = (value: number, min: number, max: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
|
||||
const randomInRange = (min: number, max: number) =>
|
||||
min + Math.random() * (max - min);
|
||||
|
||||
export default function FlightBackground({
|
||||
vanishYOffset = -64,
|
||||
centerProtectRadius = 190,
|
||||
}: FlightBackgroundProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let width = window.innerWidth;
|
||||
let height = window.innerHeight;
|
||||
let animationFrameId = 0;
|
||||
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let prefersReduced = motionQuery.matches;
|
||||
|
||||
const vanishXJitter = (Math.random() < 0.5 ? -1 : 1) * randomInRange(10, 25);
|
||||
|
||||
const setSize = () => {
|
||||
width = window.innerWidth;
|
||||
height = window.innerHeight;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
};
|
||||
|
||||
const getStarCount = () => {
|
||||
const isMobile = width < 768;
|
||||
const min = isMobile ? 12 : 18;
|
||||
const max = isMobile ? 30 : 45;
|
||||
const byArea = Math.round((width * height) / 42000);
|
||||
return clamp(byArea, min, max);
|
||||
};
|
||||
|
||||
const getVanishingPoint = () => ({
|
||||
x: width / 2 + vanishXJitter,
|
||||
y: height / 2 + vanishYOffset,
|
||||
});
|
||||
|
||||
const createSpeed = () => {
|
||||
const tier = Math.random();
|
||||
|
||||
if (tier < 0.9) return randomInRange(0.003, 0.007);
|
||||
if (tier < 0.99) return randomInRange(0.007, 0.011);
|
||||
return randomInRange(0.011, 0.014);
|
||||
};
|
||||
|
||||
const createVisuals = () => {
|
||||
const highlight = Math.random() < 0.16;
|
||||
const tailRoll = Math.random();
|
||||
const tailLength =
|
||||
tailRoll < 0.82
|
||||
? randomInRange(0, 2.5)
|
||||
: tailRoll < 0.86
|
||||
? randomInRange(2.5, 3.8)
|
||||
: randomInRange(4, 10);
|
||||
|
||||
return {
|
||||
radius: highlight ? randomInRange(1.2, 1.8) : randomInRange(0.7, 1.2),
|
||||
alpha: highlight ? randomInRange(0.55, 0.85) : randomInRange(0.25, 0.55),
|
||||
tailLength,
|
||||
};
|
||||
};
|
||||
|
||||
const createSpawnRadius = () => {
|
||||
const roll = Math.random();
|
||||
const maxWideRadius = Math.min(Math.max(width, height) * 0.7, 360);
|
||||
const ringOuter = Math.min(320, maxWideRadius);
|
||||
|
||||
if (roll < 0.08) {
|
||||
return randomInRange(0, 60);
|
||||
}
|
||||
|
||||
if (roll < 0.8) {
|
||||
return randomInRange(80, Math.max(80, ringOuter));
|
||||
}
|
||||
|
||||
return randomInRange(120, Math.max(120, maxWideRadius));
|
||||
};
|
||||
|
||||
const createStar = (isRespawn: boolean): Star => {
|
||||
const radius = createSpawnRadius();
|
||||
const angle = randomInRange(0, Math.PI * 2);
|
||||
const visuals = createVisuals();
|
||||
|
||||
return {
|
||||
wx: Math.cos(angle) * radius,
|
||||
wy: Math.sin(angle) * radius,
|
||||
z: isRespawn ? randomInRange(0.9, 1.55) : randomInRange(0.55, 1.6),
|
||||
speed: createSpeed(),
|
||||
radius: visuals.radius,
|
||||
alpha: visuals.alpha,
|
||||
tailLength: visuals.tailLength,
|
||||
};
|
||||
};
|
||||
|
||||
setSize();
|
||||
let stars: Star[] = Array.from({ length: getStarCount() }, () => createStar(false));
|
||||
|
||||
const screenDistanceFromCenter = (x: number, y: number) => {
|
||||
const cx = width / 2;
|
||||
const cy = height / 2;
|
||||
return Math.hypot(x - cx, y - cy);
|
||||
};
|
||||
|
||||
const applyCenterProtection = (x: number, y: number, alpha: number) => {
|
||||
const distance = screenDistanceFromCenter(x, y);
|
||||
|
||||
if (distance >= centerProtectRadius) return alpha;
|
||||
|
||||
const ratio = clamp(distance / centerProtectRadius, 0, 1);
|
||||
const attenuation = 0.35 + ratio * 0.65;
|
||||
return alpha * attenuation;
|
||||
};
|
||||
|
||||
const project = (star: Star, zValue: number) => {
|
||||
const vp = getVanishingPoint();
|
||||
return {
|
||||
x: vp.x + star.wx / zValue,
|
||||
y: vp.y + star.wy / zValue,
|
||||
};
|
||||
};
|
||||
|
||||
const recycleStar = (index: number) => {
|
||||
stars[index] = createStar(true);
|
||||
};
|
||||
|
||||
const drawStar = (
|
||||
star: Star,
|
||||
fromX: number,
|
||||
fromY: number,
|
||||
toX: number,
|
||||
toY: number,
|
||||
) => {
|
||||
const visibleAlpha = applyCenterProtection(toX, toY, star.alpha);
|
||||
const dx = toX - fromX;
|
||||
const dy = toY - fromY;
|
||||
const movementLength = Math.hypot(dx, dy);
|
||||
|
||||
if (star.tailLength < 1 || movementLength < 0.001) {
|
||||
ctx.globalAlpha = visibleAlpha;
|
||||
ctx.fillStyle = '#f8fbff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX, toY, star.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const dirX = dx / movementLength;
|
||||
const dirY = dy / movementLength;
|
||||
const tailX = toX - dirX * star.tailLength;
|
||||
const tailY = toY - dirY * star.tailLength;
|
||||
|
||||
const gradient = ctx.createLinearGradient(tailX, tailY, toX, toY);
|
||||
gradient.addColorStop(0, 'rgba(248, 251, 255, 0)');
|
||||
gradient.addColorStop(1, `rgba(248, 251, 255, ${visibleAlpha})`);
|
||||
|
||||
ctx.strokeStyle = gradient;
|
||||
ctx.lineWidth = clamp(star.radius * 0.9, 0.65, 1.6);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tailX, tailY);
|
||||
ctx.lineTo(toX, toY);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.globalAlpha = Math.min(1, visibleAlpha + 0.08);
|
||||
ctx.fillStyle = '#f8fbff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(toX, toY, clamp(star.radius * 0.72, 0.6, 1.45), 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
};
|
||||
|
||||
const drawCenterVeil = () => {
|
||||
const vp = getVanishingPoint();
|
||||
const veil = ctx.createRadialGradient(
|
||||
vp.x,
|
||||
vp.y,
|
||||
centerProtectRadius * 0.18,
|
||||
vp.x,
|
||||
vp.y,
|
||||
centerProtectRadius * 1.35,
|
||||
);
|
||||
veil.addColorStop(0, 'rgba(160, 185, 235, 0.08)');
|
||||
veil.addColorStop(0.55, 'rgba(90, 114, 170, 0.03)');
|
||||
veil.addColorStop(1, 'rgba(0, 0, 0, 0)');
|
||||
|
||||
ctx.fillStyle = veil;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
};
|
||||
|
||||
const drawVignette = () => {
|
||||
const vignette = ctx.createRadialGradient(
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.min(width, height) * 0.25,
|
||||
width / 2,
|
||||
height / 2,
|
||||
Math.max(width, height) * 0.95,
|
||||
);
|
||||
vignette.addColorStop(0, 'rgba(0, 0, 0, 0)');
|
||||
vignette.addColorStop(1, 'rgba(0, 0, 0, 0.82)');
|
||||
ctx.fillStyle = vignette;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
};
|
||||
|
||||
const drawFrame = (moveStars: boolean) => {
|
||||
ctx.fillStyle = 'rgba(2, 5, 10, 0.3)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
drawCenterVeil();
|
||||
|
||||
stars.forEach((star, index) => {
|
||||
const from = project(star, star.z);
|
||||
|
||||
if (moveStars) {
|
||||
star.z -= star.speed;
|
||||
}
|
||||
|
||||
const to = project(star, star.z);
|
||||
|
||||
const outOfBounds =
|
||||
to.x < -50 || to.x > width + 50 || to.y < -50 || to.y > height + 50;
|
||||
|
||||
if (star.z <= 0.22 || outOfBounds) {
|
||||
recycleStar(index);
|
||||
return;
|
||||
}
|
||||
|
||||
drawStar(star, from.x, from.y, to.x, to.y);
|
||||
});
|
||||
|
||||
drawVignette();
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
drawFrame(true);
|
||||
animationFrameId = requestAnimationFrame(render);
|
||||
};
|
||||
|
||||
const renderStatic = () => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
drawFrame(false);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
setSize();
|
||||
stars = Array.from({ length: getStarCount() }, () => createStar(false));
|
||||
|
||||
if (prefersReduced) {
|
||||
renderStatic();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMotionChange = (event: MediaQueryListEvent) => {
|
||||
prefersReduced = event.matches;
|
||||
|
||||
if (prefersReduced) {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
renderStatic();
|
||||
return;
|
||||
}
|
||||
|
||||
render();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
motionQuery.addEventListener('change', handleMotionChange);
|
||||
|
||||
if (prefersReduced) {
|
||||
renderStatic();
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
motionQuery.removeEventListener('change', handleMotionChange);
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, [vanishYOffset, centerProtectRadius]);
|
||||
|
||||
return <canvas ref={canvasRef} className="fixed inset-0 z-0 pointer-events-none bg-black" />;
|
||||
}
|
||||
332
src/components/LobbyBackground.tsx
Normal file
332
src/components/LobbyBackground.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function LobbyBackground() {
|
||||
const [isMount, setIsMount] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMount(true);
|
||||
}, []);
|
||||
|
||||
// Helper for random delay/duration style
|
||||
const getRandomStyle = () => ({
|
||||
animationDelay: `${Math.random() * 5}s`,
|
||||
animationDuration: `${2 + Math.random() * 4}s`,
|
||||
});
|
||||
|
||||
if (!isMount) return <div className="fixed inset-0 z-0 bg-black" />;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-0 overflow-hidden pointer-events-none bg-[radial-gradient(ellipse_at_center,_var(--tw-gradient-stops))] from-slate-900 via-slate-950 to-black">
|
||||
{/* Orion - Bottom Left */}
|
||||
<div className="absolute bottom-10 left-5 w-72 h-72 opacity-30 rotate-[-10deg]">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="w-full h-full text-indigo-200 fill-current"
|
||||
>
|
||||
<circle
|
||||
cx="250"
|
||||
cy="15"
|
||||
r="1.2"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="75"
|
||||
cy="25"
|
||||
r="1.2"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="45"
|
||||
cy="48"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="55"
|
||||
cy="52"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="30"
|
||||
cy="85"
|
||||
r="1.2"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="70"
|
||||
cy="80"
|
||||
r="1.2"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
|
||||
<line
|
||||
x1="25"
|
||||
y1="15"
|
||||
x2="45"
|
||||
y2="48"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="75"
|
||||
y1="25"
|
||||
x2="55"
|
||||
y2="52"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="45"
|
||||
y1="48"
|
||||
x2="30"
|
||||
y2="85"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="55"
|
||||
y1="52"
|
||||
x2="70"
|
||||
y2="80"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Auriga (마차부자리) - Top Right */}
|
||||
<div className="absolute top-10 right-10 w-64 h-64 opacity-25 rotate-[15deg]">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="w-full h-full text-blue-200 fill-current"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="15"
|
||||
r="1.3"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>{" "}
|
||||
{/* Capella */}
|
||||
<circle
|
||||
cx="20"
|
||||
cy="35"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="25"
|
||||
cy="75"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="75"
|
||||
cy="75"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="85"
|
||||
cy="35"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<line
|
||||
x1="50"
|
||||
y1="15"
|
||||
x2="20"
|
||||
y2="35"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="20"
|
||||
y1="35"
|
||||
x2="25"
|
||||
y2="75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="25"
|
||||
y1="75"
|
||||
x2="75"
|
||||
y2="75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="75"
|
||||
y1="75"
|
||||
x2="85"
|
||||
y2="35"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="85"
|
||||
y1="35"
|
||||
x2="50"
|
||||
y2="15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Big Dipper (북두칠성) - Top Left */}
|
||||
<div className="absolute top-20 left-10 w-80 h-48 opacity-25 rotate-[-5deg]">
|
||||
<svg
|
||||
viewBox="0 0 100 60"
|
||||
className="w-full h-full text-slate-200 fill-current"
|
||||
>
|
||||
{/* Handle */}
|
||||
<circle
|
||||
cx="10"
|
||||
cy="20"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="40"
|
||||
cy="35"
|
||||
r="1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
{/* Bowl */}
|
||||
<circle
|
||||
cx="55"
|
||||
cy="45"
|
||||
r="1.1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="75"
|
||||
cy="45"
|
||||
r="1.1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="80"
|
||||
cy="15"
|
||||
r="1.1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="10"
|
||||
r="1.1"
|
||||
className="animate-twinkle"
|
||||
style={getRandomStyle()}
|
||||
/>
|
||||
|
||||
<line
|
||||
x1="10"
|
||||
y1="20"
|
||||
x2="25"
|
||||
y2="25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="25"
|
||||
y1="25"
|
||||
x2="40"
|
||||
y2="35"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="40"
|
||||
y1="35"
|
||||
x2="55"
|
||||
y2="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="55"
|
||||
y1="45"
|
||||
x2="75"
|
||||
y2="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="75"
|
||||
y1="45"
|
||||
x2="80"
|
||||
y2="15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="80"
|
||||
y1="15"
|
||||
x2="60"
|
||||
y2="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
<line
|
||||
x1="60"
|
||||
y1="10"
|
||||
x2="55"
|
||||
y2="45"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.2"
|
||||
className="opacity-20"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/lib/constants.ts
Normal file
25
src/lib/constants.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Route } from "@/types";
|
||||
|
||||
export const ROUTES: Route[] = [
|
||||
{
|
||||
id: "station",
|
||||
name: "우주정거장",
|
||||
durationMinutes: 0, // 0 implies unlimited
|
||||
tag: "대기/자유",
|
||||
description: "시간 제한 없이 머무를 수 있는 안전지대",
|
||||
},
|
||||
{
|
||||
id: "orion",
|
||||
name: "오리온",
|
||||
durationMinutes: 60,
|
||||
tag: "딥워크",
|
||||
description: "60분 집중 항해",
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: "쌍둥이자리",
|
||||
durationMinutes: 30,
|
||||
tag: "숏스프린트",
|
||||
description: "30분 집중 항해",
|
||||
},
|
||||
];
|
||||
43
src/lib/store.ts
Normal file
43
src/lib/store.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Voyage, UserPreferences } from "@/types";
|
||||
|
||||
const KEYS = {
|
||||
HISTORY: 'focustella_history_v1',
|
||||
CURRENT: 'focustella_current_v1',
|
||||
PREFS: 'focustella_prefs_v1',
|
||||
};
|
||||
|
||||
export const getHistory = (): Voyage[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const item = localStorage.getItem(KEYS.HISTORY);
|
||||
return item ? JSON.parse(item) : [];
|
||||
};
|
||||
|
||||
export const saveToHistory = (voyage: Voyage) => {
|
||||
const history = getHistory();
|
||||
// Add to beginning
|
||||
localStorage.setItem(KEYS.HISTORY, JSON.stringify([voyage, ...history]));
|
||||
};
|
||||
|
||||
export const getCurrentVoyage = (): Voyage | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const item = localStorage.getItem(KEYS.CURRENT);
|
||||
return item ? JSON.parse(item) : null;
|
||||
};
|
||||
|
||||
export const saveCurrentVoyage = (voyage: Voyage | null) => {
|
||||
if (voyage === null) {
|
||||
localStorage.removeItem(KEYS.CURRENT);
|
||||
} else {
|
||||
localStorage.setItem(KEYS.CURRENT, JSON.stringify(voyage));
|
||||
}
|
||||
};
|
||||
|
||||
export const getPreferences = (): UserPreferences => {
|
||||
if (typeof window === 'undefined') return { hideSeconds: false };
|
||||
const item = localStorage.getItem(KEYS.PREFS);
|
||||
return item ? JSON.parse(item) : { hideSeconds: false };
|
||||
};
|
||||
|
||||
export const savePreferences = (prefs: UserPreferences) => {
|
||||
localStorage.setItem(KEYS.PREFS, JSON.stringify(prefs));
|
||||
};
|
||||
28
src/types/index.ts
Normal file
28
src/types/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export interface Route {
|
||||
id: string;
|
||||
name: string;
|
||||
durationMinutes: number;
|
||||
tag: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export type VoyageStatus = 'completed' | 'partial' | 'reoriented' | 'aborted' | 'in_progress';
|
||||
|
||||
export interface Voyage {
|
||||
id: string;
|
||||
routeId: string;
|
||||
routeName: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
durationMinutes: number;
|
||||
status: VoyageStatus;
|
||||
missionText: string;
|
||||
notes?: string;
|
||||
debriefProgress?: string;
|
||||
nextAction?: string;
|
||||
blockerTag?: string;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
hideSeconds: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user