commit acb171a20e15b1450daabf13a01cba1387cb3baf
parent 11d509382b8ed6b088b32c8f07dbda27d246d50d
Author: Sunny <kajimalu10@gmail.com>
Date: Tue, 12 Aug 2025 14:55:02 +0900
add general functions
Diffstat:
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