commit 6e559640019d77d2219bd406fca4b5888ed0f06a
parent acb171a20e15b1450daabf13a01cba1387cb3baf
Author: Sunny <kajimalu10@gmail.com>
Date: Wed, 13 Aug 2025 16:50:32 +0900
add sidepane etc
Diffstat:
9 files changed, 678 insertions(+), 134 deletions(-)
diff --git a/package-lock.json b/package-lock.json
@@ -9,8 +9,11 @@
"version": "0.1.0",
"dependencies": {
"@react-oauth/google": "^0.12.2",
+ "chart.js": "^4.5.0",
+ "date-fns": "^4.1.0",
"next": "15.4.6",
"react": "19.1.0",
+ "react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0"
},
"devDependencies": {
@@ -762,6 +765,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kurkle/color": {
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
+ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2309,6 +2318,18 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/chart.js": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.0.tgz",
+ "integrity": "sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@kurkle/color": "^0.3.0"
+ },
+ "engines": {
+ "pnpm": ">=8"
+ }
+ },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -2460,6 +2481,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
@@ -5088,6 +5119,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-chartjs-2": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz",
+ "integrity": "sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "chart.js": "^4.1.1",
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
diff --git a/package.json b/package.json
@@ -10,8 +10,11 @@
},
"dependencies": {
"@react-oauth/google": "^0.12.2",
+ "chart.js": "^4.5.0",
+ "date-fns": "^4.1.0",
"next": "15.4.6",
"react": "19.1.0",
+ "react-chartjs-2": "^5.3.0",
"react-dom": "19.1.0"
},
"devDependencies": {
diff --git a/src/app/globals.css b/src/app/globals.css
@@ -1,43 +1,151 @@
@import "tailwindcss";
+/* ================================== */
+/* Theming & Color Palette (RGB) */
+/* ================================== */
:root {
- --background: #ffffff;
- --foreground: #171717;
-}
+ /* ライトモードの色定義 (RGB値) */
+ --background: 255 255 255; /* Main background */
+ --foreground: 10 10 10; /* Main text */
+
+ --card: 255 255 255; /* Card background */
+ --card-foreground: 10 10 10; /* Card text */
+
+ --primary: 37 99 235; /* Primary buttons, links */
+ --primary-foreground: 250 250 250; /* Text on primary buttons */
+
+ --secondary: 244 245 246; /* Secondary backgrounds */
+ --secondary-foreground: 20 20 20; /* Text on secondary backgrounds */
-@theme inline {
- --color-background: var(--background);
- --color-foreground: var(--foreground);
- --font-sans: var(--font-geist-sans);
- --font-mono: var(--font-geist-mono);
+ --muted: 229 231 235; /* Subtle backgrounds, borders */
+ --muted-foreground: 107 114 128; /* Subtle text */
}
@media (prefers-color-scheme: dark) {
:root {
- --background: #0a0a0a;
- --foreground: #ededed;
+ /* ダークモードの色定義 (RGB値) */
+ --background: 18 18 18;
+ --foreground: 250 250 250;
+
+ --card: 24 24 27;
+ --card-foreground: 250 250 250;
+
+ --primary: 59 130 246;
+ --primary-foreground: 250 250 250;
+
+ --secondary: 30 30 33;
+ --secondary-foreground: 250 250 250;
+
+ --muted: 30 30 33;
+ --muted-foreground: 156 163 175;
}
}
+/* ================================== */
+/* Body & Card Base Style */
+/* ================================== */
+
body {
- background: var(--background);
- color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ background-color: rgb(var(--background));
+ color: rgb(var(--foreground));
+ font-family: Arial, Helvetica, sans-serif; /* フォントは指定通り */
}
-/* 既存の @tailwind base; などの下に追記 */
.card {
- @apply bg-white p-6 rounded-lg shadow-md mb-6;
+ background-color: rgb(var(--card));
+ color: rgb(var(--card-foreground));
+ padding: 1.5rem; /* p-6 */
+ border-radius: 0.5rem; /* rounded-lg */
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); /* shadow-md */
+ margin-bottom: 1.5rem; /* mb-6 */
}
.card-title {
- @apply text-xl font-bold mb-3 border-b pb-2 flex items-center gap-2;
+ display: flex; /* flex */
+ align-items: center; /* items-center */
+ gap: 0.5rem; /* gap-2 */
+ font-size: 1.25rem; /* text-xl */
+ font-weight: 700; /* font-bold */
+ margin-bottom: 0.75rem; /* mb-3 */
+ padding-bottom: 0.5rem; /* pb-2 */
+ border-bottom-width: 1px; /* border-b */
+ border-bottom-color: rgb(var(--muted)); /* 線の色を指定 */
}
+/* ================================== */
+/* Button System (Standard CSS) */
+/* ================================== */
+
+/* --- ボタンの基本形 --- */
.btn {
- @apply px-4 py-2 rounded-md font-semibold text-white transition-colors duration-200;
+ display: inline-block;
+ padding: 0.5rem 1rem; /* py-2 px-4 */
+ font-weight: 600; /* font-semibold */
+ text-align: center;
+ border-radius: 0.375rem; /* rounded-md */
+ transition: all 200ms ease-in-out;
+ cursor: pointer;
+ text-decoration: none; /* aタグでも使えるように */
+ border-width: 2px; /* アウトラインボタンと太さを合わせるため */
+ border-style: solid;
}
+/* --- バリエーション --- */
+
+/* 1. プライマリボタン(塗りつぶし) */
.btn-primary {
- @apply bg-blue-600 hover:bg-blue-700 disabled:bg-slate-400;
+ background-color: rgb(var(--primary));
+ color: rgb(var(--primary-foreground));
+ border-color: transparent;
+}
+.btn-primary:hover {
+ filter: brightness(0.9); /* 少し暗くするホバー効果 */
+}
+.btn-primary:disabled {
+ background-color: rgb(var(--muted));
+ color: rgb(var(--muted-foreground));
+ cursor: not-allowed;
+}
+
+/* 2. アウトラインボタン */
+.btn-outline {
+ background-color: transparent;
+ border-color: rgb(var(--muted));
+ color: rgb(var(--foreground));
+}
+.btn-outline:hover {
+ background-color: rgb(var(--secondary));
+ border-color: rgb(var(--muted));
+}
+
+/* アウトラインボタンが選択中のスタイル */
+/* .btn-outline.btn-outline-active のように両方のクラスを持つ要素に適用 */
+.btn-outline.btn-outline-active {
+ background-color: rgb(var(--primary));
+ border-color: rgb(var(--primary));
+ color: rgb(var(--primary-foreground));
+}
+
+/* 3. ゴーストボタン(透明) */
+.btn-ghost {
+ background-color: transparent;
+ border-color: transparent;
+ color: rgb(var(--primary));
+}
+.btn-ghost:hover {
+ background-color: rgb(var(--secondary));
+}
+.btn-ghost:disabled {
+ color: rgb(var(--muted-foreground));
+ cursor: not-allowed;
+}
+
+/* no scroll */
+.no-scrollbar::-webkit-scrollbar {
+ display: none; /* Chrome, Safari, Opera */
+}
+
+.no-scrollbar {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
@@ -1,34 +1,44 @@
-"use client"; // 追加
+"use client";
+import { useState } from 'react';
import { GoogleOAuthProvider } from '@react-oauth/google';
+import { AuthProvider, useAuth } from '@/hooks/useAuth';
+import Header from '@/components/Header';
+import SidePane from '@/components/SidePane';
import "./globals.css";
-// メタデータはサーバーコンポーネントでしか使えないため、layout.tsxからは一旦削除またはコメントアウトします。
-// export const metadata = { ... };
+function AppLayout({ children }: { children: React.ReactNode }) {
+ const { user, logout } = useAuth();
+ const [isPaneOpen, setIsPaneOpen] = useState(false);
-export default function RootLayout({
- children,
-}: {
- children: React.ReactNode;
-}) {
- const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
-
- if (!clientId) {
return (
- <html>
- <body>
- <h1>Google Client IDが設定されていません。</h1>
- <p>.env.localファイルを確認してください。</p>
- </body>
- </html>
+ <>
+ <Header onMenuClick={() => setIsPaneOpen(true)} />
+ <SidePane
+ isOpen={isPaneOpen}
+ onClose={() => setIsPaneOpen(false)}
+ user={user}
+ logout={logout}
+ />
+ <div className="pt-20">
+ {children}
+ </div>
+ </>
);
- }
+}
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
+
+ if (!clientId) return <html><body>Google Client IDが設定されていません。</body></html>;
return (
<html lang="ja">
<body>
<GoogleOAuthProvider clientId={clientId}>
- {children}
+ <AuthProvider>
+ <AppLayout>{children}</AppLayout>
+ </AuthProvider>
</GoogleOAuthProvider>
</body>
</html>
diff --git a/src/app/page.tsx b/src/app/page.tsx
@@ -1,66 +1,34 @@
"use client";
import { useState, useEffect } from 'react';
-import { useGoogleLogin, googleLogout, CredentialResponse } from '@react-oauth/google';
+import { useAuth } from '@/hooks/useAuth';
+import StatsGraph from '@/components/StatsGraph';
+import { parseISO } from 'date-fns';
-// --- 型定義 ---
-interface UserProfile {
- name: string;
- email: string;
- picture: string;
+export interface TapEntry {
+ timestamp: string;
+ isOshi: boolean;
}
-interface DriveFile {
- id: string;
- name: string;
-}
+const LoginIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
+ </svg>
+);
-// --- メインコンポーネント ---
export default function Home() {
- // --- 状態管理 (State) ---
- const [user, setUser] = useState<UserProfile | null>(null);
- const [accessToken, setAccessToken] = useState<string | null>(null);
- const [tapHistory, setTapHistory] = useState<string[]>([]);
- const [driveFileId, setDriveFileId] = useState<string | null>(null);
- const [isLoading, setIsLoading] = useState<boolean>(false);
+ const { user, accessToken, isLoading, login } = useAuth();
+
+ const [tapHistory, setTapHistory] = useState<TapEntry[]>([]);
+ const [isOshiTap, setIsOshiTap] = useState<boolean>(false);
const [isSyncing, setIsSyncing] = useState<boolean>(false);
+ const [driveFileId, setDriveFileId] = useState<string | null>(null);
- const DRIVE_FILENAME = 'bluearchive-cafe-timer-data.json';
+ const DRIVE_FILENAME = 'bluearchive-cafe-timer-data.ndjson';
const TAP_INTERVAL_HOURS = 3;
-
- // --- 認証処理 ---
- const login = useGoogleLogin({
- onSuccess: async (tokenResponse) => {
- setIsLoading(true);
- setAccessToken(tokenResponse.access_token);
- // ユーザー情報を取得
- const profileInfoRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
- headers: { Authorization: `Bearer ${tokenResponse.access_token}` },
- });
- const profileInfo = await profileInfoRes.json();
- setUser(profileInfo);
-
- // Driveからデータを読み込み
- await loadDataFromDrive(tokenResponse.access_token);
- setIsLoading(false);
- },
- onError: errorResponse => console.error(errorResponse),
- scope: 'https://www.googleapis.com/auth/drive.file',
- });
-
- const logout = () => {
- googleLogout();
- setUser(null);
- setAccessToken(null);
- setTapHistory([]);
- setDriveFileId(null);
- };
-
- // --- Google Drive連携処理 (fetch APIを使用) ---
const loadDataFromDrive = async (token: string) => {
try {
- // 1. ファイルを検索
const searchParams = new URLSearchParams({
q: `name='${DRIVE_FILENAME}' and 'root' in parents and trashed=false`,
fields: 'files(id, name)',
@@ -68,19 +36,23 @@ export default function Home() {
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 searchData = await searchRes.json();
if (searchData.files && searchData.files.length > 0) {
- // 2a. ファイルが見つかった場合 -> 読み込む
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}` },
});
- const history = await fileContentRes.json() as string[];
- setTapHistory(history);
+ if (!fileContentRes.ok) throw new Error('Failed to download file content');
+
+ const ndjsonText = await fileContentRes.text();
+ if (ndjsonText && ndjsonText.trim() !== '') {
+ const history = ndjsonText.trim().split('\n').map(line => JSON.parse(line));
+ setTapHistory(history);
+ }
} else {
- // 2b. ファイルが見つからない場合 -> 新規作成
await saveDataToDrive(token, [], true);
}
} catch (err) {
@@ -88,11 +60,10 @@ export default function Home() {
}
};
- const saveDataToDrive = async (token: string, history: string[], isCreating = false) => {
+ const saveDataToDrive = async (token: string, history: TapEntry[], isCreating = false) => {
if (!token) return;
setIsSyncing(true);
- // APIのエンドポイントとHTTPメソッドを決定
let url = 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart';
let method: 'POST' | 'PATCH' = 'POST';
@@ -101,24 +72,18 @@ export default function Home() {
method = 'PATCH';
}
- // 送信するデータを作成 (Multipart形式)
- const metadata = { name: DRIVE_FILENAME, mimeType: 'application/json' };
- const fileContent = JSON.stringify(history);
+ 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/json' }));
+ body.append('file', new Blob([fileContent], { type: 'application/x-ndjson' }));
try {
- const response = await fetch(url, {
- method: method,
- headers: { Authorization: `Bearer ${token}` },
- body: body,
- });
+ 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);
- }
+ if (data.id) setDriveFileId(data.id);
} catch (err) {
console.error("Driveへの保存に失敗", err);
} finally {
@@ -126,61 +91,74 @@ export default function Home() {
}
};
- // --- イベントハンドラ ---
const handleTap = () => {
- const newHistory = [...tapHistory, new Date().toISOString()];
+ const newEntry: TapEntry = {
+ timestamp: new Date().toISOString(),
+ isOshi: isOshiTap,
+ };
+ const newHistory = [...tapHistory, newEntry];
setTapHistory(newHistory);
if(accessToken) {
saveDataToDrive(accessToken, newHistory);
}
};
- // --- 表示用データ計算 (変更なし) ---
- const lastTapTime = tapHistory.length > 0 ? new Date(tapHistory[tapHistory.length - 1]) : null;
- const nextTapTime = lastTapTime ? new Date(lastTapTime.getTime() + TAP_INTERVAL_HOURS * 60 * 60 * 1000) : null;
- const today = new Date().toLocaleDateString('ja-JP');
- const todayTapsCount = tapHistory.filter(iso => new Date(iso).toLocaleDateString('ja-JP') === today).length;
+ useEffect(() => {
+ if (user && accessToken) {
+ loadDataFromDrive(accessToken);
+ } else {
+ setTapHistory([]);
+ setDriveFileId(null);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user, accessToken]);
+
+ 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;
- // --- レンダリング (変更なし) ---
+ if (isLoading) {
+ return <div className="text-center p-10">読み込み中...</div>;
+ }
+
return (
- <main className="flex min-h-screen flex-col items-center p-8 bg-slate-100 text-gray-800">
+ <main className="flex flex-col items-center p-4 sm:p-8 text-gray-800">
<div className="w-full max-w-md mx-auto">
- <header className="text-center mb-8">
- <h1 className="text-4xl font-bold text-blue-600">ブルアカ カフェタイマー</h1>
- <p className="text-slate-500 mt-2">Next.js + fetch API Edition</p>
- </header>
-
{!user ? (
<div className="card text-center">
- <h2 className="card-title">🔐 Googleアカウント連携</h2>
- <p>Googleドライブにデータを保存し、どの端末からでも同じ履歴を利用できます。</p>
- <button onClick={() => login()} className="btn btn-primary mt-4" disabled={isLoading}>
- {isLoading ? "処理中..." : "Googleアカウントでログイン"}
+ <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"
+ >
+ <LoginIcon />
+ <span>Googleでログイン</span>
</button>
</div>
) : (
- <div>
- <div className="text-center mb-6">
- <div className='flex items-center justify-center gap-3'>
- <img src={user.picture} alt="user avatar" className='w-10 h-10 rounded-full' />
- <span className="font-semibold">{user.name}としてログイン中</span>
- </div>
- <button onClick={logout} className="text-sm text-slate-500 hover:text-blue-600 mt-2">ログアウト</button>
- </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>
+ </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">
- <h2 className="card-title">📊 タップ統計</h2>
- <p><strong>総タップ回数:</strong> {tapHistory.length}回</p>
- <p><strong>今日のタップ回数:</strong> {todayTapsCount}回</p>
+ <StatsGraph tapHistory={tapHistory} />
</div>
</div>
)}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
@@ -0,0 +1,23 @@
+"use client";
+
+// ハンバーガーメニューアイコン
+const MenuIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 12h16M4 18h16" />
+ </svg>
+);
+
+interface HeaderProps {
+ onMenuClick: () => void; // メニューボタンがクリックされたことを親に伝える
+}
+
+export default function Header({ onMenuClick }: HeaderProps) {
+ return (
+ <header className="fixed top-0 left-0 right-0 z-20 flex items-center justify-between p-4 bg-slate-100/80 backdrop-blur-sm shadow-sm">
+ <h1 className="text-xl font-bold text-blue-600">カフェタイマー</h1>
+ <button onClick={onMenuClick} className="p-2">
+ <MenuIcon />
+ </button>
+ </header>
+ );
+}+
\ No newline at end of file
diff --git a/src/components/SidePane.tsx b/src/components/SidePane.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+interface UserProfile {
+ name: string;
+ picture: string;
+}
+
+interface SidePaneProps {
+ isOpen: boolean;
+ onClose: () => void; // ペインを閉じるための関数
+ user: UserProfile | null;
+ logout: () => void; // ログアウト処理を行う関数
+}
+
+// ログアウトアイコン
+const LogoutIcon = () => (
+ <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
+ <path strokeLinecap="round" strokeLinejoin="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
+ </svg>
+);
+
+export default function SidePane({ isOpen, onClose, user, logout }: SidePaneProps) {
+ const menuItems = ['About', '利用規約', 'プライバシーポリシー', 'お問い合わせ', '運営者情報'];
+
+ const handleLogoutClick = () => {
+ if (window.confirm("本当にログアウトしますか?")) {
+ logout();
+ onClose();
+ }
+ };
+
+ return (
+ <main>
+ {/* オーバーレイ */}
+ <div
+ className={`fixed inset-0 bg-black/50 z-30 transition-opacity ${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
+ onClick={onClose}
+ ></div>
+
+ {/* サイドペイン本体 */}
+ <aside
+ className={`fixed top-0 right-0 h-full w-72 bg-gray-800 text-white shadow-lg transform transition-transform z-40 ${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
+ >
+ <nav className="p-6 flex flex-col h-full">
+ {/* メニュー項目 */}
+ <ul className="space-y-4">
+ {menuItems.map(item => <li key={item}><a href="#" className="hover:text-blue-300">{item}</a></li>)}
+ </ul>
+
+ {/* 言語選択 */}
+ <div className="mt-8">
+ <p className="text-sm text-gray-400 mb-2">Language</p>
+ <div className="flex space-x-3">
+ <span>🇯🇵</span><span>🇺🇸</span><span>🇨🇳</span><span>🇰🇷</span>
+ </div>
+ </div>
+
+ {/* 認証情報*/}
+ <div className="mt-auto">
+ {/* userが存在する場合(ログイン中)のみ、このブロックを表示 */}
+ {user && (
+ <>
+ <div className="flex items-center gap-4 mb-4">
+ <img src={user.picture} alt="user avatar" className="w-10 h-10 rounded-full" />
+ <span className="font-semibold">{user.name}</span>
+ </div>
+ <button onClick={handleLogoutClick} className="btn btn-ghost w-full justify-start text-left !text-white">
+ <LogoutIcon /> ログアウト
+ </button>
+ </>
+ )}
+ {/* userが存在しない場合は何も表示しない */}
+ </div>
+ </nav>
+ </aside>
+ </main>
+ );
+}+
\ No newline at end of file
diff --git a/src/components/StatsGraph.tsx b/src/components/StatsGraph.tsx
@@ -0,0 +1,191 @@
+"use client";
+
+import { useState, useMemo } from 'react';
+import { Line } from 'react-chartjs-2';
+import {
+ Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ChartOptions, ChartData
+} from 'chart.js';
+import {
+ isToday, isThisWeek, isThisMonth, isThisYear,
+ getMonth,
+ format, addMonths, subMonths, isSameMonth, isAfter,
+ eachDayOfInterval, startOfMonth, endOfMonth,
+ isSameDay,
+ addYears, subYears, isSameYear, parseISO
+} from 'date-fns';
+import type { TapEntry } from '@/app/page';
+
+ChartJS.register(
+ CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend
+);
+
+// 表示モードを「日別」と「月別」に変更
+type ViewType = 'daily' | 'monthly';
+
+interface StatsGraphProps {
+ tapHistory: TapEntry[];
+}
+
+export default function StatsGraph({ tapHistory }: StatsGraphProps) {
+ // デフォルトの表示を「日別」に変更
+ const [view, setView] = useState<ViewType>('daily');
+ const [displayDate, setDisplayDate] = useState(new Date());
+
+ // --- サマリー統計データの計算 ---
+ const weekOptions = { weekStartsOn: 1 as const };
+ const allTapsToday = tapHistory.filter(t => isToday(parseISO(t.timestamp))).length;
+ const oshiTapsToday = tapHistory.filter(t => t.isOshi && isToday(parseISO(t.timestamp))).length;
+
+ const allTapsThisWeek = tapHistory.filter(t => isThisWeek(parseISO(t.timestamp), weekOptions)).length;
+ const oshiTapsThisWeek = tapHistory.filter(t => t.isOshi && isThisWeek(parseISO(t.timestamp), weekOptions)).length;
+
+ const allTapsThisMonth = tapHistory.filter(t => isThisMonth(parseISO(t.timestamp))).length;
+ const oshiTapsThisMonth = tapHistory.filter(t => t.isOshi && isThisMonth(parseISO(t.timestamp))).length;
+
+ const allTapsThisYear = tapHistory.filter(t => isThisYear(parseISO(t.timestamp))).length;
+ const oshiTapsThisYear = tapHistory.filter(t => t.isOshi && isThisYear(parseISO(t.timestamp))).length;
+
+ // --- 表示期間移動のハンドラ関数 ---
+ const handlePrev = () => setDisplayDate(prev => view === 'daily' ? subMonths(prev, 1) : subYears(prev, 1));
+ const handleNext = () => setDisplayDate(prev => view === 'daily' ? addMonths(prev, 1) : addYears(prev, 1));
+
+ // 未来の期間には移動できないようにボタンを無効化
+ const isNextDisabled = view === 'daily'
+ ? isSameMonth(displayDate, new Date()) || isAfter(displayDate, new Date())
+ : isSameYear(displayDate, new Date()) || isAfter(displayDate, new Date());
+
+
+ // --- グラフ用データの準備 ---
+ const { chartData, chartOptions, navLabel } = useMemo(() => {
+ const commonOptions: ChartOptions<'line'> = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: { legend: { position: 'top' } },
+ scales: { y: { beginAtZero: true, ticks: { stepSize: 2 } } },
+ };
+
+ let navLabelText = '';
+ let datasets: ChartData<'line'>['datasets'] = [];
+ let labels: string[] = [];
+ let titleText = '';
+
+ if (view === 'daily') {
+ navLabelText = format(displayDate, 'yyyy年 M月');
+ titleText = `${navLabelText}の日別タップ数`;
+
+ const monthStart = startOfMonth(displayDate);
+ const monthEnd = endOfMonth(displayDate);
+ const daysInMonth = eachDayOfInterval({ start: monthStart, end: monthEnd });
+
+ labels = daysInMonth.map(day => format(day, 'd'));
+
+ const dailyAllTaps = daysInMonth.map(day =>
+ tapHistory.filter(t => isSameDay(parseISO(t.timestamp), day)).length
+ );
+ const dailyOshiTaps = daysInMonth.map(day =>
+ tapHistory.filter(t => t.isOshi && isSameDay(parseISO(t.timestamp), day)).length
+ );
+
+ datasets = [
+ { label: 'すべて', data: dailyAllTaps, borderColor: 'rgb(54, 162, 235)', backgroundColor: 'rgba(54, 162, 235, 0.5)', tension: 0.1 },
+ { label: '推し', data: dailyOshiTaps, borderColor: 'rgb(255, 99, 132)', backgroundColor: 'rgba(255, 99, 132, 0.5)', tension: 0.1 },
+ ];
+
+ } else { // view === 'monthly'
+ navLabelText = format(displayDate, 'yyyy年');
+ titleText = `${navLabelText}の月別タップ数`;
+
+ const targetYearHistory = tapHistory.filter(t => isSameYear(parseISO(t.timestamp), displayDate));
+
+ labels = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
+ const monthlyAllTaps = new Array(12).fill(0);
+ const monthlyOshiTaps = new Array(12).fill(0);
+
+ targetYearHistory.forEach(tap => {
+ const month = getMonth(parseISO(tap.timestamp));
+ monthlyAllTaps[month]++;
+ if (tap.isOshi) monthlyOshiTaps[month]++;
+ });
+
+ datasets = [
+ { label: 'すべて', data: monthlyAllTaps, borderColor: 'rgb(54, 162, 235)', backgroundColor: 'rgba(54, 162, 235, 0.5)', tension: 0.1 },
+ { label: '推し', data: monthlyOshiTaps, borderColor: 'rgb(255, 99, 132)', backgroundColor: 'rgba(255, 99, 132, 0.5)', tension: 0.1 },
+ ];
+ }
+
+ return {
+ chartData: { labels, datasets },
+ chartOptions: { ...commonOptions, plugins: { ...commonOptions.plugins, title: { display: false, text: titleText } } },
+ navLabel: navLabelText
+ };
+
+ }, [tapHistory, view, displayDate]);
+
+ return (
+ <div className="space-y-6">
+ {/* サマリー表示 */}
+ <div>
+ <h3 className="text-lg font-bold mb-2 text-slate-700">サマリー</h3>
+ <div className="grid grid-cols-2 gap-4 text-center">
+ <div className="p-4 bg-secondary rounded-lg">
+ <div className="text-sm text-muted-foreground">今日</div>
+ <div className="text-xl font-bold text-foreground">{allTapsToday} <span className="text-pink-600">({oshiTapsToday})</span></div>
+ </div>
+ <div className="p-4 bg-secondary rounded-lg">
+ <div className="text-sm text-muted-foreground">今週</div>
+ <div className="text-xl font-bold text-foreground">{allTapsThisWeek} <span className="text-pink-600">({oshiTapsThisWeek})</span></div>
+ </div>
+ <div className="p-4 bg-secondary rounded-lg">
+ <div className="text-sm text-muted-foreground">今月</div>
+ <div className="text-xl font-bold text-foreground">{allTapsThisMonth} <span className="text-pink-600">({oshiTapsThisMonth})</span></div>
+ </div>
+ <div className="p-4 bg-secondary rounded-lg">
+ <div className="text-sm text-muted-foreground">今年</div>
+ <div className="text-xl font-bold text-foreground">{allTapsThisYear} <span className="text-pink-600">({oshiTapsThisYear})</span></div>
+ </div>
+ </div>
+ </div>
+
+ {/* グラフ表示エリア */}
+ <div>
+ <h3 className="text-lg font-bold mb-2 text-slate-700">タップ履歴グラフ</h3>
+ <div className="my-4 flex justify-center gap-2">
+ <button
+ onClick={() => setView('daily')}
+ className={`btn btn-outline ${view === 'daily' ? 'btn-outline-active' : ''}`}
+ >
+ 日別
+ </button>
+ <button
+ onClick={() => setView('monthly')}
+ className={`btn btn-outline ${view === 'monthly' ? 'btn-outline-active' : ''}`}
+ >
+ 月別
+ </button>
+ </div>
+
+ {/* 期間移動ナビゲーション */}
+ <div className="my-4 flex items-center justify-center gap-4">
+ <button onClick={handlePrev} className="btn btn-ghost text-2xl">
+ <
+ </button>
+ <span className="font-semibold text-lg w-32 text-center">
+ {navLabel}
+ </span>
+ <button
+ onClick={handleNext}
+ className="btn btn-ghost text-2xl"
+ disabled={isNextDisabled}
+ >
+ >
+ </button>
+ </div>
+
+ {/* グラフ本体 */}
+ <div className="relative" style={{ height: '300px' }}>
+ <Line options={chartOptions} data={chartData} />
+ </div>
+ </div>
+ </div>
+ );
+}+
\ No newline at end of file
diff --git a/src/hooks/useAuth.tsx b/src/hooks/useAuth.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useState, useEffect, useCallback, createContext, useContext, ReactNode } from 'react';
+import { useGoogleLogin, googleLogout, TokenResponse } from '@react-oauth/google';
+
+// --- 型定義 ---
+interface UserProfile {
+ name: string;
+ email: string;
+ picture: string;
+}
+
+interface AuthContextType {
+ user: UserProfile | null;
+ accessToken: string | null;
+ isLoading: boolean;
+ login: () => void;
+ logout: () => void;
+}
+
+// 1. Contextの器を作成
+const AuthContext = createContext<AuthContextType | undefined>(undefined);
+
+// 2. AuthProviderコンポーネント(アプリ全体に認証状態を共有する)
+export const AuthProvider = ({ children }: { children: ReactNode }) => {
+ const [user, setUser] = useState<UserProfile | null>(null);
+ const [accessToken, setAccessToken] = useState<string | null>(null);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const handleLogout = useCallback(() => {
+ googleLogout();
+ setUser(null);
+ setAccessToken(null);
+ localStorage.removeItem('googleAuthToken');
+ localStorage.removeItem('userProfile');
+ }, []);
+
+ const handleLogin = useGoogleLogin({
+ onSuccess: async (tokenResponse) => {
+ try {
+ const accessToken = tokenResponse.access_token;
+ setAccessToken(accessToken);
+
+ const profileInfoRes = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ });
+
+ if (!profileInfoRes.ok) {
+ throw new Error('Failed to fetch user profile');
+ }
+
+ const profileInfo = await profileInfoRes.json();
+ setUser(profileInfo);
+
+ localStorage.setItem('googleAuthToken', JSON.stringify(tokenResponse));
+ localStorage.setItem('userProfile', JSON.stringify(profileInfo));
+ } catch (error) {
+ console.error("Login process failed:", error);
+ // エラーが発生した場合はクリーンな状態に戻す
+ handleLogout();
+ }
+ },
+ onError: errorResponse => {
+ console.error("Google Login Error:", errorResponse);
+ },
+ scope: 'https://www.googleapis.com/auth/drive.file',
+ });
+
+ useEffect(() => {
+ const init = () => {
+ try {
+ const storedTokenStr = localStorage.getItem('googleAuthToken');
+ const storedProfileStr = localStorage.getItem('userProfile');
+
+ if (storedTokenStr && storedProfileStr) {
+ const storedToken: TokenResponse = JSON.parse(storedTokenStr);
+ const storedProfile: UserProfile = JSON.parse(storedProfileStr);
+ setAccessToken(storedToken.access_token);
+ setUser(storedProfile);
+ }
+ } catch (error) {
+ console.error("Failed to initialize auth state from localStorage", error);
+ handleLogout();
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ init();
+ }, [handleLogout]);
+
+ // Contextに渡す値
+ const value = { user, accessToken, isLoading, login: handleLogin, logout: handleLogout };
+
+ return (
+ <AuthContext.Provider value={value}>
+ {children}
+ </AuthContext.Provider>
+ );
+};
+
+// 3. useAuthカスタムフック(各コンポーネントがこれを使ってContextの値にアクセスする)
+export const useAuth = () => {
+ const context = useContext(AuthContext);
+ if (context === undefined) {
+ throw new Error('useAuth must be used within an AuthProvider');
+ }
+ return context;
+};+
\ No newline at end of file