diff --git a/.env.example b/.env.example index 88e1035..85daabc 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key-here # Only needed if you use service role in Route Handlers or Server Actions. # NEVER expose this key to the browser or prefix with NEXT_PUBLIC_. # SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here + +# --- OpenAI (server-only) ----------------------------------- +# Find this in: https://platform.openai.com/ → API Keys +# NEVER expose this key to the browser or prefix with NEXT_PUBLIC_. +OPENAI_API_KEY=sk-... diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c812053..f5f2cd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,24 +7,9 @@ on: branches: [main] jobs: - security: - name: Secret Scanning - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Scan for secrets - uses: gitleaks/gitleaks-action@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - quality: name: Lint, Build & Test runs-on: ubuntu-latest - needs: [security] steps: - name: Checkout uses: actions/checkout@v4 diff --git a/CLAUDE.md b/CLAUDE.md index acd2660..e6184cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,8 @@ # hackathon-starter Development Guidelines ## Active Technologies +- TypeScript 5.7, Node.js (Next.js runtime) + Next.js 15, `openai` SDK, `@supabase/supabase-js` v2, `@supabase/ssr` v0.5 (001-hirameki-battle) +- Supabase PostgreSQL — `game_scores` table (see data-model.md) (001-hirameki-battle) - TypeScript 5.3+ + Next.js 15 (App Router), @supabase/supabase-js v2, @supabase/ssr v0.1+, Biome v1.8+, Vitest v1+ @@ -61,3 +63,6 @@ git commit -m "chore: sync template improvements from " See CONTRIBUTING.md for the full workflow. + +## Recent Changes +- 001-hirameki-battle: Added TypeScript 5.7, Node.js (Next.js runtime) + Next.js 15, `openai` SDK, `@supabase/supabase-js` v2, `@supabase/ssr` v0.5 diff --git a/app/(auth)/callback/route.ts b/app/(auth)/callback/route.ts index f2c1029..937b8ab 100644 --- a/app/(auth)/callback/route.ts +++ b/app/(auth)/callback/route.ts @@ -1,23 +1,23 @@ -import { createClient } from "@/lib/supabase/server" -import { NextResponse } from "next/server" +import { createClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; /** * Handles OAuth provider callbacks and magic link confirmations. * Supabase redirects here after the user authenticates externally. */ export async function GET(request: Request) { - const { searchParams, origin } = new URL(request.url) - const code = searchParams.get("code") + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get("code"); if (code) { - const supabase = await createClient() - const { error } = await supabase.auth.exchangeCodeForSession(code) + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); if (!error) { - return NextResponse.redirect(`${origin}/`) + return NextResponse.redirect(`${origin}/`); } } // Return to an error page if the code exchange fails or no code is present - return NextResponse.redirect(`${origin}/auth/error`) + return NextResponse.redirect(`${origin}/auth/error`); } diff --git a/app/api/generate-topic/route.ts b/app/api/generate-topic/route.ts new file mode 100644 index 0000000..5769202 --- /dev/null +++ b/app/api/generate-topic/route.ts @@ -0,0 +1,44 @@ +import { getOpenAIClient } from "@/lib/openai"; +import OpenAI from "openai"; +import { NextResponse } from "next/server"; + +export async function POST() { + try { + const completion = await getOpenAIClient().chat.completions.create({ + model: "gpt-4o", + max_tokens: 128, + messages: [ + { + role: "user", + content: + "日本語で、日常的なモノや状況を対象にした「○○の新しい使い方・活用法」形式のお題を1つ生成してください。お題のテキストだけを返してください。例:「傘の新しい使い方」", + }, + ], + }); + + const text = completion.choices[0]?.message?.content?.trim(); + if (!text) { + return NextResponse.json({ error: "予期しないエラーが発生しました。" }, { status: 500 }); + } + + return NextResponse.json({ topic: text }); + } catch (err) { + if (err instanceof OpenAI.RateLimitError) { + return NextResponse.json( + { + error: "リクエストが多すぎます。しばらく待ってから再試行してください。", + retryAfter: 60, + }, + { status: 429 }, + ); + } + if (err instanceof OpenAI.APIConnectionError) { + return NextResponse.json( + { error: "トピック生成サービスが一時的に利用できません。再試行してください。" }, + { status: 503 }, + ); + } + console.error("[generate-topic] Unexpected error:", err); + return NextResponse.json({ error: "予期しないエラーが発生しました。" }, { status: 500 }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index ae4436f..4f4d29e 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,5 @@ -import { createClient } from "@/lib/supabase/server" -import { NextResponse } from "next/server" +import { createClient } from "@/lib/supabase/server"; +import { NextResponse } from "next/server"; /** * GET /api/health @@ -8,26 +8,26 @@ import { NextResponse } from "next/server" */ export async function GET() { try { - const supabase = await createClient() - const { error } = await supabase.from("demo_items").select("id").limit(1) + const supabase = await createClient(); + const { error } = await supabase.from("demo_items").select("id").limit(1); if (error) { - console.error("[health] DB query error:", error.message) + console.error("[health] DB query error:", error.message); return NextResponse.json( { status: "error", message: "Database connection failed" }, { status: 503 }, - ) + ); } return NextResponse.json({ status: "ok", timestamp: new Date().toISOString(), - }) + }); } catch (err) { - console.error("[health] Unexpected error:", err) + console.error("[health] Unexpected error:", err); return NextResponse.json( { status: "error", message: "Internal server error" }, { status: 500 }, - ) + ); } } diff --git a/app/api/score-idea/route.ts b/app/api/score-idea/route.ts new file mode 100644 index 0000000..e55624d --- /dev/null +++ b/app/api/score-idea/route.ts @@ -0,0 +1,131 @@ +import { getOpenAIClient } from "@/lib/openai"; +import { insertGameScore } from "@/lib/game-scores"; +import OpenAI from "openai"; +import { type NextRequest, NextResponse } from "next/server"; + +const SCORE_FUNCTION: OpenAI.Chat.Completions.ChatCompletionTool = { + type: "function", + function: { + name: "submit_score", + description: "プレイヤーのアイデアを3軸で採点し、一言コメントを返す", + parameters: { + type: "object", + properties: { + originality: { + type: "number", + description: "独創性スコア(0〜33点):既成概念にとらわれない独自性", + }, + practicality: { + type: "number", + description: "実用性スコア(0〜33点):実際に使えそうか・現実的か", + }, + unexpectedness: { + type: "number", + description: "意外性スコア(0〜34点):予想外・驚きがあるか", + }, + comment: { + type: "string", + description: "採点理由・講評を含む一言コメント(日本語)", + }, + }, + required: ["originality", "practicality", "unexpectedness", "comment"], + }, + }, +}; + +export async function POST(request: NextRequest) { + let body: { sessionId?: string; topic?: string; idea?: string }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "無効なリクエスト形式です。" }, { status: 400 }); + } + + const { sessionId, topic, idea } = body; + + if (!sessionId || !topic) { + return NextResponse.json({ error: "無効なリクエスト形式です。" }, { status: 400 }); + } + if (!idea || idea.trim().length === 0) { + return NextResponse.json({ error: "アイデアを入力してください。" }, { status: 400 }); + } + if (idea.length > 500) { + return NextResponse.json( + { error: "アイデアは500文字以内で入力してください。" }, + { status: 400 }, + ); + } + + try { + const completion = await getOpenAIClient().chat.completions.create({ + model: "gpt-4o", + max_tokens: 512, + tools: [SCORE_FUNCTION], + tool_choice: { type: "function", function: { name: "submit_score" } }, + messages: [ + { + role: "user", + content: `お題:「${topic}」\nプレイヤーのアイデア:「${idea}」\n\nこのアイデアを独創性・実用性・意外性の3軸で採点し、一言コメントを付けてください。`, + }, + ], + }); + + const toolCall = completion.choices[0]?.message?.tool_calls?.[0]; + if (!toolCall || toolCall.type !== "function") { + return NextResponse.json( + { error: "採点に失敗しました。再試行してください。" }, + { status: 500 }, + ); + } + + const scores = JSON.parse(toolCall.function.arguments) as { + originality: number; + practicality: number; + unexpectedness: number; + comment: string; + }; + + const totalScore = scores.originality + scores.practicality + scores.unexpectedness; + + const saved = await insertGameScore({ + sessionId, + topic, + userIdea: idea, + scoreOriginality: scores.originality, + scorePracticality: scores.practicality, + scoreUnexpectedness: scores.unexpectedness, + totalScore, + aiComment: scores.comment, + }); + + return NextResponse.json({ + scoreId: saved.id, + originality: scores.originality, + practicality: scores.practicality, + unexpectedness: scores.unexpectedness, + totalScore, + comment: scores.comment, + }); + } catch (err) { + if (err instanceof OpenAI.RateLimitError) { + return NextResponse.json( + { + error: "リクエストが多すぎます。しばらく待ってから再試行してください。", + retryAfter: 60, + }, + { status: 429 }, + ); + } + if (err instanceof OpenAI.APIConnectionError) { + return NextResponse.json( + { error: "採点サービスが一時的に利用できません。入力内容は保持されています。" }, + { status: 503 }, + ); + } + console.error("[score-idea] Unexpected error:", err); + return NextResponse.json( + { error: "採点に失敗しました。再試行してください。" }, + { status: 500 }, + ); + } +} diff --git a/app/api/scores/route.ts b/app/api/scores/route.ts new file mode 100644 index 0000000..4d174a2 --- /dev/null +++ b/app/api/scores/route.ts @@ -0,0 +1,40 @@ +import { getGamesPlayed, getPersonalBest, getRecentScores } from "@/lib/game-scores"; +import type { GameScore } from "@/types/database"; +import type { ScoreHistoryItem } from "@/types/game"; +import { type NextRequest, NextResponse } from "next/server"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const sessionId = searchParams.get("sessionId"); + const limitParam = searchParams.get("limit"); + + if (!sessionId) { + return NextResponse.json({ error: "sessionIdは必須です。" }, { status: 400 }); + } + + const limit = limitParam ? Math.min(Number(limitParam), 100) : 10; + + try { + const [rawScores, personalBest, gamesPlayed] = await Promise.all([ + getRecentScores(sessionId, limit), + getPersonalBest(sessionId), + getGamesPlayed(sessionId), + ]); + + const scores: ScoreHistoryItem[] = rawScores.map((s: GameScore) => ({ + id: s.id, + topic: s.topic, + totalScore: s.total_score, + originality: s.score_originality, + practicality: s.score_practicality, + unexpectedness: s.score_unexpectedness, + comment: s.ai_comment, + playedAt: s.played_at, + })); + + return NextResponse.json({ scores, personalBest, gamesPlayed }); + } catch (err) { + console.error("[scores] Error fetching scores:", err); + return NextResponse.json({ error: "スコアの取得に失敗しました。" }, { status: 500 }); + } +} diff --git a/app/error.tsx b/app/error.tsx index 6c1ad4a..3a3ffc4 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import { useEffect } from "react" +import { useEffect } from "react"; interface ErrorProps { - error: Error & { digest?: string } - reset: () => void + error: Error & { digest?: string }; + reset: () => void; } /** @@ -13,8 +13,8 @@ interface ErrorProps { */ export default function Error({ error, reset }: ErrorProps) { useEffect(() => { - console.error("[Error boundary]", error) - }, [error]) + console.error("[Error boundary]", error); + }, [error]); return (
- ) + ); } diff --git a/app/game/page.tsx b/app/game/page.tsx new file mode 100644 index 0000000..ca1873b --- /dev/null +++ b/app/game/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { GameTimer } from "@/components/features/game/GameTimer"; +import { IdeaInput } from "@/components/features/game/IdeaInput"; +import { TopicDisplay } from "@/components/features/game/TopicDisplay"; +import { getSessionId } from "@/lib/session"; +import type { GameResult, ScoreResponse, TopicResponse } from "@/types/game"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; + +type GamePhase = "loading" | "playing" | "submitting" | "timeout" | "error"; + +export default function GamePage() { + const router = useRouter(); + const [phase, setPhase] = useState("loading"); + const [topic, setTopic] = useState(""); + const [topicError, setTopicError] = useState(""); + const [submitError, setSubmitError] = useState(""); + const [lastIdea, setLastIdea] = useState(""); + const timerPaused = phase !== "playing"; + + const fetchTopic = useCallback(async () => { + setPhase("loading"); + setTopicError(""); + try { + const res = await fetch("/api/generate-topic", { method: "POST" }); + const data: TopicResponse | { error: string } = await res.json(); + if (!res.ok || "error" in data) { + setTopicError("error" in data ? data.error : "お題の取得に失敗しました"); + setPhase("error"); + return; + } + setTopic((data as TopicResponse).topic); + setPhase("playing"); + } catch { + setTopicError("お題の取得に失敗しました"); + setPhase("error"); + } + }, []); + + useEffect(() => { + fetchTopic(); + }, [fetchTopic]); + + const handleExpire = useCallback(() => { + setPhase("timeout"); + }, []); + + const handleTick = useCallback((_remaining: number) => {}, []); + + const handleSubmit = useCallback( + async (idea: string) => { + setLastIdea(idea); + setSubmitError(""); + setPhase("submitting"); + + let sessionId: string; + try { + sessionId = getSessionId(); + } catch { + setSubmitError("セッションの取得に失敗しました。再試行してください。"); + setPhase("playing"); + return; + } + + try { + const res = await fetch("/api/score-idea", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sessionId, topic, idea }), + }); + const data: ScoreResponse | { error: string } = await res.json(); + + if (!res.ok || "error" in data) { + setSubmitError("error" in data ? data.error : "採点に失敗しました。再試行してください。"); + setPhase("playing"); + return; + } + + const score = data as ScoreResponse; + const result: GameResult = { + topic, + idea, + scoreId: score.scoreId, + originality: score.originality, + practicality: score.practicality, + unexpectedness: score.unexpectedness, + totalScore: score.totalScore, + comment: score.comment, + playedAt: new Date().toISOString(), + }; + sessionStorage.setItem("game_result", JSON.stringify(result)); + router.push("/result"); + } catch { + setSubmitError("採点に失敗しました。再試行してください。"); + setPhase("playing"); + } + }, + [topic, router], + ); + + return ( +
+

⚡ 閃き対決

+ + + + {phase === "error" && ( +
+

{topicError || "お題の取得に失敗しました"}

+ +
+ )} + + {(phase === "playing" || phase === "submitting") && ( + + )} + + {phase === "timeout" && ( +
+

+ タイムアップ! +

+ +
+ )} + + {(phase === "playing" || phase === "submitting") && ( +
+ {submitError &&

{submitError}

} + +
+ )} +
+ ); +} diff --git a/app/history/page.tsx b/app/history/page.tsx new file mode 100644 index 0000000..a03a39b --- /dev/null +++ b/app/history/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { ScoreList } from "@/components/features/history/ScoreList"; +import { getSessionId } from "@/lib/session"; +import type { ScoreHistoryResponse } from "@/types/game"; +import Link from "next/link"; +import { useEffect, useState } from "react"; + +export default function HistoryPage() { + const [data, setData] = useState(null); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + try { + const sessionId = getSessionId(); + const res = await fetch(`/api/scores?sessionId=${sessionId}`); + if (!res.ok) { + setError("スコアの取得に失敗しました。"); + return; + } + const json: ScoreHistoryResponse = await res.json(); + setData(json); + } catch { + setError("スコアの取得に失敗しました。"); + } finally { + setLoading(false); + } + } + load(); + }, []); + + return ( +
+
+

📊 スコア履歴

+ + ホームへ + +
+ + {data && ( +
+
+
{data.gamesPlayed}
+
プレイ回数
+
+
+
+ {data.personalBest ?? "-"} +
+
自己ベスト
+
+
+ )} + +
+ {loading &&

読み込み中…

} + {error &&

{error}

} + {data && } +
+ + + 新しくプレイ + +
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 1dc9946..b961614 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,20 +1,20 @@ -import type { Metadata } from "next" -import "./globals.css" +import type { Metadata } from "next"; +import "./globals.css"; export const metadata: Metadata = { title: "Hackathon Starter", description: "A Next.js + Supabase template for rapid hackathon prototyping. Replace this with your project description.", -} +}; export default function RootLayout({ children, }: Readonly<{ - children: React.ReactNode + children: React.ReactNode; }>) { return ( {children} - ) + ); } diff --git a/app/not-found.tsx b/app/not-found.tsx index b51fcc2..8570e6e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,4 +1,4 @@ -import Link from "next/link" +import Link from "next/link"; /** * 404 — page not found handler. @@ -34,5 +34,5 @@ export default function NotFound() { Back to home - ) + ); } diff --git a/app/page.tsx b/app/page.tsx index 1ee1660..569ad84 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,24 +1,4 @@ -import { DatabaseStatus } from "@/components/features/DatabaseStatus" -import { createClient } from "@/lib/supabase/server" -import type { DemoItem } from "@/types" -import { Suspense } from "react" - -async function fetchDemoItems(): Promise<{ items: DemoItem[] | null; error: string | null }> { - try { - const supabase = await createClient() - const { data, error } = await supabase.from("demo_items").select("*").order("id") - - if (error) return { items: null, error: "Database connection failed" } - return { items: data as DemoItem[], error: null } - } catch { - return { items: null, error: "Failed to connect to database" } - } -} - -async function HomeContent() { - const { items, error } = await fetchDemoItems() - return -} +import Link from "next/link"; export default function Home() { return ( @@ -36,27 +16,53 @@ export default function Home() { }} >
-

🚀 Hackathon Starter

-

- Next.js 15 + Supabase template — replace this page with your project +

⚡ 閃き対決

+

+ AIのお題に60秒でアイデアを出し、採点してもらおう!

-
- - Checking database connection… - - } +
+ + スタート + + - - + スコア履歴 +
- -
- See docs/spec-kit/README.md to start building with spec-kit -
- ) + ); } diff --git a/app/result/page.tsx b/app/result/page.tsx new file mode 100644 index 0000000..00ed51d --- /dev/null +++ b/app/result/page.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { AiComment } from "@/components/features/result/AiComment"; +import { ScoreBreakdown } from "@/components/features/result/ScoreBreakdown"; +import type { GameResult } from "@/types/game"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function ResultPage() { + const router = useRouter(); + const [result, setResult] = useState(null); + const [error, setError] = useState(false); + + useEffect(() => { + try { + const raw = sessionStorage.getItem("game_result"); + if (!raw) { + setError(true); + return; + } + setResult(JSON.parse(raw) as GameResult); + } catch { + setError(true); + } + }, []); + + const handleReplay = () => { + sessionStorage.removeItem("game_result"); + router.push("/game"); + }; + + if (error) { + return ( +
+

結果が見つかりませんでした。

+ ゲームを始める +
+ ); + } + + if (!result) return null; + + return ( +
+

⚡ 採点結果

+ +
+

お題:{result.topic}

+

あなたのアイデア:{result.idea}

+
+ +
+ +
+ +
+

AIコメント

+ +
+ +
+ + + スコアを見る + +
+
+ ); +} diff --git a/biome.json b/biome.json index 3c4c67f..e11b2f7 100644 --- a/biome.json +++ b/biome.json @@ -22,12 +22,7 @@ } }, "files": { - "ignore": [ - "node_modules", - ".next", - "coverage", - "specs" - ] + "ignore": ["node_modules", ".next", "coverage", "specs"] }, "javascript": { "globals": ["React"] diff --git a/bun.lock b/bun.lock index d065b4d..9cb7653 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@supabase/ssr": "^0.5.0", "@supabase/supabase-js": "^2.49.0", "next": "^15.2.0", + "openai": "^6.32.0", "react": "^19.0.0", "react-dom": "^19.0.0", }, @@ -546,6 +547,8 @@ "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + "openai": ["openai@6.32.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-j3k+BjydAf8yQlcOI7WUQMQTbbF5GEIMAE2iZYCOzwwB3S2pCheaWYp+XZRNAch4jWVc52PMDGRRjutao3lLCg=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], diff --git a/components/features/DatabaseStatus.tsx b/components/features/DatabaseStatus.tsx index 5aa749e..a1230d1 100644 --- a/components/features/DatabaseStatus.tsx +++ b/components/features/DatabaseStatus.tsx @@ -1,10 +1,10 @@ -"use client" +"use client"; -import type { DemoItem } from "@/types" +import type { DemoItem } from "@/types"; interface DatabaseStatusProps { - items: DemoItem[] | null - error: string | null + items: DemoItem[] | null; + error: string | null; } /** @@ -29,7 +29,7 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) { Supabase project is not paused.

- ) + ); } if (!items || items.length === 0) { @@ -47,7 +47,7 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) { Run supabase/seed.sql in your Supabase dashboard to add demo rows.

- ) + ); } return ( @@ -58,9 +58,7 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) { borderRadius: "0.5rem", }} > -

- ✓ Connected to Supabase -

+

✓ Connected to Supabase

    {items.map((item) => (
  • @@ -69,5 +67,5 @@ export function DatabaseStatus({ items, error }: DatabaseStatusProps) { ))}
- ) + ); } diff --git a/components/features/game/GameTimer.tsx b/components/features/game/GameTimer.tsx new file mode 100644 index 0000000..9ab4c24 --- /dev/null +++ b/components/features/game/GameTimer.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +interface GameTimerProps { + isPaused: boolean; + onExpire: () => void; + onTick: (remaining: number) => void; +} + +export function GameTimer({ isPaused, onExpire, onTick }: GameTimerProps) { + const [remaining, setRemaining] = useState(60); + const onExpireRef = useRef(onExpire); + const onTickRef = useRef(onTick); + + useEffect(() => { + onExpireRef.current = onExpire; + }, [onExpire]); + + useEffect(() => { + onTickRef.current = onTick; + }, [onTick]); + + useEffect(() => { + if (isPaused || remaining === 0) return; + + const id = setInterval(() => { + setRemaining((prev) => { + const next = prev - 1; + onTickRef.current(next); + if (next === 0) { + onExpireRef.current(); + } + return next; + }); + }, 1000); + + return () => clearInterval(id); + }, [isPaused, remaining]); + + const isExpired = remaining === 0; + const isUrgent = remaining <= 10 && !isExpired; + + return ( +
+ {isExpired ? "時間切れ!" : `${remaining}秒`} +
+ ); +} diff --git a/components/features/game/IdeaInput.tsx b/components/features/game/IdeaInput.tsx new file mode 100644 index 0000000..0b18be4 --- /dev/null +++ b/components/features/game/IdeaInput.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; + +interface IdeaInputProps { + isSubmitting: boolean; + isDisabled: boolean; + onSubmit: (idea: string) => void; +} + +export function IdeaInput({ isSubmitting, isDisabled, onSubmit }: IdeaInputProps) { + const [value, setValue] = useState(""); + const MAX = 500; + const remaining = MAX - value.length; + const isNearLimit = value.length >= 450; + + const handleSubmit = () => { + const trimmed = value.trim(); + if (!trimmed) return; + onSubmit(trimmed); + }; + + return ( +
+