ba-cafe

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

commit acb171a20e15b1450daabf13a01cba1387cb3baf
parent 11d509382b8ed6b088b32c8f07dbda27d246d50d
Author: Sunny <kajimalu10@gmail.com>
Date:   Tue, 12 Aug 2025 14:55:02 +0900

add general functions

Diffstat:
Mpackage-lock.json | 11+++++++++++
Mpackage.json | 13+++++++------
Msrc/app/globals.css | 17+++++++++++++++++
Msrc/app/layout.tsx | 51+++++++++++++++++++++++++++------------------------
Msrc/app/page.tsx | 284++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
5 files changed, 248 insertions(+), 128 deletions(-)

diff --git a/package-lock.json b/package-lock.json @@ -8,6 +8,7 @@ "name": "hw-ba-cafe", "version": "0.1.0", "dependencies": { + "@react-oauth/google": "^0.12.2", "next": "15.4.6", "react": "19.1.0", "react-dom": "19.1.0" @@ -966,6 +967,16 @@ "node": ">=12.4.0" } }, + "node_modules/@react-oauth/google": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.2.tgz", + "integrity": "sha512-d1GVm2uD4E44EJft2RbKtp8Z1fp/gK8Lb6KHgs3pHlM0PxCXGLaq8LLYQYENnN4xPWO1gkL4apBtlPKzpLvZwg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", diff --git a/package.json b/package.json @@ -9,19 +9,20 @@ "lint": "next lint" }, "dependencies": { + "@react-oauth/google": "^0.12.2", + "next": "15.4.6", "react": "19.1.0", - "react-dom": "19.1.0", - "next": "15.4.6" + "react-dom": "19.1.0" }, "devDependencies": { - "typescript": "^5", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", - "@tailwindcss/postcss": "^4", - "tailwindcss": "^4", "eslint": "^9", "eslint-config-next": "15.4.6", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4", + "typescript": "^5" } } diff --git a/src/app/globals.css b/src/app/globals.css @@ -24,3 +24,20 @@ body { color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* 既存の @tailwind base; などの下に追記 */ +.card { + @apply bg-white p-6 rounded-lg shadow-md mb-6; +} + +.card-title { + @apply text-xl font-bold mb-3 border-b pb-2 flex items-center gap-2; +} + +.btn { + @apply px-4 py-2 rounded-md font-semibold text-white transition-colors duration-200; +} + +.btn-primary { + @apply bg-blue-600 hover:bg-blue-700 disabled:bg-slate-400; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx @@ -1,34 +1,36 @@ -import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; -import "./globals.css"; - -const geistSans = Geist({ - variable: "--font-geist-sans", - subsets: ["latin"], -}); +"use client"; // 追加 -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", - subsets: ["latin"], -}); +import { GoogleOAuthProvider } from '@react-oauth/google'; +import "./globals.css"; -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +// メタデータはサーバーコンポーネントでしか使えないため、layout.tsxからは一旦削除またはコメントアウトします。 +// export const metadata = { ... }; export default function RootLayout({ children, -}: Readonly<{ +}: { 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> + ); + } + return ( - <html lang="en"> - <body - className={`${geistSans.variable} ${geistMono.variable} antialiased`} - > - {children} + <html lang="ja"> + <body> + <GoogleOAuthProvider clientId={clientId}> + {children} + </GoogleOAuthProvider> </body> </html> ); -} +}+ \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx @@ -1,103 +1,190 @@ -import Image from "next/image"; +"use client"; +import { useState, useEffect } from 'react'; +import { useGoogleLogin, googleLogout, CredentialResponse } from '@react-oauth/google'; + +// --- 型定義 --- +interface UserProfile { + name: string; + email: string; + picture: string; +} + +interface DriveFile { + id: string; + name: string; +} + +// --- メインコンポーネント --- 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 [isSyncing, setIsSyncing] = useState<boolean>(false); + + const DRIVE_FILENAME = 'bluearchive-cafe-timer-data.json'; + 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)', + }); + const searchRes = await fetch(`https://www.googleapis.com/drive/v3/files?${searchParams}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + 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); + } else { + // 2b. ファイルが見つからない場合 -> 新規作成 + await saveDataToDrive(token, [], true); + } + } catch (err) { + console.error("Driveからの読み込みに失敗", err); + } + }; + + const saveDataToDrive = async (token: string, history: string[], 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'; + + if (!isCreating && driveFileId) { + url = `https://www.googleapis.com/upload/drive/v3/files/${driveFileId}?uploadType=multipart`; + method = 'PATCH'; + } + + // 送信するデータを作成 (Multipart形式) + const metadata = { name: DRIVE_FILENAME, mimeType: 'application/json' }; + const fileContent = JSON.stringify(history); + + const body = new FormData(); + body.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' })); + body.append('file', new Blob([fileContent], { type: 'application/json' })); + + try { + const response = await fetch(url, { + method: method, + headers: { Authorization: `Bearer ${token}` }, + body: body, + }); + const data = await response.json(); + if (data.id) { + setDriveFileId(data.id); + } + } catch (err) { + console.error("Driveへの保存に失敗", err); + } finally { + setIsSyncing(false); + } + }; + + // --- イベントハンドラ --- + const handleTap = () => { + const newHistory = [...tapHistory, new Date().toISOString()]; + 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; + + // --- レンダリング (変更なし) --- return ( - <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20"> - <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> - <Image - className="dark:invert" - src="/next.svg" - alt="Next.js logo" - width={180} - height={38} - priority - /> - <ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left"> - <li className="mb-2 tracking-[-.01em]"> - Get started by editing{" "} - <code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded"> - src/app/page.tsx - </code> - . - </li> - <li className="tracking-[-.01em]"> - Save and see your changes instantly. - </li> - </ol> + <main className="flex min-h-screen flex-col items-center p-8 bg-slate-100 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> - <div className="flex gap-4 items-center flex-col sm:flex-row"> - <a - className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto" - href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - className="dark:invert" - src="/vercel.svg" - alt="Vercel logomark" - width={20} - height={20} - /> - Deploy now - </a> - <a - className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]" - href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - Read our docs - </a> - </div> - </main> - <footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center"> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="/file.svg" - alt="File icon" - width={16} - height={16} - /> - Learn - </a> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="/window.svg" - alt="Window icon" - width={16} - height={16} - /> - Examples - </a> - <a - className="flex items-center gap-2 hover:underline hover:underline-offset-4" - href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" - target="_blank" - rel="noopener noreferrer" - > - <Image - aria-hidden - src="/globe.svg" - alt="Globe icon" - width={16} - height={16} - /> - Go to nextjs.org → - </a> - </footer> - </div> + {!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アカウントでログイン"} + </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="card"> + <h2 className="card-title">👋 なでなでタイマー</h2> + <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> + </div> + </div> + )} + </div> + </main> ); -} +}+ \ No newline at end of file