ba-cafe

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README | LICENSE

commit 0e454634c7d6f80c1d12c30f9046e1cd422fa087
parent 86286972d2e7f981f724b16aae6dc92cacb4335f
Author: Sunny <kajimalu10@gmail.com>
Date:   Wed, 20 Aug 2025 11:55:11 +0900

add invitation

Diffstat:
Msrc/app/globals.css | 35+++++++++++++++++++++++++++++++++++
Msrc/app/page.tsx | 256+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Asrc/components/CountdownDisplay.tsx | 26++++++++++++++++++++++++++
Asrc/components/TimerCard.tsx | 18++++++++++++++++++
Asrc/components/TimerDashboard.tsx | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 341 insertions(+), 104 deletions(-)

diff --git a/src/app/globals.css b/src/app/globals.css @@ -154,3 +154,38 @@ body { .btn-sidepane { @apply w-full flex items-center p-2 rounded-md text-slate-300 hover:bg-gray-700 hover:text-white transition-colors duration-200; } + +/* ================================== */ +/* New Timer UI Styles */ +/* ================================== */ +.timer-dashboard-bg { + @apply bg-gray-900 text-gray-200; +} + +.timer-card { + @apply bg-gray-800/50 border border-gray-700 rounded-2xl p-6 backdrop-blur-sm; +} + +.timer-card-title { + @apply text-sm font-semibold text-gray-400 mb-2; +} + +.countdown-text { + @apply text-4xl font-mono font-bold tracking-wider; +} + +.timer-sub-info { + @apply text-xs text-gray-500 mt-2 flex items-center gap-2; +} + +.btn-timer { + @apply w-full py-3 rounded-lg font-bold transition-colors duration-200 disabled:bg-gray-700 disabled:text-gray-500 disabled:cursor-not-allowed; +} + +.btn-timer-action { + @apply bg-blue-600 text-white hover:bg-blue-700; +} + +.btn-timer-fave { + @apply bg-pink-600 text-white hover:bg-pink-700; +} diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -1,99 +1,146 @@ "use client"; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '@/hooks/useAuth'; -import StatsGraph from '@/components/StatsGraph'; -import { parseISO } from 'date-fns'; +import TimerDashboard from "@/components/TimerDashboard"; -export interface TapEntry { +// --- ★ データ構造の定義を変更 ★ --- +// 個々のタップ記録の型 +interface TapEntry { timestamp: string; isOshi: boolean; } +// Driveに保存する全体のデータ型 +interface AppData { + tapHistory: TapEntry[]; + ticket1Time: string | null; + ticket2Time: string | null; +} + export default function Home() { const { user, accessToken, isLoading, login } = useAuth(); const [tapHistory, setTapHistory] = useState<TapEntry[]>([]); + const [ticket1Time, setTicket1Time] = useState<Date | null>(null); + const [ticket2Time, setTicket2Time] = useState<Date | null>(null); const [isOshiTap, setIsOshiTap] = useState<boolean>(false); - const [isSyncing, setIsSyncing] = useState<boolean>(false); + const [driveFileId, setDriveFileId] = useState<string | null>(null); + const [isSyncing, setIsSyncing] = useState(false); + const DRIVE_FILENAME = 'ba-cafe-timer-data.json'; + + + // --- ★ Google Driveへのデータ保存関数 (完全版) ★ --- + const saveDataToDrive = useCallback(async (token: string, data: AppData, isCreating = false) => { + if (isSyncing) return; // 同期中の多重実行を防ぐ + setIsSyncing(true); + + const content = JSON.stringify(data); + const blob = new Blob([content], { type: 'application/json' }); + const formData = new FormData(); + const metadata = { name: DRIVE_FILENAME, mimeType: 'application/json' }; + formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); + formData.append('file', blob); + + let url = 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; + let method: 'POST' | 'PATCH' = 'POST'; - const DRIVE_FILENAME = 'bluearchive-cafe-timer-data.ndjson'; - const TAP_INTERVAL_HOURS = 3; + // 既存ファイルがあれば更新(PATCH)、なければ新規作成(POST) + if (!isCreating && driveFileId) { + url = `https://www.googleapis.com/upload/drive/v3/files/${driveFileId}?uploadType=multipart`; + method = 'PATCH'; + } - const loadDataFromDrive = async (token: string) => { try { - const searchParams = new URLSearchParams({ - q: `name='${DRIVE_FILENAME}' and 'root' in parents and trashed=false`, - fields: 'files(id, name)', - }); - const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?${searchParams}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!searchRes.ok) throw new Error('Failed to search file on Drive'); + const response = await fetch(url, { method, headers: { Authorization: `Bearer ${token}` }, body: formData }); + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Failed to save data: ${errorBody}`); + } + const result = await response.json(); + if (result.id) { + setDriveFileId(result.id); // 新規作成時にファイルIDを保存 + } + } catch (err) { + console.error("Driveへの保存に失敗", err); + } finally { + setIsSyncing(false); + } + }, [driveFileId, isSyncing]); + + + // --- ★ Google Driveからのデータ読み込み関数 (完全版) ★ --- + const loadDataFromDrive = useCallback(async (token: string) => { + try { + const searchParams = new URLSearchParams({ q: `name='${DRIVE_FILENAME}' and 'root' in parents and trashed=false`, fields: 'files(id, name)' }); + const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?${searchParams}`, { headers: { Authorization: `Bearer ${token}` } }); + if (!searchRes.ok) throw new Error('Failed to search file'); const searchData = await searchRes.json(); if (searchData.files && searchData.files.length > 0) { const fileId = searchData.files[0].id; setDriveFileId(fileId); - const fileContentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { - headers: { Authorization: `Bearer ${token}` }, - }); - if (!fileContentRes.ok) throw new Error('Failed to download file content'); + const fileContentRes = await fetch(`https://www.googleapis.com/drive/v3/files/${fileId}?alt=media`, { headers: { Authorization: `Bearer ${token}` } }); + if (!fileContentRes.ok) throw new Error('Failed to download file'); + + const data: AppData & { lastTapTime?: string } = await fileContentRes.json(); // 古い形式も読めるように型定義 - const ndjsonText = await fileContentRes.text(); - if (ndjsonText && ndjsonText.trim() !== '') { - const history = ndjsonText.trim().split('\n').map(line => JSON.parse(line)); - setTapHistory(history); + // --- ★ 読み込みロジックを更新 ★ --- + // tapHistoryがあればそれを使い、なければ古い形式(lastTapTime)から移行する + if (data.tapHistory) { + setTapHistory(data.tapHistory); + } else if (data.lastTapTime) { + // 古いデータ形式からの移行処理 + setTapHistory([{ timestamp: data.lastTapTime, isOshi: false }]); } + + if (data.ticket1Time) setTicket1Time(new Date(data.ticket1Time)); + if (data.ticket2Time) setTicket2Time(new Date(data.ticket2Time)); } else { - await saveDataToDrive(token, [], true); + // 新規作成時は空の履歴で初期化 + const initialData: AppData = { tapHistory: [], ticket1Time: null, ticket2Time: null }; + await saveDataToDrive(token, initialData, true); } } catch (err) { console.error("Driveからの読み込みに失敗", err); } - }; + }, [saveDataToDrive]); - const saveDataToDrive = async (token: string, history: TapEntry[], isCreating = false) => { - if (!token) return; - setIsSyncing(true); - - let url = 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart'; - let method: 'POST' | 'PATCH' = 'POST'; - - if (!isCreating && driveFileId) { - url = `https://www.googleapis.com/upload/drive/v3/files/${driveFileId}?uploadType=multipart`; - method = 'PATCH'; - } - - const fileContent = history.length > 0 ? history.map(entry => JSON.stringify(entry)).join('\n') : ''; - const metadata = { name: DRIVE_FILENAME, mimeType: 'application/x-ndjson' }; - - const body = new FormData(); - body.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); - body.append('file', new Blob([fileContent], { type: 'application/x-ndjson' })); - try { - const response = await fetch(url, { method, headers: { Authorization: `Bearer ${token}` }, body }); - if (!response.ok) throw new Error('Failed to save data to Drive'); - const data = await response.json(); - if (data.id) setDriveFileId(data.id); - } catch (err) { - console.error("Driveへの保存に失敗", err); - } finally { - setIsSyncing(false); - } - }; - - const handleTap = () => { + // --- ★ handleTapのロジックを「追記」に変更 ★ --- + const handleTap = async (isFave: boolean) => { const newEntry: TapEntry = { timestamp: new Date().toISOString(), - isOshi: isOshiTap, + isOshi: isFave, }; + // 既存の履歴に新しい記録を追加 const newHistory = [...tapHistory, newEntry]; setTapHistory(newHistory); - if(accessToken) { - saveDataToDrive(accessToken, newHistory); + + if (accessToken) { + const newData: AppData = { + tapHistory: newHistory, // 更新された完全な履歴を保存 + ticket1Time: ticket1Time?.toISOString() || null, + ticket2Time: ticket2Time?.toISOString() || null + }; + await saveDataToDrive(accessToken, newData); + } + }; + + // --- handleInviteは履歴に影響しないので、tapHistoryをそのまま渡す --- + const handleInvite = async (ticketNumber: 1 | 2) => { + const newInviteTime = new Date(); + let newData: AppData; + if (ticketNumber === 1) { + setTicket1Time(newInviteTime); + newData = { tapHistory, ticket1Time: newInviteTime.toISOString(), ticket2Time: ticket2Time?.toISOString() || null }; + } else { + setTicket2Time(newInviteTime); + newData = { tapHistory, ticket1Time: ticket1Time?.toISOString() || null, ticket2Time: newInviteTime.toISOString() }; + } + if (accessToken) { + await saveDataToDrive(accessToken, newData); } }; @@ -101,61 +148,62 @@ export default function Home() { if (user && accessToken) { loadDataFromDrive(accessToken); } else { - setTapHistory([]); - setDriveFileId(null); + setTapHistory([]); // ログアウト時に履歴をリセット } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [user, accessToken]); + }, [user, accessToken, loadDataFromDrive]); + // --- ★ TimerDashboardに渡すために最新のタップ時間を算出 ★ --- const lastTap = tapHistory.length > 0 ? tapHistory[tapHistory.length - 1] : null; - const lastTapTime = lastTap ? parseISO(lastTap.timestamp) : null; - const nextTapTime = lastTapTime ? new Date(lastTapTime.getTime() + (TAP_INTERVAL_HOURS * 60 * 60 * 1000)) : null; + const lastTapTime = lastTap ? new Date(lastTap.timestamp) : null; + if (isLoading) { return <div className="text-center p-10">読み込み中...</div>; } return ( - <main className="flex flex-col items-center p-4 sm:p-8 text-foreground"> - <div className="w-full max-w-md mx-auto"> - {!user ? ( - <div className="card text-center"> - <h2 className="text-xl font-bold mb-4">ようこそ!</h2> - <p className="mb-6">全ての機能を利用するには、Googleアカウントでログインしてください。</p> - <button - onClick={() => login()} - className="btn btn-primary inline-flex items-center justify-center whitespace-nowrap" - > - <span>Googleでログイン</span> - </button> + <div className="timer-dashboard-bg min-h-screen"> + {!user ? ( + <div className="flex flex-col items-center justify-center h-screen p-8"> + <div className="card text-center !bg-gray-800 border border-gray-700"> + <h2 className="text-xl font-bold mb-4 text-white">ようこそ!</h2> + <p className="mb-6 text-gray-400">タイマー機能を利用するには、Googleアカウントでログインしてください。</p> + <button + onClick={() => login()} + className="btn btn-primary inline-flex items-center justify-center whitespace-nowrap" + > + <span>Googleでログイン</span> + </button> + </div> </div> - ) : ( - <div className="space-y-6"> - <div className="card"> - <h2 className="card-title">👋 なでなでタイマー</h2> - <div className="my-4 flex items-center justify-center gap-2"> - <input - type="checkbox" id="oshi-toggle" checked={isOshiTap} - onChange={(e) => setIsOshiTap(e.target.checked)} - className="w-4 h-4 accent-pink-500 cursor-pointer" - /> - <label htmlFor="oshi-toggle" className="font-semibold text-pink-600 cursor-pointer">推しキャラのタップ</label> + ) : ( + <> + <TimerDashboard + lastTapTime={lastTapTime} // 最新のタップ時間だけを渡す + ticket1Time={ticket1Time} + ticket2Time={ticket2Time} + onTap={handleTap} + onInvite={handleInvite} + isSyncing={isSyncing} + /> + + {/* ★ 履歴表示エリア(確認用)★ */} + <div className="p-4 sm:p-8 max-w-md mx-auto"> + <div className="card text-center !bg-gray-800 border border-gray-700"> + <h2 className="text-xl font-bold mb-4 text-white">Tap History</h2> + <ul className="text-left text-gray-300"> + {tapHistory.slice(-5).reverse().map(tap => ( + <li key={tap.timestamp} className="mb-1"> + {new Date(tap.timestamp).toLocaleString('ja-JP')} + {tap.isOshi && <span className="ml-2 text-pink-500 font-bold">(推し)</span>} + </li> + ))} + {tapHistory.length === 0 && <li>まだ記録がありません</li>} + </ul> </div> - - <button onClick={handleTap} className="btn btn-primary w-full" disabled={isSyncing}> - {isSyncing ? "保存中..." : "なでなでした!"} - </button> - - <p className="mt-2"><strong>最後にタップした時間:</strong> {lastTapTime ? lastTapTime.toLocaleString('ja-JP') : '記録なし'}</p> - <p><strong>次にタップ可能な時間:</strong> {nextTapTime ? nextTapTime.toLocaleString('ja-JP') : '---'}</p> - </div> - - <div className="card"> - <StatsGraph tapHistory={tapHistory} /> - </div> </div> - )} - </div> - </main> + </> + )} + </div> ); } \ No newline at end of file diff --git a/src/components/CountdownDisplay.tsx b/src/components/CountdownDisplay.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useMemo } from 'react'; + +interface CountdownDisplayProps { + milliseconds: number; +} + +export default function CountdownDisplay({ milliseconds }: CountdownDisplayProps) { + const { hours, minutes, seconds } = useMemo(() => { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0'); + const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0'); + const seconds = String(totalSeconds % 60).padStart(2, '0'); + return { hours, minutes, seconds }; + }, [milliseconds]); + + return ( + <div className="countdown-text"> + <span>{hours}</span>: + <span>{minutes}</span>: + <span>{seconds}</span> + </div> + ); +}+ \ No newline at end of file diff --git a/src/components/TimerCard.tsx b/src/components/TimerCard.tsx @@ -0,0 +1,17 @@ +"use client"; + +import { ReactNode } from "react"; + +interface TimerCardProps { + title: string; + children: ReactNode; +} + +export default function TimerCard({ title, children }: TimerCardProps) { + return ( + <div className="timer-card"> + <h2 className="timer-card-title">{title}</h2> + <div>{children}</div> + </div> + ); +}+ \ No newline at end of file diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useState, useEffect, useMemo } from 'react'; +import { addHours, differenceInMilliseconds } from 'date-fns'; +import TimerCard from './TimerCard'; +import CountdownDisplay from './CountdownDisplay'; + +// propsの型定義 +interface TimerDashboardProps { + lastTapTime: Date | null; + ticket1Time: Date | null; + ticket2Time: Date | null; + onTap: (isFave: boolean) => void; + onInvite: (ticketNumber: 1 | 2) => void; + isSyncing: boolean; +} + +export default function TimerDashboard({ + lastTapTime, + ticket1Time, + ticket2Time, + onTap, + onInvite, + isSyncing, +}: TimerDashboardProps) { + const [now, setNow] = useState(new Date()); + + useEffect(() => { + const timer = setInterval(() => setNow(new Date()), 1000); + return () => clearInterval(timer); + }, []); + + // --- 計算ロジックはpropsで受け取った値を使う --- + const studentsChangeRemaining = useMemo(() => { + // 04:00または16:00までの残り時間を計算 + const hour = now.getHours(); + let nextChange = new Date(now); + if (hour < 4) { + nextChange.setHours(4, 0, 0, 0); + } else if (hour < 16) { + nextChange.setHours(16, 0, 0, 0); + } else { + // 翌日の4時 + nextChange.setDate(nextChange.getDate() + 1); + nextChange.setHours(4, 0, 0, 0); + } + return differenceInMilliseconds(nextChange, now); + }, [now]); + const cafeTapRemaining = useMemo(() => { + if (!lastTapTime) return 0; + return differenceInMilliseconds(addHours(lastTapTime, 3), now); + }, [now, lastTapTime]); + const ticket1Remaining = useMemo(() => { + if (!ticket1Time) return 0; + return differenceInMilliseconds(addHours(ticket1Time, 20), now); + }, [now, ticket1Time]); + const ticket2Remaining = useMemo(() => { + if (!ticket2Time) return 0; + return differenceInMilliseconds(addHours(ticket2Time, 20), now); + }, [now, ticket2Time]); + + + return ( + <div className="p-4 sm:p-8 space-y-6"> + <TimerCard title="Next Students Change"> + <CountdownDisplay milliseconds={studentsChangeRemaining} /> + <div className="timer-sub-info"> + <span>🔄</span> + <span>{now.getHours() < 4 || now.getHours() >= 16 ? "04:00" : "16:00"}</span> + </div> + </TimerCard> + + <TimerCard title="Next Cafe Tap"> + <CountdownDisplay milliseconds={cafeTapRemaining} /> + <div className="grid grid-cols-2 gap-4 mt-4"> + <button + onClick={() => onTap(false)} + disabled={cafeTapRemaining > 0 || isSyncing} + className="btn-timer btn-timer-action" + >{ isSyncing ? '保存中…' : 'Tap' }</button> + <button + onClick={() => onTap(true)} + disabled={cafeTapRemaining > 0 || isSyncing} + className="btn-timer btn-timer-fave" + >{ isSyncing ? '保存中…' : 'Fave Tap' }</button> + </div> + </TimerCard> + + <TimerCard title="Next Invitation"> + <div className="grid grid-cols-2 gap-4"> + <CountdownDisplay milliseconds={ticket1Remaining} /> + <CountdownDisplay milliseconds={ticket2Remaining} /> + </div> + <div className="grid grid-cols-2 gap-4 mt-4"> + <button + onClick={() => onInvite(1)} + disabled={ticket1Remaining > 0 || isSyncing} + className="btn-timer btn-timer-action" + >{ isSyncing ? '保存中…' : 'Ticket 1' }</button> + <button + onClick={() => onInvite(2)} + disabled={ticket2Remaining > 0 || isSyncing} + className="btn-timer btn-timer-action" + >{ isSyncing ? '保存中…' : 'Ticket 2' }</button> + </div> + </TimerCard> + </div> + ); +}+ \ No newline at end of file