ba-cafe

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

commit 6e559640019d77d2219bd406fca4b5888ed0f06a
parent acb171a20e15b1450daabf13a01cba1387cb3baf
Author: Sunny <kajimalu10@gmail.com>
Date:   Wed, 13 Aug 2025 16:50:32 +0900

add sidepane etc

Diffstat:
Mpackage-lock.json | 41+++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 3+++
Msrc/app/globals.css | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/app/layout.tsx | 48+++++++++++++++++++++++++++++-------------------
Msrc/app/page.tsx | 172+++++++++++++++++++++++++++++++++++--------------------------------------------
Asrc/components/Header.tsx | 24++++++++++++++++++++++++
Asrc/components/SidePane.tsx | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/StatsGraph.tsx | 192+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/hooks/useAuth.tsx | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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"> + &lt; + </button> + <span className="font-semibold text-lg w-32 text-center"> + {navLabel} + </span> + <button + onClick={handleNext} + className="btn btn-ghost text-2xl" + disabled={isNextDisabled} + > + &gt; + </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