feat: 세션 진행화면 생성

This commit is contained in:
2026-02-13 11:35:06 +09:00
parent 73654788da
commit 1fd357cf95
3 changed files with 269 additions and 0 deletions

118
src/app/flight/page.tsx Normal file
View 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 />
{/* 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">
&ldquo;{voyage.missionText}&rdquo;
</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>
);
}

View File

@@ -123,3 +123,28 @@
@apply bg-background text-foreground;
}
}
@layer utilities {
@keyframes twinkle {
0%, 100% { opacity: 0.8; transform: scale(1); }
50% { opacity: 0.3; transform: scale(0.8); }
}
.animate-twinkle {
animation: twinkle 4s ease-in-out infinite;
}
.animate-twinkle-delay-1 {
animation: twinkle 5s ease-in-out infinite;
animation-delay: 1s;
}
.animate-twinkle-delay-2 {
animation: twinkle 6s ease-in-out infinite;
animation-delay: 2s;
}
@media (prefers-reduced-motion: reduce) {
.animate-twinkle, .animate-twinkle-delay-1, .animate-twinkle-delay-2 {
animation: none;
opacity: 0.8;
}
}
}

View File

@@ -0,0 +1,126 @@
'use client';
import { useEffect, useRef } from 'react';
export default function FlightBackground() {
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;
// Check prefers-reduced-motion
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const setSize = () => {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
};
setSize();
window.addEventListener('resize', setSize);
// Star configuration
const starCount = 150;
const stars: { x: number; y: number; z: number; speed: number }[] = [];
// Target point (slightly above center)
// We will move stars away from this point.
// Instead of full 3D, let's do a 2D split flow.
// Stars spawn randomly.
// If x < center, move left-down.
// If x > center, move right-down.
// Initial Spawn
for (let i = 0; i < starCount; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height,
z: Math.random() * 2 + 0.5, // depth/size
speed: Math.random() * 2 + 0.5
});
}
let animationFrameId: number;
const render = () => {
if (!ctx) return;
ctx.fillStyle = 'rgba(0, 0, 0, 0.2)'; // Trails
ctx.fillRect(0, 0, width, height);
// ctx.clearRect(0, 0, width, height); // Clear
const targetX = width / 2;
// targetY is virtual, above screen.
// Movement vector:
// Left side: dx = -speed, dy = speed
// Right side: dx = speed, dy = speed
ctx.fillStyle = '#ffffff';
stars.forEach((star) => {
// Reduced motion: very slow or static
const moveFactor = prefersReduced ? 0.1 : 1.0;
// Determine direction based on side
const isLeft = star.x < targetX;
// Simple perspective-ish flow
// The further from center X, the faster dx
// The further down, the faster dy
// Let's stick to the PRD requirements:
// Left -> Bottom-Left
// Right -> Bottom-Right
const dx = (isLeft ? -1 : 1) * star.speed * 0.5 * moveFactor;
const dy = star.speed * moveFactor;
star.x += dx;
star.y += dy;
// Reset if out of bounds
if (star.y > height || star.x < 0 || star.x > width) {
star.y = -10;
star.x = Math.random() * width;
// Bias respawn near center-top for "flow" effect?
// Or just random top. Random top is safer for uniform coverage.
}
const size = Math.max(0.5, star.z * (star.y / height) * 2);
const opacity = Math.min(1, star.y / 200); // Fade in from top
ctx.globalAlpha = opacity;
ctx.beginPath();
ctx.arc(star.x, star.y, size, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1.0;
});
// Draw subtle cockpit frame or vignette
// Gradient vignette
const gradient = ctx.createRadialGradient(width/2, height/2, height/3, width/2, height/2, height);
gradient.addColorStop(0, 'transparent');
gradient.addColorStop(1, 'rgba(0,0,0,0.8)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
animationFrameId = requestAnimationFrame(render);
};
render();
return () => {
window.removeEventListener('resize', setSize);
cancelAnimationFrame(animationFrameId);
};
}, []);
return <canvas ref={canvasRef} className="fixed inset-0 z-0 pointer-events-none bg-black" />;
}