commit 0e454634c7d6f80c1d12c30f9046e1cd422fa087
parent 86286972d2e7f981f724b16aae6dc92cacb4335f
Author: Sunny <kajimalu10@gmail.com>
Date: Wed, 20 Aug 2025 11:55:11 +0900
add invitation
Diffstat:
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