ba-cafe

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

commit 8c272fdbee0c0f61c25ea2e2d1ec9997087f14b5
parent 49da279f366753bd7acac3b76d77d6a9382b7535
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date:   Sat, 24 Jan 2026 14:36:09 +0900

V4 sunny dev (#14)

* tab

* feat(firebase): Integrate Firebase for authentication and data persistence

* add warangler

* delete wrangler

* Update README.md

* comment out unused notosans400

* change

* adjust style

* asjust style 2

* fix color

* add notify

* update nextconfig

* add exclude functions

* delete runtime edge

* adjust env

* adjust style

* notify alert

* debug

* debug2

* adjust style

* adjust style

* svh

* notify 3hours

* gitignore

* gitignore

* fix notify logic

* debug

* debug

* debug

* debug

* debug

* debug

* debug

* add messages

* fix JST

* add pages

* pwa

* change to supabase

* debug

* fix login logic

* notify by user

* theme color

* debug

* debug

* debug adguard

* add guide

* add favicon

* fix favicon

* fix css

* debug

* notify 3hours

* debug

* adjust style

* edit readme

* fix 1600

* fix slot start

* fix notify 4

* change icons

* update text

* cal color

* fix text

* icon

* fix api

* del text of pwa offline

* fix

---------

Co-authored-by: minerva-jupiter <ryouturn@gmail.com>
Co-authored-by: Minerva_juppiter <94231606+minerva-jupiter@users.noreply.github.com>
Diffstat:
MREADME.md | 4----
Aassets/coffee-break.png | 0
Aassets/icon-192x192.png | 0
Aassets/icon-512x512.png | 0
Aassets/icon-maskable-512x512.png | 0
Aassets/notification-icon.png | 0
Msrc/app/about/page.tsx | 2+-
Msrc/app/api/tap/route.ts | 143+++++++++++++++++++++++++++++++++++++-------------------------------------------
Msrc/app/globals.css | 65+++++++++++++++++++++++++++++++++++++++++++++++------------------
Msrc/app/guide/page.tsx | 29+++++++++++++----------------
Msrc/app/operator/page.tsx | 6+++---
Msrc/app/privacy/page.tsx | 2+-
Msrc/app/providers.tsx | 1-
Msrc/app/terms/page.tsx | 4++--
Msrc/components/HistoryCalendar.tsx | 18+++++++++---------
Msrc/components/Settings.tsx | 39+++++++++++++++++++++++++++++++++++++--
Msrc/components/TimerDashboard.tsx | 2+-
17 files changed, 180 insertions(+), 135 deletions(-)

diff --git a/README.md b/README.md @@ -44,10 +44,6 @@ export const runtime = 'edge'; ``` JSON "build": "next build --webpack" ``` -## PWA & 通知 -PWA: `public/`ディレクトリ内のアセットを参照し、オフライン動作に対応しています。 - -OneSignal: プッシュ通知を実現するため、`OneSignalSDKWorker.js`を`public/`に配置しています。 ## ディレクトリ構造 - `src/app/api/`: Cloudflare Workers (Edge Runtime) 上で動作する API エンドポイント diff --git a/assets/coffee-break.png b/assets/coffee-break.png Binary files differ. diff --git a/assets/icon-192x192.png b/assets/icon-192x192.png Binary files differ. diff --git a/assets/icon-512x512.png b/assets/icon-512x512.png Binary files differ. diff --git a/assets/icon-maskable-512x512.png b/assets/icon-maskable-512x512.png Binary files differ. diff --git a/assets/notification-icon.png b/assets/notification-icon.png Binary files differ. diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; export default function AboutPage() { return ( - <div className="min-h-screen bg-background p-6"> + <div className="p-6"> <div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 border border-muted"> <h1 className="text-3xl font-bold mb-6 text-foreground">About</h1> diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts @@ -5,99 +5,88 @@ import { shouldScheduleNotification } from '@/lib/timeUtils'; export const runtime = 'edge'; +const ONESIGNAL_APP_ID = process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID; +const ONESIGNAL_REST_KEY = process.env.ONESIGNAL_REST_API_KEY; + export async function POST(request: Request) { try { - const body = await request.json(); - const { tapTime, onesignalId, ticket1Time, ticket2Time } = body; + const { tapTime, onesignalId, ticket1Time, ticket2Time } = await request.json(); const authHeader = request.headers.get('Authorization'); + if (!authHeader) return NextResponse.json({ error: 'No token' }, { status: 401 }); const supabase = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - global: { - headers: { - Authorization: authHeader ?? '', - }, - }, - } + { global: { headers: { Authorization: authHeader } } } ); - - if (!authHeader) return NextResponse.json({ error: 'No token' }, { status: 401 }); - const { data: { user }, error: authError } = await supabase.auth.getUser(); - if (authError || !user) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const { data: profile } = await supabase - .from('profiles') - .select('tap_history') - .eq('id', user.id) - .single(); - - let newHistory = [...(profile?.tap_history || [])]; - if (tapTime) newHistory.push(tapTime); - - const upsertData: any = { - id: user.id, - updated_at: new Date().toISOString() - }; + if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - if (tapTime) upsertData.tap_history = newHistory; - if (onesignalId) upsertData.onesignal_id = onesignalId; - if (ticket1Time !== undefined) upsertData.ticket1_time = ticket1Time ? new Date(ticket1Time).toISOString() : null; - if (ticket2Time !== undefined) upsertData.ticket2_time = ticket2Time ? new Date(ticket2Time).toISOString() : null; - - const { error: upsertError } = await supabase - .from('profiles') - .upsert(upsertData); - - if (upsertError) { - console.error("DB Upsert Error:", upsertError); - throw new Error(upsertError.message); + if (onesignalId) { + await cleanupOldDevices(user.id); } - if (tapTime) { - // timeUtilsから判定関数をインポートして使用 - if (shouldScheduleNotification(new Date(tapTime))) { - const sendAfter = new Date(tapTime); - sendAfter.setHours(sendAfter.getHours() + 3); // Production - // sendAfter.setSeconds(sendAfter.getSeconds() + 180); // Testing - - const randomMsg = messages - ? messages[Math.floor(Math.random() * messages.length)] - : { title: "Cafe Timer", body: "カフェ業務の時間です" }; - - const notificationPayload = { - app_id: process.env.NEXT_PUBLIC_ONESIGNAL_APP_ID, - include_aliases: { - external_id: [user.id] - }, - target_channel: "push", - contents: { en: randomMsg.body, ja: randomMsg.body }, - headings: { en: randomMsg.title, ja: randomMsg.title }, - send_after: sendAfter.toISOString(), - }; + const { data: profile } = await supabase.from('profiles').select('tap_history').eq('id', user.id).single(); + const newHistory = tapTime ? [...(profile?.tap_history || []), tapTime] : (profile?.tap_history || []); + const { error: upsertError } = await supabase.from('profiles').upsert({ + id: user.id, + updated_at: new Date().toISOString(), + tap_history: newHistory, + onesignal_id: onesignalId || undefined, + ticket1_time: ticket1Time ? new Date(ticket1Time).toISOString() : (ticket1Time === null ? null : undefined), + ticket2_time: ticket2Time ? new Date(ticket2Time).toISOString() : (ticket2Time === null ? null : undefined), + }); + if (upsertError) throw new Error(upsertError.message); - const osRes = await fetch("https://onesignal.com/api/v1/notifications", { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Basic ${process.env.ONESIGNAL_REST_API_KEY}` - }, - body: JSON.stringify(notificationPayload) - }); - - if (!osRes.ok) { - console.error("OneSignal Error:", await osRes.text()); - } - } + if (tapTime && shouldScheduleNotification(new Date(tapTime))) { + await scheduleNotification(user.id, tapTime); } - return NextResponse.json({ success: true, history: newHistory }); - } catch (error: any) { console.error("API Error:", error); return NextResponse.json({ error: error.message }, { status: 500 }); } +} + +async function cleanupOldDevices(userId: string) { + try { + const res = await fetch(`https://api.onesignal.com/apps/${ONESIGNAL_APP_ID}/users/by/external_id/${userId}`, { + headers: { "Authorization": `Basic ${ONESIGNAL_REST_KEY}` } + }); + if (!res.ok) return; + const { subscriptions } = await res.json(); + const oldSubs = (subscriptions || []) + .filter((s: any) => s.type === "Push") + .sort((a: any, b: any) => new Date(b.last_active || 0).getTime() - new Date(a.last_active || 0).getTime()) + .slice(2); + await Promise.all(oldSubs.map((sub: any) => + fetch(`https://api.onesignal.com/apps/${ONESIGNAL_APP_ID}/subscriptions/${sub.id}`, { + method: "DELETE", + headers: { "Authorization": `Basic ${ONESIGNAL_REST_KEY}` } + }) + )); + } catch (e) { + console.error("Cleanup Error:", e); + } +} + +async function scheduleNotification(userId: string, tapTime: string) { + const sendAfter = new Date(tapTime); + sendAfter.setHours(sendAfter.getHours() + 3); + const msg = messages[Math.floor(Math.random() * messages.length)] || { title: "Cafe Timer", body: "カフェ業務の時間です" }; + const res = await fetch("https://onesignal.com/api/v1/notifications", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Basic ${ONESIGNAL_REST_KEY}` + }, + body: JSON.stringify({ + app_id: ONESIGNAL_APP_ID, + include_aliases: { external_id: [userId] }, + target_channel: "push", + contents: { en: msg.body, ja: msg.body }, + headings: { en: msg.title, ja: msg.title }, + send_after: sendAfter.toISOString(), + }) + }); + if (!res.ok) console.error("Notification Error:", await res.text()); } \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css @@ -14,15 +14,17 @@ --muted: #cccccc; --muted-foreground: #777777; --nav-foreground: #555555; - --tap-bg-1: #ededed; - --tap-bg-2: #ddffcc; - --tap-bg-3: #caffbb; - --tap-bg-4: #b0ffaa; - --tap-bg-5: #a0ff99; - --tap-bg-6: #88ff88; - --tap-bg-7: #73ff80; - --tap-bg-8: #55ff70; - --tap-bg-9: #00ff5e; + --tap-h: 120; + --tap-s: 100%; + --tap-bg-0: #ededed; + --tap-bg-1: hsl(var(--tap-h) var(--tap-s) 95%); + --tap-bg-2: hsl(var(--tap-h) var(--tap-s) 93%); + --tap-bg-3: hsl(var(--tap-h) var(--tap-s) 89%); + --tap-bg-4: hsl(var(--tap-h) var(--tap-s) 85%); + --tap-bg-5: hsl(var(--tap-h) var(--tap-s) 77%); + --tap-bg-6: hsl(var(--tap-h) var(--tap-s) 72%); + --tap-bg-7: hsl(var(--tap-h) var(--tap-s) 65%); + --tap-bg-8: hsl(var(--tap-h) var(--tap-s) 50%); --setting: #333333; --setting-foreground: #000000; } @@ -41,15 +43,17 @@ --muted: #555555; --muted-foreground: #aaaaaa; --nav-foreground: #aaaaaa; - --tap-bg-1: #151515; - --tap-bg-2: #153011; - --tap-bg-3: #153911; - --tap-bg-4: #154811; - --tap-bg-5: #155711; - --tap-bg-6: #156611; - --tap-bg-7: #158811; - --tap-bg-8: #15aa11; - --tap-bg-9: #15cc11; + --tap-h: 120; + --tap-s: 75%; + --tap-bg-0: #151515; + --tap-bg-8: hsl(var(--tap-h) var(--tap-s) 45%); + --tap-bg-7: hsl(var(--tap-h) var(--tap-s) 38%); + --tap-bg-6: hsl(var(--tap-h) var(--tap-s) 29%); + --tap-bg-5: hsl(var(--tap-h) var(--tap-s) 22%); + --tap-bg-4: hsl(var(--tap-h) var(--tap-s) 16%); + --tap-bg-3: hsl(var(--tap-h) var(--tap-s) 12%); + --tap-bg-2: hsl(var(--tap-h) var(--tap-s) 8%); + --tap-bg-1: hsl(var(--tap-h) var(--tap-s) 5%); } html { @@ -301,6 +305,31 @@ body { background-color: var(--tap-bg-9); } +input[type="range"] { + -webkit-appearance: none; + appearance: none; + background: transparent; + cursor: pointer; +} +input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + height: 18px; + width: 18px; + background-color: white; + border: 4px solid var(--tap-bg-8); + border-radius: 50%; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} +input[type="range"]::-moz-range-thumb { + height: 18px; + width: 18px; + background-color: white; + border: 4px solid var(--tap-bg-8); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + .mobile-view { display: block; margin-top: 3rem; diff --git a/src/app/guide/page.tsx b/src/app/guide/page.tsx @@ -2,12 +2,11 @@ import Link from 'next/link'; export default function GuidePage() { return ( - <div className="min-h-screen bg-background p-6"> + <div className="p-6"> <div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 border border-muted"> <h1 className="text-3xl font-bold mb-6 text-foreground">使い方ガイド</h1> <div className="space-y-6 font-normal"> - <section> <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2"> 🟦 カフェタイマーの使い方 @@ -21,36 +20,34 @@ export default function GuidePage() { </li> </ul> </section> - + <section> + <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2"> + 🟨 履歴と同期 + </h2> + <p> + Discordログインにより、タップ履歴がサーバーに保存されます。 + 同じアカウントでログインすると、PCやスマホなど異なる端末でも同じデータを共有・同期できます。 + </p> + </section> <section> <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2"> 🟦 通知設定について </h2> <p className="mb-4"> メニュー内の「通知設定」から、生徒さんと触れ合える時刻をプッシュ通知でお知らせします。 + 通知設定は1アカウントにつき2端末まで有効です。3端末以上で設定した場合、通知設定を行ったのが最も古い端末から通知設定が解除されます。<br /> + 4:00/16:00 JSTについては、時刻が固定されている&負荷軽減のため、通知を行いません。 </p> - <div className="bg-red-500/10 border border-red-500/50 rounded-lg p-4 text-sm"> <p className="font-bold mb-2">⚠️ 通知設定がうまくいかない場合</p> <p> <strong>AdGuard</strong> や <strong>uBlock Origin</strong> などの広告ブロック(コンテンツブロッカー)機能を使用している場合、通知システムが「トラッキング」と誤認され、許可ダイアログが表示されないことがあります。 </p> <p className="mt-2"> - 通知設定を行う際は、<strong>このサイト(ドメイン)を許可リストに追加する</strong>か、<strong>一時的に機能をOFF</strong>にしてからページを再読み込みしてください。 + 通知設定を行う際は、<strong>このサイト(ドメイン)を許可リストに追加する</strong>か、<strong>一時的に機能をOFF</strong>にしてからページを再読み込み・通知設定を行ってください。 </p> </div> </section> - - <section> - <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2"> - 🟨 履歴と同期 - </h2> - <p> - Discordログインにより、タップ履歴がサーバーに保存されます。 - 同じアカウントでログインすると、PCやスマホなど異なる端末でも同じデータを共有・同期できます。 - </p> - </section> - </div> <div className="mt-8 pt-4"> diff --git a/src/app/operator/page.tsx b/src/app/operator/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; export default function OperatorPage() { return ( - <div className="min-h-screen bg-background p-6"> + <div className="p-6"> <div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 border border-muted"> <h1 className="text-3xl font-bold mb-6 text-foreground">運営者情報</h1> @@ -10,8 +10,8 @@ export default function OperatorPage() { <div className="flex flex-col gap-2"> <h3 className="font-bold text-foreground">開発</h3> <p className="font-normal"> - 代表/運営: さにー (<a href="https://x.com/156miyako" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@156miyako</a>)<br /> - *** (<a href="https://x.com/" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@******</a>)</p> + ・さにー (代表) <a href="https://x.com/156miyako" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@156miyako</a><br /> + ・ハチか <a href="https://x.com/bite_sour_sweet" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@bite_sour_sweet</a></p> </div> <div className="flex flex-col gap-2 mt-4"> diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx @@ -2,7 +2,7 @@ import Link from 'next/link'; export default function PrivacyPage() { return ( - <div className="min-h-screen bg-background p-6"> + <div className="p-6"> <div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 border border-muted"> <h1 className="text-3xl font-bold mb-6 text-foreground">プライバシーポリシー</h1> diff --git a/src/app/providers.tsx b/src/app/providers.tsx @@ -1,6 +1,5 @@ "use client"; -// ★変更: 新しい useAuth をインポート import { AuthProvider } from "@/hooks/useAuth"; import { ThemeProvider } from "@/hooks/useTheme"; import { useEffect } from "react"; diff --git a/src/app/terms/page.tsx b/src/app/terms/page.tsx @@ -2,11 +2,11 @@ import Link from 'next/link'; export default function TermsPage() { return ( - <div className="min-h-screen bg-background p-6"> + <div className="p-6"> <div className="max-w-2xl mx-auto bg-card rounded-lg shadow-lg p-8 border border-muted"> <h1 className="text-3xl font-bold mb-6 text-foreground">利用規約</h1> - <div className="space-y-4 text-muted-foreground text-sm"> + <div className="space-y-4 font-normal text-sm"> <p>この利用規約(以下、「本規約」といいます。)は、本ウェブサイト上で提供するサービス(以下、「本サービス」といいます。)の利用条件を定めるものです。</p> <h3 className="font-bold text-foreground mt-4">1. 免責事項</h3> diff --git a/src/components/HistoryCalendar.tsx b/src/components/HistoryCalendar.tsx @@ -31,15 +31,15 @@ const HistoryCalendar: React.FC<HistoryCalendarProps> = ({ tapHistory }) => { const daysInMonth = new Date(year, month + 1, 0).getDate(); const getTapBgClass = (tapCount: number) => { - if (tapCount === 0) return 'bg-tap-1'; - if (tapCount === 1) return 'bg-tap-2'; - if (tapCount === 2) return 'bg-tap-3'; - if (tapCount === 3) return 'bg-tap-4'; - if (tapCount === 4) return 'bg-tap-5'; - if (tapCount === 5) return 'bg-tap-6'; - if (tapCount === 6) return 'bg-tap-7'; - if (tapCount === 7) return 'bg-tap-8'; - return 'bg-tap-9'; + if (tapCount === 0) return 'bg-tap-0'; + if (tapCount === 1) return 'bg-tap-1'; + if (tapCount === 2) return 'bg-tap-2'; + if (tapCount === 3) return 'bg-tap-3'; + if (tapCount === 4) return 'bg-tap-4'; + if (tapCount === 5) return 'bg-tap-5'; + if (tapCount === 6) return 'bg-tap-6'; + if (tapCount === 7) return 'bg-tap-7'; + return 'bg-tap-8'; }; const calendarDays = []; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { useAuth, supabase } from "@/hooks/useAuth"; import OneSignal from 'react-onesignal'; @@ -31,7 +31,24 @@ interface SettingsProps {} const Settings = ({}: SettingsProps) => { const { isLoggedIn, logout, avatarUrl, displayName } = useAuth(); const [isDeleting, setIsDeleting] = useState(false); + const [hue, setHue] = useState(120); + + useEffect(() => { + const savedHue = localStorage.getItem('theme-hue'); + if (savedHue) { + const h = parseInt(savedHue); + setHue(h); + document.documentElement.style.setProperty('--tap-h', h.toString()); + } + }, []); + const handleHueChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const val = parseInt(e.target.value); + setHue(val); + document.documentElement.style.setProperty('--tap-h', val.toString()); + localStorage.setItem('theme-hue', val.toString()); + }; + const menuItems = [ { label: 'About', path: '/about' }, { label: '使い方ガイド', path: '/guide' }, @@ -111,7 +128,25 @@ const handleNotificationClick = async () => { </li> ))} </ul> - + <div className="p-2"> + <div className="flex justify-between items-center mb-1"> + <label className="text-xs font-mono opacity-60">Calendar Color</label> + <span className="text-xs font-mono opacity-60">Hue: {hue}</span> + </div> + <input + type="range" + min="0" + max="360" + value={hue} + onChange={handleHueChange} + className="w-full h-2 rounded-lg appearance-none cursor-pointer" + style={{ + background: `linear-gradient(to right, + hsl(0, 80%, 50%), hsl(60, 80%, 50%), hsl(120, 80%, 50%), + hsl(180, 80%, 50%), hsl(240, 80%, 50%), hsl(300, 80%, 50%), hsl(360, 80%, 50%))` + }} + /> + </div> <div className="mt-8 border-t pt-4"> {isLoggedIn && ( <> diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo } from 'react'; import { addHours, differenceInMilliseconds } from 'date-fns'; import { formatInTimeZone } from 'date-fns-tz'; import CountdownDisplay from './CountdownDisplay'; -import { getNextBoundary, getSessionEndTime } from '@/lib/timeUtils'; +import { getSessionEndTime } from '@/lib/timeUtils'; const JST_TZ = 'Asia/Tokyo';