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:
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';