commit b76a18238989c7073f281defc21d215f65514214
parent 7b2c95bc41a29f4a3294e8cda5972fd989282a2d
Author: Sunny <122193933+Sunny-JP@users.noreply.github.com>
Date: Sun, 1 Feb 2026 17:02:14 +0900
Merge pull request #45 from Sunny-JP/v4-sunny-dev
V4 sunny dev
Diffstat:
9 files changed, 262 insertions(+), 25 deletions(-)
diff --git a/src/app/api/tap/route.ts b/src/app/api/tap/route.ts
@@ -33,7 +33,34 @@ export async function POST(request: Request) {
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- // --- 1. DB更新用データの構築 ---
+ // --- 1. 重複タップ防止ロジック ---
+ if (tapTime) {
+ const { data: profile } = await supabase
+ .from('profiles')
+ .select('tap_history')
+ .eq('id', user.id)
+ .single();
+
+ const history = profile?.tap_history || [];
+ if (history.length > 0) {
+ const lastTapDate = new Date(history[history.length - 1]);
+ const currentTapDate = new Date(tapTime);
+ const diffMs = currentTapDate.getTime() - lastTapDate.getTime();
+
+ // 1時間以内の連続タップをチェック
+ if (diffMs < 3600000) {
+ const lastShouldNotify = shouldScheduleNotification(lastTapDate);
+ const currentShouldNotify = shouldScheduleNotification(currentTapDate);
+
+ // 通知予約の判定(送る/送らない)が同じ時間枠なら、重複として拒否
+ if (lastShouldNotify === currentShouldNotify) {
+ return NextResponse.json({ error: 'Duplicate tap within 1 hour' }, { status: 429 });
+ }
+ }
+ }
+ }
+
+ // --- 2. DB更新データの作成 ---
const upsertData: any = {
id: user.id,
updated_at: new Date().toISOString()
@@ -56,6 +83,7 @@ export async function POST(request: Request) {
await supabase.from('profiles').upsert(upsertData);
+ // --- 3. 通知予約処理 ---
if (tapTime && shouldScheduleNotification(new Date(tapTime))) {
const sendAfter = new Date(tapTime);
sendAfter.setSeconds(0, 0);
diff --git a/src/app/faq/page.tsx b/src/app/faq/page.tsx
@@ -0,0 +1,95 @@
+import Link from 'next/link';
+
+export default function FaqPage() {
+ return (
+ <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">FAQ</h1>
+
+ <div className="space-y-4 text-muted-foreground">
+ <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2">
+ 🟦 機能
+ </h2>
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: 記録を間違えてタップしてしまいました。削除できますか?</h3>
+ <p className="font-normal text-sm">
+ A: Tap履歴を個別に編集・削除することはできません。データベースへの接続増加を伴うため、現時点では実装予定はありません。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: 追加招待券には対応しますか?</h3>
+ <p className="font-normal text-sm">
+ A: 今のところは対応していません。が、要望が多ければ検討します。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: Tap履歴のエクスポートはできますか?</h3>
+ <p className="font-normal text-sm">
+ A: テキストデータなどによるエクスポートは、今のところは対応していません。SNSなどへの共有は、月ごとの画像エクスポート機能をご使用ください。</p>
+ </div>
+
+ <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2">
+ 🟨 システム
+ </h2>
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: オフラインでも利用できますか?</h3>
+ <p className="font-normal text-sm">
+ A: 利用できません。インターネットに接続が必要です。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: アプリを閉じてもタイマー・チケットは維持されますか?</h3>
+ <p className="font-normal text-sm">
+ A: はい、維持されます。時間はサーバー側に保存されているため、ブラウザを閉じたり、別の端末からアクセスしたりしても、最新の状態が反映されます。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: データのバックアップは必要ですか?</h3>
+ <p className="font-normal text-sm">
+ A: ログインに使用しているDiscordアカウントに紐づいてクラウド上に保存されるため、ログアウトしてもデータが消えることはありません。ただし、データの永久保存については保証しておりませんので、各自で別途記録しておくことをおすすめします。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: 複数の端末で同時に使えますか?</h3>
+ <p className="font-normal text-sm">
+ A: 可能ですが、データの整合性を保つために、他方の端末では再読み込みを行ってから操作を開始してください。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: このサービスは無料で使えますか?</h3>
+ <p className="font-normal text-sm">
+ A: はい、すべての機能を無料でご利用いただけます。ただし、個人開発のサービスであるため、予告なく仕様変更やサービス停止を行う可能性がある点はあらかじめご了承ください。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: 通知が届きません。</h3>
+ <p className="font-normal text-sm">
+ A: ブラウザの設定で通知が「許可」になっているかご確認ください。また、本サイトの通知設定をやり直してみてください(使い方ガイドのページも参照)。ご利用の端末に起因する通知の不着は、申し訳ありませんが対応いたしかねます。利用規約にもある通り、通知は必ず届くことを保証しておりません。</p>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <h3 className="font-bold text-foreground">
+ Q: Discord以外のログイン方法はありますか?</h3>
+ <p className="font-normal text-sm">
+ A: 現在はDiscord認証のみに対応しています。本サイトはアカウントを切り替えて使用する設計になっていないため、他の認証システムを導入する予定もありません。</p>
+ </div>
+ </div>
+
+ <div className="mt-8 pt-4">
+ <Link href="/" className="btn-setting inline-block text-center w-full sm:w-auto">
+ ホームに戻る
+ </Link>
+ </div>
+ </div>
+ </div>
+ );
+}+
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
@@ -254,26 +254,36 @@ body {
font-weight: 700;
padding: 3cqw 0;
margin: 0;
- border-radius: 1.5cqw;
font-size: 4.5cqw;
- transition: all 0.2s ease-in-out;
+ transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
background-color: var(--primary);
color: var(--primary-foreground);
height: auto;
+ cursor: pointer;
+ border: none;
+ transform: skewX(-20deg);
+ border-radius: 8px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+.btn-timer span,
+.btn-timer div {
+ transform: skewX(20deg);
+}
+.btn-timer:hover:not(:disabled) {
+ filter: brightness(1.1);
+ transform: skewX(-20deg) translateX(5px);
}
.btn-timer:disabled {
background-color: var(--muted);
color: var(--muted-foreground);
cursor: not-allowed;
}
-
-.btn-timer-tap {
- background-color: var(--primary);
- color: var(--primary-foreground);
- transition: all 0.2s ease-in-out;
-}
-.btn-timer-tap:hover {
- filter: brightness(0.9);
+.btn-timer:disabled::before,
+.btn-timer:disabled::after {
+ background-color: var(--muted);
}
.cal-info {
@@ -427,3 +437,32 @@ input[type="range"]::-moz-range-thumb {
font-size: 3.5cqw;
}
}
+
+.ba-ring-container {
+ overflow: visible;
+}
+.ba-glow-ring {
+ fill: none;
+ stroke: var(--primary);
+ stroke-width: 1.5;
+ transform-origin: center;
+ opacity: 0;
+ filter: blur(1px) drop-shadow(0 0 4px var(--primary));
+ animation: ba-glow-anim 0.5s ease-out forwards;
+}
+.dark .ba-glow-ring {
+ filter: drop-shadow(0 0 4px var(--primary)) drop-shadow(0 0 12px var(--primary)) brightness(1.8);
+}
+@keyframes ba-glow-anim {
+ 0% {
+ transform: scale(0.6);
+ opacity: 0;
+ }
+ 20% {
+ opacity: 1;
+ }
+ 100% {
+ transform: scale(1.2);
+ opacity: 0;
+ }
+}
diff --git a/src/app/guide/page.tsx b/src/app/guide/page.tsx
@@ -22,7 +22,17 @@ export default function GuidePage() {
</section>
<section>
<h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2">
- 🟨 履歴と同期
+ 🟨 Tap履歴の使い方
+ </h2>
+ <ul className="list-disc pl-5 space-y-2">
+ <li>
+ <strong>SAVE IMAGE:</strong> Tap履歴を月ごとに画像で保存できます。SNSへの投稿などにご利用いただけます。
+ </li>
+ </ul>
+ </section>
+ <section>
+ <h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2">
+ 🟦 履歴と同期
</h2>
<p>
Discordログインにより、タップ履歴がサーバーに保存されます。
@@ -31,7 +41,7 @@ export default function GuidePage() {
</section>
<section>
<h2 className="text-xl font-bold mb-3 text-foreground border-b border-muted pb-2">
- 🟦 通知設定について
+ 🟨 通知設定について
</h2>
<p className="mb-4">
メニュー内の「通知設定」から、生徒さんと触れ合える時刻をプッシュ通知でお知らせします。
@@ -40,10 +50,10 @@ export default function GuidePage() {
<div className="bg-blue-500/10 border border-blue-500/50 rounded-lg p-4 text-sm mb-4">
<p className="font-bold mb-2">ℹ️ お願い</p>
<p>
- 通知登録されている端末数が増加すると運営者のお財布にクリティカルが発生します。
+ 通知登録されている端末数が増加すると、運営者の大人のカードの出番がやってきてしまいます。
</p>
<p className="mt-1">
- 通知登録は1ユーザーあたり2端末を目安とし、通知が不要な端末は都度登録解除していただくようご協力をお願いいたします。
+ 通知登録は1ユーザーあたり2端末までを目安とし、通知が不要な端末は都度登録解除していただくよう、何卒ご協力をお願いいたします。
</p>
</div>
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-4 text-sm">
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { Mandali, Kosugi_Maru } from "next/font/google";
import { GoogleAnalytics } from '@next/third-parties/google';
import "./globals.css";
import Providers from "./providers";
+import TouchEffect from "@/components/TouchEffect";
const mandali = Mandali({
weight: "400",
@@ -18,7 +19,7 @@ const kosugi = Kosugi_Maru({
export const metadata: Metadata = {
title: "Cafe Timer",
- description: "大切な人との記憶を、永遠に。",
+ description: "この一瞬を、アーカイブ。",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
@@ -27,7 +28,7 @@ export const metadata: Metadata = {
},
openGraph: {
title: "Cafe Timer",
- description: "大切な人との記憶を、永遠に。",
+ description: "この一瞬を、アーカイブ。",
url: "https://cafetimer.rabbit1.cc",
siteName: "Cafe Timer",
images: [
@@ -42,7 +43,7 @@ export const metadata: Metadata = {
twitter: {
card: "summary_large_image",
title: "Cafe Timer",
- description: "大切な人との記憶を、永遠に。",
+ description: "この一瞬を、アーカイブ。",
images: ["https://cafetimer.rabbit1.cc/ogp.png"],
},
};
@@ -68,6 +69,7 @@ export default function RootLayout({
<body className={`${mandali.variable} ${kosugi.variable} h-full`}>
<GoogleAnalytics gaId="G-FQSKNGBJ7W" />
<Providers>
+ <TouchEffect />
{children}
</Providers>
</body>
diff --git a/src/app/operator/page.tsx b/src/app/operator/page.tsx
@@ -10,7 +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></p>
+ ・さにー <a href="https://x.com/156miyako" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">@156miyako </a>
+ <a href="https://rabbit1.cc" target="_blank" rel="noopener noreferrer" className="hover:underline text-blue-400">https://rabbit1.cc</a></p>
</div>
<div className="flex flex-col gap-2">
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx
@@ -34,6 +34,7 @@ const Settings = () => {
const menuItems = [
{ label: 'About', path: '/about' },
{ label: '使い方ガイド', path: '/guide' },
+ { label: 'FAQ', path: '/faq' },
{ label: '利用規約', path: '/terms' },
{ label: 'プライバシーポリシー', path: '/privacy' },
{ label: '運営者情報', path: '/operator' },
@@ -81,8 +82,13 @@ const Settings = () => {
}
};
- const handleDeleteData = async () => {
- if (!window.confirm("本当に全データを削除しますか?")) return;
+const handleDeleteData = async () => {
+ const firstConfirm = window.confirm("本当に全データを削除しますか?\nこの操作は取り消せません。");
+ if (!firstConfirm) return;
+
+ const secondConfirm = window.confirm("本当に、すべての記録を完全に消去してもよろしいですか?\nこの操作は取り消せません。");
+ if (!secondConfirm) return;
+
setIsDeleting(true);
try {
const { data: { user } } = await supabase.auth.getUser();
@@ -99,7 +105,6 @@ const Settings = () => {
setIsDeleting(false);
}
};
-
return (
<div className="p-4">
<div className="space-y-4">
diff --git a/src/components/TimerDashboard.tsx b/src/components/TimerDashboard.tsx
@@ -165,7 +165,7 @@ export default function TimerDashboard({
disabled={!canTapCafe || isSyncing}
className="btn-timer btn-timer-tap"
>
- { !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' }
+ <span>{ !isDataLoaded ? 'Wait...' : isSyncing ? 'Wait...' : 'Tap' }</span>
</button>
</div>
@@ -180,7 +180,7 @@ export default function TimerDashboard({
onClick={() => onInvite(1)}
disabled={!isDataLoaded || ticket1Remaining > 0 || isSyncing}
className="btn-timer btn-timer-tap"
- >{ isSyncing ? 'Wait...' : 'Ticket 1' }</button>
+ ><span>{ isSyncing ? 'Wait...' : 'Ticket 1' }</span></button>
</div>
<div className="flex flex-col gap-2">
<div className="countdown-text-s bg-background/50 p-2 rounded">
@@ -190,7 +190,7 @@ export default function TimerDashboard({
onClick={() => onInvite(2)}
disabled={!isDataLoaded || ticket2Remaining > 0 || isSyncing}
className="btn-timer btn-timer-tap"
- >{ isSyncing ? 'Wait...' : 'Ticket 2' }</button>
+ ><span>{ isSyncing ? 'Wait...' : 'Ticket 2' }</span></button>
</div>
</div>
</div>
diff --git a/src/components/TouchEffect.tsx b/src/components/TouchEffect.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import React, { useEffect, useState, useCallback } from 'react';
+
+interface EffectInstance {
+ id: number;
+ x: number;
+ y: number;
+}
+
+export default function BlueArchiveTouchEffect() {
+ const [effects, setEffects] = useState<EffectInstance[]>([]);
+
+ const handlePointerDown = useCallback((e: PointerEvent) => {
+ const id = Date.now();
+ setEffects(prev => [...prev, { id, x: e.clientX, y: e.clientY }]);
+
+ setTimeout(() => {
+ setEffects(prev => prev.filter(effect => effect.id !== id));
+ }, 800);
+ }, []);
+
+ useEffect(() => {
+ window.addEventListener('pointerdown', handlePointerDown);
+ return () => window.removeEventListener('pointerdown', handlePointerDown);
+ }, [handlePointerDown]);
+
+ return (
+ <div className="fixed inset-0 pointer-events-none z-9999 overflow-hidden">
+ {effects.map(effect => (
+ <div
+ key={effect.id}
+ className="absolute"
+ style={{
+ left: `${effect.x}px`,
+ top: `${effect.y}px`,
+ transform: 'translate(-50%, -50%)',
+ width: '1px',
+ height: '1px'
+ }}
+ >
+
+ <svg className="ba-ring-container" width="100" height="100" viewBox="0 0 100 100" style={{ position: 'absolute', left: '-50px', top: '-50px' }}>
+ <circle
+ cx="50"
+ cy="50"
+ r="40"
+ className="ba-glow-ring"
+ />
+ </svg>
+ </div>
+ ))}
+ </div>
+ );
+}+
\ No newline at end of file