feat: 메인화면 배경 생성

This commit is contained in:
2026-02-13 11:36:05 +09:00
parent 1fd357cf95
commit c37678ca01
7 changed files with 227 additions and 272 deletions

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
.idea
.gemini

View File

@@ -1,35 +1,35 @@
// app/layout.tsx
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: {
default: "QuietSprint",
template: "%s · QuietSprint",
},
description: "One goal. Start now.",
title: "Focustella",
description: "Space-themed focus timer",
};
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<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>
);

View File

@@ -1,266 +1,65 @@
// app/page.tsx
"use client";
'use client';
import Link from "next/link";
import { ROUTES } from "@/lib/constants";
import { useEffect, useState } from "react";
import { getCurrentVoyage } from "@/lib/store";
import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import LobbyBackground from "@/components/LobbyBackground";
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 Mode = "freeflow" | "sprint" | "deepwork";
const PRIMARY = "#2F6FED";
const PRIMARY_HOVER = "#295FD1";
function modeLabel(mode: Mode) {
switch (mode) {
case "freeflow":
return "프리플로우";
case "sprint":
return "스프린트";
case "deepwork":
return "딥워크";
}
}
function modeMeta(mode: Mode) {
if (mode === "freeflow") return "무제한";
if (mode === "sprint") return "25분";
return "90분";
}
function startLabel(mode: Mode) {
if (mode === "freeflow") return "집중 시작";
return `집중 시작 (${modeMeta(mode)})`;
}
export default function HomePage() {
export default function Home() {
const router = useRouter();
const [isMount, setIsMount] = useState(false);
const [open, setOpen] = useState(false);
const [mode, setMode] = useState<Mode | null>(null);
const [goal, setGoal] = useState("");
useEffect(() => {
setIsMount(true);
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);
};
if (!isMount) return null;
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">
QuietSprint
</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">
{/* Background Layer */}
<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>
{/* Content Layer */}
<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="grid gap-6 w-full max-w-5xl grid-cols-1 md:grid-cols-2 lg:grid-cols-3 place-items-stretch">
{ROUTES.map((route) => (
<div key={route.id} 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">
<div className="flex justify-between items-start mb-4">
<div>
<h3 className="font-bold text-xl text-indigo-100 group-hover:text-white transition-colors">{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-1 inline-block">
{route.tag}
</span>
</div>
<span className="text-3xl font-light text-slate-300 group-hover:text-white transition-colors">{route.durationMinutes}<span className="text-sm ml-1 text-slate-500">min</span></span>
</div>
<p className="text-sm text-slate-400 mb-8 flex-1 leading-relaxed">{route.description}</p>
<Link
href={`/boarding?routeId=${route.id}`}
className="flex w-full items-center justify-center py-4 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]"
>
<div className="w-full">
<div className="text-2xl font-semibold leading-none">
</Link>
</div>
<div className="mt-2 text-lg opacity-90"></div>
</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="mt-16 text-center py-6 text-xs text-slate-500 font-medium tracking-wide uppercase">
3
</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>
);
}

View File

@@ -0,0 +1,59 @@
export default 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">
{/* Orion (Approximate) - Bottom Left */}
<div className="absolute bottom-20 left-10 w-80 h-80 opacity-20 rotate-[-15deg]">
<svg viewBox="0 0 100 100" className="w-full h-full text-indigo-200 fill-current">
{/* Stars */}
<circle cx="20" cy="10" r="1.5" className="animate-twinkle" /> {/* Betelgeuse */}
<circle cx="80" cy="25" r="1.5" className="animate-twinkle-delay-1" /> {/* Rigel */}
<circle cx="25" cy="85" r="1.2" className="animate-twinkle-delay-2" /> {/* Saiph */}
<circle cx="75" cy="80" r="1.2" className="animate-twinkle" /> {/* Bellatrix */}
{/* Belt */}
<circle cx="45" cy="50" r="1" className="animate-twinkle-delay-1" />
<circle cx="50" cy="48" r="1" className="animate-twinkle" />
<circle cx="55" cy="46" r="1" className="animate-twinkle-delay-2" />
{/* Lines */}
<line x1="20" y1="10" x2="45" y2="50" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="80" y1="25" x2="55" y2="46" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="25" y1="85" x2="45" y2="50" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="75" y1="80" x2="55" y2="46" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
</svg>
</div>
{/* Lyra (Approximate) - Top Right */}
<div className="absolute top-20 right-20 w-64 h-64 opacity-20 rotate-[10deg]">
<svg viewBox="0 0 100 100" className="w-full h-full text-blue-200 fill-current">
<circle cx="50" cy="10" r="1.8" className="animate-twinkle" /> {/* Vega */}
<circle cx="30" cy="30" r="1" className="animate-twinkle-delay-1" />
<circle cx="70" cy="30" r="1" className="animate-twinkle-delay-2" />
<circle cx="35" cy="60" r="1" className="animate-twinkle" />
<circle cx="65" cy="60" r="1" className="animate-twinkle-delay-1" />
<line x1="50" y1="10" x2="30" y2="30" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="50" y1="10" x2="70" y2="30" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="30" y1="30" x2="35" y2="60" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="70" y1="30" x2="65" y2="60" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
<line x1="35" y1="60" x2="65" y2="60" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
</svg>
</div>
{/* Ursa Major (Big Dipper) - Top Left */}
<div className="absolute top-10 left-10 md:left-40 w-96 h-64 opacity-15 rotate-[20deg]">
<svg viewBox="0 0 100 100" className="w-full h-full text-white fill-current">
<circle cx="10" cy="30" r="1.2" className="animate-twinkle-delay-2" />
<circle cx="30" cy="25" r="1.2" className="animate-twinkle" />
<circle cx="45" cy="35" r="1.2" className="animate-twinkle-delay-1" />
<circle cx="60" cy="40" r="1.2" className="animate-twinkle-delay-2" />
<circle cx="70" cy="60" r="1.2" className="animate-twinkle" />
<circle cx="90" cy="60" r="1.2" className="animate-twinkle-delay-1" />
<circle cx="90" cy="40" r="1.2" className="animate-twinkle-delay-2" />
<polyline points="10,30 30,25 45,35 60,40 70,60 90,60 90,40 60,40" fill="none" stroke="currentColor" strokeWidth="0.2" className="opacity-30" />
</svg>
</div>
</div>
);
}

25
src/lib/constants.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Route } from "@/types";
export const ROUTES: Route[] = [
{
id: 'orion',
name: '오리온',
durationMinutes: 180,
tag: '딥워크',
description: '집필, 코딩 등 긴 호흡이 필요한 작업'
},
{
id: 'lyra',
name: '거문고',
durationMinutes: 60,
tag: '정리/기획',
description: '기획안 작성, 문서 정리'
},
{
id: 'cygnus',
name: '백조',
durationMinutes: 30,
tag: '리뷰/회고',
description: '하루 회고, 코드 리뷰'
},
];

43
src/lib/store.ts Normal file
View 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
View 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;
}