diff --git a/app/[locale]/mypage/page.tsx b/app/[locale]/mypage/page.tsx new file mode 100644 index 0000000..7b3d868 --- /dev/null +++ b/app/[locale]/mypage/page.tsx @@ -0,0 +1,27 @@ +import { auth } from '@/lib/auth' +import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { MyPageContent } from '@/components/mypage/MyPageContent' + +/** + * My Page - Protected server component. + * Validates session server-side and redirects to sign-in if unauthenticated. + * The proxy.ts middleware provides a lightweight cookie check as the first gate. + */ +// eslint-disable-next-line @laststance/react-next/all-memo -- async server components cannot be wrapped in React.memo +export default async function MyPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const { locale } = await params + const session = await auth.api.getSession({ + headers: await headers(), + }) + + if (!session) { + redirect(`/${locale}/sign-in`) + } + + return +} diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 0e77118..db33e19 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -1,8 +1,10 @@ 'use client' import { useState, memo, useCallback, Suspense } from 'react' -import { Settings } from 'lucide-react' +import { Settings, User, LogOut } from 'lucide-react' import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' +import { authClient } from '@/lib/auth-client' import { useTimerStore } from '@/lib/stores/timerStore' import { useSettingsStore } from '@/lib/stores/settingsStore' import { useNotificationStore } from '@/lib/stores/notificationStore' @@ -14,6 +16,7 @@ import { useSoundPreloader } from '@/lib/hooks/useSoundPreloader' import { useTimerTick } from '@/lib/hooks/useTimerTick' import { usePageVisibility } from '@/lib/hooks/usePageVisibility' import { useTimerCompletion } from '@/lib/hooks/useTimerCompletion' +import { useTimerSessionSave } from '@/lib/hooks/useTimerSessionSave' import { TimerDisplay } from '@/components/timer/TimerDisplay' import { TimerControls } from '@/components/timer/TimerControls' import { TimeInput } from '@/components/timer/TimeInput' @@ -41,7 +44,10 @@ const ShortcutHandler = memo(function ShortcutHandler({ const Home = memo(function Home() { const t = useTranslations('App') + const tAuth = useTranslations('Auth') + const tSettings = useTranslations('Settings') const tNotifications = useTranslations('Notifications') + const { data: session } = authClient.useSession() // Use hydration-safe hook to prevent SSR mismatches const timerState = useStore(useTimerStore, (state) => state) const settingsState = useStore(useSettingsStore, (state) => state) @@ -78,6 +84,7 @@ const Home = memo(function Home() { tNotifications('timerCompleteTitle'), tNotifications('timerCompleteBody'), ) + useTimerSessionSave(timeRemaining, initialTime, soundPreset, userSetTimeRef) // Start timer from PWA shortcut (separate function to handle async) const handleStartFromShortcut = useCallback(async () => { @@ -135,24 +142,60 @@ const Home = memo(function Home() {
- {/* Header with Language and Settings */} -
-

{t('title')}

-

{t('description')}

- - {/* Language Toggle - Left Side */} -
+ {/* Header with Language, Auth, and Settings */} +
+ {/* Controls Row */} +
+ {/* Language Toggle - Left Side */} + + {/* Auth + Settings - Right Side */} +
+ {session ? ( + <> + + + + + + ) : ( + + {tAuth('signIn')} + + )} + +
- {/* Settings Button - Right Side */} - + {/* Title */} +
+

+ {t('title')} +

+

{t('description')}

+
{/* Timer Display */} diff --git a/app/[locale]/sign-in/page.tsx b/app/[locale]/sign-in/page.tsx new file mode 100644 index 0000000..d8c79f9 --- /dev/null +++ b/app/[locale]/sign-in/page.tsx @@ -0,0 +1,14 @@ +'use client' + +import { memo } from 'react' +import { SignInForm } from '@/components/auth/SignInForm' + +const SignInPage = memo(function SignInPage() { + return ( +
+ +
+ ) +}) + +export default SignInPage diff --git a/app/[locale]/sign-up/page.tsx b/app/[locale]/sign-up/page.tsx new file mode 100644 index 0000000..438f16d --- /dev/null +++ b/app/[locale]/sign-up/page.tsx @@ -0,0 +1,14 @@ +'use client' + +import { memo } from 'react' +import { SignUpForm } from '@/components/auth/SignUpForm' + +const SignUpPage = memo(function SignUpPage() { + return ( +
+ +
+ ) +}) + +export default SignUpPage diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5d94414 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from '@/lib/auth' +import { toNextJsHandler } from 'better-auth/next-js' + +export const { GET, POST } = toNextJsHandler(auth) diff --git a/app/api/timer-sessions/[id]/route.ts b/app/api/timer-sessions/[id]/route.ts new file mode 100644 index 0000000..053c283 --- /dev/null +++ b/app/api/timer-sessions/[id]/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server' +import { headers } from 'next/headers' +import { eq, and } from 'drizzle-orm' +import { auth } from '@/lib/auth' +import { db } from '@/db' +import { timerSession } from '@/db/schema' + +/** + * PATCH /api/timer-sessions/[id] - Update a timer session. + * Only allows updating note and durationSeconds. + * + * @param request - JSON body: { note?: string, durationSeconds?: number } + * @returns Updated timer session + */ +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ + headers: await headers(), + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + let body: { note?: string; durationSeconds?: number } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + const updated = await db + .update(timerSession) + .set({ + ...(body.note !== undefined && { note: body.note }), + ...(body.durationSeconds !== undefined && { + durationSeconds: body.durationSeconds, + }), + updatedAt: new Date(), + }) + .where( + and(eq(timerSession.id, id), eq(timerSession.userId, session.user.id)), + ) + .returning() + + if (updated.length === 0) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + return NextResponse.json(updated[0]) +} + +/** + * DELETE /api/timer-sessions/[id] - Delete a timer session. + * + * @returns 204 No Content on success + */ +export async function DELETE( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const session = await auth.api.getSession({ + headers: await headers(), + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const deleted = await db + .delete(timerSession) + .where( + and(eq(timerSession.id, id), eq(timerSession.userId, session.user.id)), + ) + .returning() + + if (deleted.length === 0) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }) + } + + return new NextResponse(null, { status: 204 }) +} diff --git a/app/api/timer-sessions/route.ts b/app/api/timer-sessions/route.ts new file mode 100644 index 0000000..f872e27 --- /dev/null +++ b/app/api/timer-sessions/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from 'next/server' +import { headers } from 'next/headers' +import { eq, desc } from 'drizzle-orm' +import { nanoid } from 'nanoid' +import { auth } from '@/lib/auth' +import { db } from '@/db' +import { timerSession } from '@/db/schema' + +/** + * GET /api/timer-sessions - List timer sessions for the authenticated user. + * + * @returns JSON array of timer sessions, ordered by completedAt descending + * + * @example + * // Response: [{ id: 'abc', durationSeconds: 300, completedAt: '2026-02-16T09:30:00Z', ... }] + */ +export async function GET() { + const session = await auth.api.getSession({ + headers: await headers(), + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const sessions = await db + .select() + .from(timerSession) + .where(eq(timerSession.userId, session.user.id)) + .orderBy(desc(timerSession.completedAt)) + + return NextResponse.json(sessions) +} + +/** + * POST /api/timer-sessions - Create a new timer session. + * + * @param request - JSON body: { durationSeconds: number, completedAt: string, soundPreset: string } + * @returns Created timer session + * + * @example + * // Request body: + * { "durationSeconds": 300, "completedAt": "2026-02-16T09:30:00Z", "soundPreset": "ascending-chime" } + */ +export async function POST(request: NextRequest) { + const session = await auth.api.getSession({ + headers: await headers(), + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + let body: { + durationSeconds: number + completedAt: string + soundPreset: string + } + try { + body = await request.json() + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) + } + + if ( + typeof body.durationSeconds !== 'number' || + !Number.isFinite(body.durationSeconds) || + body.durationSeconds <= 0 + ) { + return NextResponse.json( + { error: 'Invalid durationSeconds' }, + { status: 400 }, + ) + } + + if (!body.completedAt || isNaN(Date.parse(body.completedAt))) { + return NextResponse.json({ error: 'Invalid completedAt' }, { status: 400 }) + } + + if (typeof body.soundPreset !== 'string' || body.soundPreset.length === 0) { + return NextResponse.json({ error: 'Invalid soundPreset' }, { status: 400 }) + } + + const newSession = await db + .insert(timerSession) + .values({ + id: nanoid(), + userId: session.user.id, + durationSeconds: body.durationSeconds, + completedAt: new Date(body.completedAt), + soundPreset: body.soundPreset, + }) + .returning() + + return NextResponse.json(newSession[0], { status: 201 }) +} diff --git a/app/globals.css b/app/globals.css index 094c262..52e089f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -41,6 +41,14 @@ --glass-tint-red: transparent; --glass-tint-blue: transparent; --ambient-gradient: none; + + /* Heatmap Colors */ + --heatmap-empty: #ebedf0; + --heatmap-level-1: rgba(4, 120, 87, 0.2); + --heatmap-level-2: rgba(4, 120, 87, 0.4); + --heatmap-level-3: rgba(4, 120, 87, 0.65); + --heatmap-level-4: #047857; + --heatmap-selected: #047857; } /* Dark Theme (Solid Styling) */ @@ -78,6 +86,14 @@ --glass-tint-red: transparent; --glass-tint-blue: transparent; --ambient-gradient: none; + + /* Heatmap Colors */ + --heatmap-empty: #161b22; + --heatmap-level-1: rgba(16, 185, 129, 0.25); + --heatmap-level-2: rgba(16, 185, 129, 0.45); + --heatmap-level-3: rgba(16, 185, 129, 0.7); + --heatmap-level-4: #10b981; + --heatmap-selected: #10b981; } /* Coffee Theme (Solid Styling) */ @@ -115,6 +131,14 @@ --glass-tint-red: transparent; --glass-tint-blue: transparent; --ambient-gradient: none; + + /* Heatmap Colors */ + --heatmap-empty: #d7ccc8; + --heatmap-level-1: rgba(93, 64, 55, 0.2); + --heatmap-level-2: rgba(93, 64, 55, 0.4); + --heatmap-level-3: rgba(93, 64, 55, 0.65); + --heatmap-level-4: #5d4037; + --heatmap-selected: #5d4037; } /* ============================================ @@ -184,6 +208,14 @@ rgba(96, 165, 250, 0.05) 0%, transparent 70% ); + + /* Heatmap Colors */ + --heatmap-empty: #ebedf0; + --heatmap-level-1: rgba(4, 120, 87, 0.2); + --heatmap-level-2: rgba(4, 120, 87, 0.4); + --heatmap-level-3: rgba(4, 120, 87, 0.65); + --heatmap-level-4: #047857; + --heatmap-selected: #047857; } /* Liquid Glass Dark Theme */ @@ -248,6 +280,14 @@ rgba(96, 165, 250, 0.04) 0%, transparent 70% ); + + /* Heatmap Colors */ + --heatmap-empty: #161b22; + --heatmap-level-1: rgba(16, 185, 129, 0.25); + --heatmap-level-2: rgba(16, 185, 129, 0.45); + --heatmap-level-3: rgba(16, 185, 129, 0.7); + --heatmap-level-4: #10b981; + --heatmap-selected: #10b981; } /* Liquid Glass Coffee Theme */ @@ -312,6 +352,14 @@ rgba(141, 110, 99, 0.05) 0%, transparent 70% ); + + /* Heatmap Colors */ + --heatmap-empty: #d7ccc8; + --heatmap-level-1: rgba(93, 64, 55, 0.2); + --heatmap-level-2: rgba(93, 64, 55, 0.4); + --heatmap-level-3: rgba(93, 64, 55, 0.65); + --heatmap-level-4: #5d4037; + --heatmap-selected: #5d4037; } /* ============================================ diff --git a/components/auth/SignInForm.tsx b/components/auth/SignInForm.tsx new file mode 100644 index 0000000..08b6931 --- /dev/null +++ b/components/auth/SignInForm.tsx @@ -0,0 +1,137 @@ +'use client' + +import { memo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { useTheme } from 'next-themes' +import { useRouter } from '@/i18n/navigation' +import { authClient } from '@/lib/auth-client' +import { useMounted } from '@/lib/hooks/useMounted' +import { GlassPanel } from '@/components/ui/GlassPanel' +import { Link } from '@/i18n/navigation' + +/** + * SignInForm - Email/password sign-in form with theme-aware styling. + * Supports both original and Liquid Glass theme variants. + * + * @example + * + */ +export const SignInForm = memo(function SignInForm() { + const t = useTranslations('Auth') + const { resolvedTheme } = useTheme() + const router = useRouter() + const mounted = useMounted() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [isPending, setIsPending] = useState(false) + + const isLiquidGlass = + mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setIsPending(true) + + await authClient.signIn.email( + { email, password }, + { + onSuccess: () => { + router.push('/') + router.refresh() + setIsPending(false) + }, + onError: (ctx) => { + setError(ctx.error?.message ?? t('signInError')) + setIsPending(false) + }, + }, + ) + } + + const formContent = ( +
+

+ {t('signIn')} +

+ + {error && ( +

+ {error} +

+ )} + +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="w-full rounded-lg border-2 border-bg-secondary bg-bg-primary px-4 py-3 text-text-primary placeholder:text-text-secondary/50 focus:border-primary-green focus:outline-none focus:ring-2 focus:ring-primary-green/20 transition-colors" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="current-password" + minLength={8} + className="w-full rounded-lg border-2 border-bg-secondary bg-bg-primary px-4 py-3 text-text-primary placeholder:text-text-secondary/50 focus:border-primary-green focus:outline-none focus:ring-2 focus:ring-primary-green/20 transition-colors" + /> +
+
+ + + +

+ {t('noAccount')}{' '} + + {t('signUp')} + +

+
+ ) + + if (isLiquidGlass) { + return ( + + {formContent} + + ) + } + + return ( +
+ {formContent} +
+ ) +}) diff --git a/components/auth/SignUpForm.tsx b/components/auth/SignUpForm.tsx new file mode 100644 index 0000000..87efc50 --- /dev/null +++ b/components/auth/SignUpForm.tsx @@ -0,0 +1,155 @@ +'use client' + +import { memo, useState } from 'react' +import { useTranslations } from 'next-intl' +import { useTheme } from 'next-themes' +import { useRouter } from '@/i18n/navigation' +import { authClient } from '@/lib/auth-client' +import { useMounted } from '@/lib/hooks/useMounted' +import { GlassPanel } from '@/components/ui/GlassPanel' +import { Link } from '@/i18n/navigation' + +/** + * SignUpForm - Email/password registration form with theme-aware styling. + * Supports both original and Liquid Glass theme variants. + * + * @example + * + */ +export const SignUpForm = memo(function SignUpForm() { + const t = useTranslations('Auth') + const { resolvedTheme } = useTheme() + const router = useRouter() + const mounted = useMounted() + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [isPending, setIsPending] = useState(false) + + const isLiquidGlass = + mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setIsPending(true) + + await authClient.signUp.email( + { name, email, password }, + { + onSuccess: () => { + router.push('/') + router.refresh() + }, + onError: (ctx) => { + setError(ctx.error?.message ?? t('signUpError')) + setIsPending(false) + }, + }, + ) + } + + const formContent = ( +
+

+ {t('signUp')} +

+ + {error && ( +

+ {error} +

+ )} + +
+
+ + setName(e.target.value)} + required + autoComplete="name" + className="w-full rounded-lg border-2 border-bg-secondary bg-bg-primary px-4 py-3 text-text-primary placeholder:text-text-secondary/50 focus:border-primary-green focus:outline-none focus:ring-2 focus:ring-primary-green/20 transition-colors" + /> +
+ +
+ + setEmail(e.target.value)} + required + autoComplete="email" + className="w-full rounded-lg border-2 border-bg-secondary bg-bg-primary px-4 py-3 text-text-primary placeholder:text-text-secondary/50 focus:border-primary-green focus:outline-none focus:ring-2 focus:ring-primary-green/20 transition-colors" + /> +
+ +
+ + setPassword(e.target.value)} + required + autoComplete="new-password" + minLength={8} + className="w-full rounded-lg border-2 border-bg-secondary bg-bg-primary px-4 py-3 text-text-primary placeholder:text-text-secondary/50 focus:border-primary-green focus:outline-none focus:ring-2 focus:ring-primary-green/20 transition-colors" + /> +
+
+ + + +

+ {t('hasAccount')}{' '} + + {t('signIn')} + +

+
+ ) + + if (isLiquidGlass) { + return ( + + {formContent} + + ) + } + + return ( +
+ {formContent} +
+ ) +}) diff --git a/components/mypage/CalendarHeatmap.tsx b/components/mypage/CalendarHeatmap.tsx new file mode 100644 index 0000000..be9f156 --- /dev/null +++ b/components/mypage/CalendarHeatmap.tsx @@ -0,0 +1,189 @@ +'use client' + +import { memo, useMemo } from 'react' +import { ChevronLeft, ChevronRight } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { useLocale } from 'next-intl' +import { useTheme } from 'next-themes' +import { useMounted } from '@/lib/hooks/useMounted' +import { GlassPanel } from '@/components/ui/GlassPanel' +import { DayCell } from './DayCell' +import { HeatmapLegend } from './HeatmapLegend' + +interface CalendarHeatmapProps { + currentMonth: Date + onMonthChange: (date: Date) => void + selectedDate: string + onDateSelect: (dateStr: string) => void + sessionCountByDate: Map +} + +/** + * CalendarHeatmap - Monthly calendar with GitHub-style heatmap coloring. + * Each day cell shows intensity based on coffee break count. + * Week start: Sunday for EN, Monday for JA. + * + * @example + * + */ +export const CalendarHeatmap = memo(function CalendarHeatmap({ + currentMonth, + onMonthChange, + selectedDate, + onDateSelect, + sessionCountByDate, +}: CalendarHeatmapProps) { + const t = useTranslations('MyPage') + const locale = useLocale() + const { resolvedTheme } = useTheme() + const mounted = useMounted() + const isLiquidGlass = + mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false) + + // Week starts on Monday for JA, Sunday for EN + const weekStartsOnMonday = locale === 'ja' + + const monthLabel = useMemo(() => { + return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'en-US', { + year: 'numeric', + month: 'long', + }).format(currentMonth) + }, [currentMonth, locale]) + + const dayHeaders = useMemo(() => { + const formatter = new Intl.DateTimeFormat( + locale === 'ja' ? 'ja-JP' : 'en-US', + { weekday: 'narrow' }, + ) + // Generate headers for Sun(0) through Sat(6) + const days = Array.from({ length: 7 }, (_, i) => { + const d = new Date(2026, 0, 4 + i) // 2026-01-04 is a Sunday + return formatter.format(d) + }) + if (weekStartsOnMonday) { + // Rotate: [Sun, Mon, ..., Sat] → [Mon, ..., Sat, Sun] + const [sun, ...rest] = days + return [...rest, sun] + } + return days + }, [locale, weekStartsOnMonday]) + + const calendarDays = useMemo(() => { + const year = currentMonth.getFullYear() + const month = currentMonth.getMonth() + const firstDay = new Date(year, month, 1) + const lastDay = new Date(year, month + 1, 0) + const daysInMonth = lastDay.getDate() + + // Day of week for first day (0=Sun, 6=Sat) + let startDow = firstDay.getDay() + if (weekStartsOnMonday) { + startDow = startDow === 0 ? 6 : startDow - 1 + } + + const cells: Array<{ day: number; dateStr: string } | null> = [] + // Leading empty cells + for (let i = 0; i < startDow; i++) { + cells.push(null) + } + // Day cells + for (let d = 1; d <= daysInMonth; d++) { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` + cells.push({ day: d, dateStr }) + } + return cells + }, [currentMonth, weekStartsOnMonday]) + + const todayStr = useMemo(() => { + const now = new Date() + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}` + }, []) + + const calendarContent = ( +
+ {/* Month Navigation */} +
+ +

+ {monthLabel} +

+ +
+ + {/* Day Headers */} +
+ {dayHeaders.map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {calendarDays.map((cell, i) => { + if (!cell) { + return + + {/* Legend */} + +
+ ) + + if (isLiquidGlass) { + return ( + + {calendarContent} + + ) + } + + return ( +
+ {calendarContent} +
+ ) +}) diff --git a/components/mypage/DayCell.tsx b/components/mypage/DayCell.tsx new file mode 100644 index 0000000..c5de018 --- /dev/null +++ b/components/mypage/DayCell.tsx @@ -0,0 +1,76 @@ +'use client' + +import { memo } from 'react' + +/** + * Maps session count to a heatmap intensity level (0-4). + * + * @param count - Number of sessions on a given day + * @returns CSS variable name for the background color + * + * @example + * getHeatmapColor(0) // => 'var(--heatmap-empty)' + * getHeatmapColor(3) // => 'var(--heatmap-level-2)' + * getHeatmapColor(6) // => 'var(--heatmap-level-4)' + */ +function getHeatmapColor(count: number): string { + if (count === 0) return 'var(--heatmap-empty)' + if (count === 1) return 'var(--heatmap-level-1)' + if (count <= 3) return 'var(--heatmap-level-2)' + if (count <= 5) return 'var(--heatmap-level-3)' + return 'var(--heatmap-level-4)' +} + +interface DayCellProps { + day: number + dateStr: string + sessionCount: number + isSelected: boolean + isToday: boolean + onSelect: (dateStr: string) => void +} + +/** + * DayCell - Individual calendar day with heatmap coloring. + * Shows session intensity and handles click selection. + * + * @example + * + */ +export const DayCell = memo(function DayCell({ + day, + dateStr, + sessionCount, + isSelected, + isToday, + onSelect, +}: DayCellProps) { + return ( + + ) +}) diff --git a/components/mypage/HeatmapLegend.tsx b/components/mypage/HeatmapLegend.tsx new file mode 100644 index 0000000..abef07d --- /dev/null +++ b/components/mypage/HeatmapLegend.tsx @@ -0,0 +1,41 @@ +'use client' + +import { memo } from 'react' +import { useTranslations } from 'next-intl' + +/** + * HeatmapLegend - Shows the color scale from "Less" to "More". + * + * @example + * + */ +export const HeatmapLegend = memo(function HeatmapLegend() { + const t = useTranslations('MyPage') + + return ( +
+ {t('heatmapLess')} +
+
+
+
+
+ {t('heatmapMore')} +
+ ) +}) diff --git a/components/mypage/MyPageContent.tsx b/components/mypage/MyPageContent.tsx new file mode 100644 index 0000000..10095ae --- /dev/null +++ b/components/mypage/MyPageContent.tsx @@ -0,0 +1,157 @@ +'use client' + +import { memo, useState, useCallback, useMemo } from 'react' +import { ArrowLeft, Settings } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { Link } from '@/i18n/navigation' +import { LanguageToggle } from '@/components/LanguageToggle' +import { SettingsPanel } from '@/components/settings/SettingsPanel' +import { CalendarHeatmap } from './CalendarHeatmap' +import { TimelinePanel } from './TimelinePanel' +import { SummaryStats } from './SummaryStats' +import { useTimerSessions } from '@/lib/hooks/useTimerSessions' + +/** + * Convert a date value to local YYYY-MM-DD string. + * Ensures consistent local-day bucketing regardless of timezone. + * + * @param value - Date string (ISO) or Date object + * @returns Local date string like "2026-02-16" + * + * @example + * toLocalDateStr('2026-02-16T23:59:00Z') // => "2026-02-17" (in UTC+9) + * toLocalDateStr(new Date()) // => "2026-02-16" + */ +function toLocalDateStr(value: string | Date): string { + const date = new Date(value) + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` +} + +/** + * Get today's date as YYYY-MM-DD string. + * + * @returns Local date string for today + * @example + * getTodayStr() // => "2026-02-16" + */ +function getTodayStr(): string { + return toLocalDateStr(new Date()) +} + +/** + * MyPageContent - Main client component for the My Page dashboard. + * Orchestrates calendar heatmap, timeline, and summary stats. + * Fetches session data from the API via useTimerSessions hook. + * + * @example + * + */ +export const MyPageContent = memo(function MyPageContent() { + const t = useTranslations('MyPage') + const tSettings = useTranslations('Settings') + const { sessions, isLoading, editSession, deleteSession } = useTimerSessions() + const [selectedDate, setSelectedDate] = useState(getTodayStr) + const [currentMonth, setCurrentMonth] = useState( + () => new Date(new Date().getFullYear(), new Date().getMonth()), + ) + const [isSettingsOpen, setIsSettingsOpen] = useState(false) + + // Semantic handlers for calendar navigation + const handleMonthChange = useCallback((date: Date) => { + setCurrentMonth(date) + }, []) + + const handleDateSelect = useCallback((dateStr: string) => { + setSelectedDate(dateStr) + }, []) + + const handleCloseSettings = useCallback(() => { + setIsSettingsOpen(false) + }, []) + + // Pre-compute session count by date for heatmap + const sessionCountByDate = useMemo(() => { + const map = new Map() + for (const s of sessions) { + const dateStr = toLocalDateStr(s.completedAt) + map.set(dateStr, (map.get(dateStr) ?? 0) + 1) + } + return map + }, [sessions]) + + // Filter sessions for the selected date + const selectedDateSessions = useMemo(() => { + return sessions.filter( + (s) => toLocalDateStr(s.completedAt) === selectedDate, + ) + }, [sessions, selectedDate]) + + return ( +
+
+ {/* Header */} +
+ {/* Controls Row */} +
+ {/* Back + Language Toggle - Left Side */} +
+ + + + +
+ + {/* Settings - Right Side */} + +
+ + {/* Title */} +

+ {t('title')} +

+
+ + {isLoading ? ( +
+
+
+ ) : ( + <> + {/* Calendar Heatmap */} + + + {/* Timeline */} + + + {/* Summary Stats */} + + + )} +
+ + {/* Settings Panel */} + +
+ ) +}) diff --git a/components/mypage/StatCard.tsx b/components/mypage/StatCard.tsx new file mode 100644 index 0000000..ae2c301 --- /dev/null +++ b/components/mypage/StatCard.tsx @@ -0,0 +1,52 @@ +'use client' + +import { memo } from 'react' +import { useTheme } from 'next-themes' +import { useMounted } from '@/lib/hooks/useMounted' +import { GlassPanel } from '@/components/ui/GlassPanel' + +interface StatCardProps { + label: string + time: string + count: string +} + +/** + * StatCard - Displays a summary statistic (total time + count) in a themed card. + * Supports both original and Liquid Glass theme variants. + * + * @example + * + */ +export const StatCard = memo(function StatCard({ + label, + time, + count, +}: StatCardProps) { + const { resolvedTheme } = useTheme() + const mounted = useMounted() + const isLiquidGlass = + mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false) + + const content = ( +
+

{label}

+

{time}

+

{count}

+
+ ) + + if (isLiquidGlass) { + return ( + + {content} + + ) + } + + return ( +
+ {content} +
+ ) +}) diff --git a/components/mypage/SummaryStats.tsx b/components/mypage/SummaryStats.tsx new file mode 100644 index 0000000..f315e48 --- /dev/null +++ b/components/mypage/SummaryStats.tsx @@ -0,0 +1,140 @@ +'use client' + +import { memo, useMemo } from 'react' +import { useTranslations } from 'next-intl' +import { useLocale } from 'next-intl' +import { StatCard } from './StatCard' +import type { TimerSessionRecord } from '@/lib/types/timerSession' + +interface SummaryStatsProps { + sessions: TimerSessionRecord[] +} + +/** + * Formats total seconds into a readable time string. + * + * @param totalSeconds - Total duration in seconds + * @param locale - 'en' or 'ja' + * @returns Formatted string like "18 min" or "1h 45min" + * + * @example + * formatTime(1080, 'en') // => "18 min" + * formatTime(6300, 'en') // => "1h 45min" + * formatTime(6300, 'ja') // => "1時間45分" + */ +function formatTime(totalSeconds: number, locale: string): string { + if (totalSeconds === 0) return locale === 'ja' ? '0分' : '0 min' + const hours = Math.floor(totalSeconds / 3600) + const minutes = Math.floor((totalSeconds % 3600) / 60) + + if (locale === 'ja') { + if (hours > 0) return `${hours}時間${minutes}分` + return `${minutes}分` + } + if (hours > 0) return `${hours}h ${minutes}min` + return `${minutes} min` +} + +/** + * Calculate stats for sessions in a date range. + * + * @param sessions - All sessions + * @param startDate - Range start (inclusive) + * @param endDate - Range end (inclusive) + * @returns Object with totalSeconds and count + * + * @example + * calcRange(sessions, '2026-02-16', '2026-02-16') // => { totalSeconds: 900, count: 3 } + */ +function calcRange( + sessions: TimerSessionRecord[], + startDate: Date, + endDate: Date, +): { totalSeconds: number; count: number } { + let totalSeconds = 0 + let count = 0 + for (const s of sessions) { + const d = new Date(s.completedAt) + if (d >= startDate && d <= endDate) { + totalSeconds += s.durationSeconds + count++ + } + } + return { totalSeconds, count } +} + +/** + * SummaryStats - Shows today/this week/this month break statistics. + * + * @example + * + */ +export const SummaryStats = memo(function SummaryStats({ + sessions, +}: SummaryStatsProps) { + const t = useTranslations('MyPage') + const locale = useLocale() + + const stats = useMemo(() => { + const now = new Date() + const todayStart = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + ) + const todayEnd = new Date( + now.getFullYear(), + now.getMonth(), + now.getDate(), + 23, + 59, + 59, + 999, + ) + + // Week start: Monday for JA, Sunday for EN + const weekStart = new Date(todayStart) + const dow = weekStart.getDay() + if (locale === 'ja') { + weekStart.setDate(weekStart.getDate() - (dow === 0 ? 6 : dow - 1)) + } else { + weekStart.setDate(weekStart.getDate() - dow) + } + + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1) + + return { + today: calcRange(sessions, todayStart, todayEnd), + week: calcRange(sessions, weekStart, todayEnd), + month: calcRange(sessions, monthStart, todayEnd), + } + }, [sessions, locale]) + + const formatCount = (count: number): string => { + if (count === 0) return t('noBreaksYet') + if (count === 1) return t('break') + return t('breaks', { count }) + } + + return ( +
+ + +
+ +
+
+ ) +}) diff --git a/components/mypage/TimelineEntry.tsx b/components/mypage/TimelineEntry.tsx new file mode 100644 index 0000000..5af99e9 --- /dev/null +++ b/components/mypage/TimelineEntry.tsx @@ -0,0 +1,184 @@ +'use client' + +import { memo, useState } from 'react' +import { Pencil, Trash2, MoreHorizontal } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { useLocale } from 'next-intl' +import type { TimerSessionRecord } from '@/lib/types/timerSession' + +interface TimelineEntryProps { + session: TimerSessionRecord + onEdit: ( + id: string, + updates: { note?: string; durationSeconds?: number }, + ) => void + onDelete: (id: string) => void +} + +/** + * Formats seconds into a human-readable duration string. + * + * @param seconds - Duration in seconds + * @returns Formatted string like "5:00" or "10:30" + * + * @example + * formatDuration(300) // => "5:00" + * formatDuration(630) // => "10:30" + */ +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${String(s).padStart(2, '0')}` +} + +/** + * TimelineEntry - Single timer session row in the timeline. + * Shows time, duration, and edit/delete actions. + * + * @example + * + */ +export const TimelineEntry = memo(function TimelineEntry({ + session, + onEdit, + onDelete, +}: TimelineEntryProps) { + const t = useTranslations('MyPage') + const locale = useLocale() + const [showMenu, setShowMenu] = useState(false) + const [isEditing, setIsEditing] = useState(false) + const [noteValue, setNoteValue] = useState(session.note ?? '') + const [showConfirmDelete, setShowConfirmDelete] = useState(false) + + const timeStr = new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(new Date(session.completedAt)) + + const saveNote = () => { + onEdit(session.id, { note: noteValue || undefined }) + setIsEditing(false) + } + + return ( +
  • + {/* Time */} + + {timeStr} + + + {/* Duration */} + + {formatDuration(session.durationSeconds)} + + + {/* Note or Sound Preset */} +
    + {isEditing ? ( +
    + setNoteValue(e.target.value)} + placeholder={t('notePlaceholder')} + className="flex-1 rounded-md border border-bg-secondary bg-bg-primary px-2 py-1 text-sm text-text-primary focus:border-primary-green focus:outline-none" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') saveNote() + if (e.key === 'Escape') setIsEditing(false) + }} + /> + + +
    + ) : ( + + {session.note ?? session.soundPreset} + + )} +
    + + {/* Actions - Desktop */} +
    + + {showConfirmDelete ? ( +
    + + +
    + ) : ( + + )} +
    + + {/* Actions - Mobile (overflow menu) */} +
    + + {showMenu && ( +
    + + +
    + )} +
    +
  • + ) +}) diff --git a/components/mypage/TimelinePanel.tsx b/components/mypage/TimelinePanel.tsx new file mode 100644 index 0000000..12b0427 --- /dev/null +++ b/components/mypage/TimelinePanel.tsx @@ -0,0 +1,110 @@ +'use client' + +import { memo, useMemo } from 'react' +import { Coffee } from 'lucide-react' +import { useTranslations } from 'next-intl' +import { useLocale } from 'next-intl' +import { useTheme } from 'next-themes' +import { useMounted } from '@/lib/hooks/useMounted' +import { GlassPanel } from '@/components/ui/GlassPanel' +import { TimelineEntry } from './TimelineEntry' +import type { TimerSessionRecord } from '@/lib/types/timerSession' +import { AnimatePresence, motion } from 'framer-motion' + +interface TimelinePanelProps { + selectedDate: string + sessions: TimerSessionRecord[] + onEdit: ( + id: string, + updates: { note?: string; durationSeconds?: number }, + ) => void + onDelete: (id: string) => void +} + +/** + * TimelinePanel - Shows timer sessions for a selected date in chronological order. + * Animates when the selected date changes. + * + * @example + * + */ +export const TimelinePanel = memo(function TimelinePanel({ + selectedDate, + sessions, + onEdit, + onDelete, +}: TimelinePanelProps) { + const t = useTranslations('MyPage') + const locale = useLocale() + const { resolvedTheme } = useTheme() + const mounted = useMounted() + const isLiquidGlass = + mounted && (resolvedTheme?.startsWith('liquid-glass') ?? false) + + const dateLabel = useMemo(() => { + const [y, m, d] = selectedDate.split('-').map(Number) + const date = new Date(y, m - 1, d) + return new Intl.DateTimeFormat(locale === 'ja' ? 'ja-JP' : 'en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }).format(date) + }, [selectedDate, locale]) + + const sortedSessions = useMemo(() => { + return [...sessions].sort( + (a, b) => + new Date(a.completedAt).getTime() - new Date(b.completedAt).getTime(), + ) + }, [sessions]) + + const timelineContent = ( +
    +

    + {t('timeline')}: {dateLabel} +

    + + + + {sortedSessions.length === 0 ? ( +
    + +

    {t('noSessions')}

    +
    + ) : ( +
      + {sortedSessions.map((s) => ( + + ))} +
    + )} +
    +
    +
    + ) + + if (isLiquidGlass) { + return ( + + {timelineContent} + + ) + } + + return ( +
    + {timelineContent} +
    + ) +}) diff --git a/db/index.ts b/db/index.ts new file mode 100644 index 0000000..207a33a --- /dev/null +++ b/db/index.ts @@ -0,0 +1,16 @@ +import { drizzle } from 'drizzle-orm/node-postgres' +import { Pool } from 'pg' +import * as schema from './schema' + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}) + +/** + * Drizzle ORM client instance with typed schema. + * Uses node-postgres Pool for connection management. + * + * @example + * const users = await db.select().from(schema.user) + */ +export const db = drizzle(pool, { schema }) diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..41794a3 --- /dev/null +++ b/db/schema.ts @@ -0,0 +1,76 @@ +import { pgTable, text, timestamp, boolean, integer } from 'drizzle-orm/pg-core' + +// ─── Better Auth tables ─────────────────────────────────────────── + +export const user = pgTable('user', { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: boolean('email_verified').notNull(), + image: text('image'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}) + +export const session = pgTable('session', { + id: text('id').primaryKey(), + expiresAt: timestamp('expires_at').notNull(), + token: text('token').notNull().unique(), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), +}) + +export const account = pgTable('account', { + id: text('id').primaryKey(), + accountId: text('account_id').notNull(), + providerId: text('provider_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + accessToken: text('access_token'), + refreshToken: text('refresh_token'), + idToken: text('id_token'), + accessTokenExpiresAt: timestamp('access_token_expires_at'), + refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), + scope: text('scope'), + password: text('password'), + createdAt: timestamp('created_at').notNull(), + updatedAt: timestamp('updated_at').notNull(), +}) + +export const verification = pgTable('verification', { + id: text('id').primaryKey(), + identifier: text('identifier').notNull(), + value: text('value').notNull(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at'), + updatedAt: timestamp('updated_at'), +}) + +// ─── Application tables ─────────────────────────────────────────── + +/** + * Stores completed timer sessions for authenticated users. + * Each row represents one coffee break that was timed to completion. + * + * @example + * // A 5-minute break completed at 9:30 AM + * { id: 'abc123', userId: 'usr_1', durationSeconds: 300, completedAt: '2026-02-16T09:30:00Z' } + */ +export const timerSession = pgTable('timer_session', { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + durationSeconds: integer('duration_seconds').notNull(), + completedAt: timestamp('completed_at').notNull(), + soundPreset: text('sound_preset').notNull(), + note: text('note'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), +}) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..9e76911 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:17-alpine + ports: + - '5433:5432' + environment: + POSTGRES_USER: coffee_timer + POSTGRES_PASSWORD: coffee_timer_dev + POSTGRES_DB: coffee_timer + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..a997c5a --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './db/schema.ts', + out: './db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}) diff --git a/lib/auth-client.ts b/lib/auth-client.ts new file mode 100644 index 0000000..9dd1671 --- /dev/null +++ b/lib/auth-client.ts @@ -0,0 +1,15 @@ +import { createAuthClient } from 'better-auth/react' + +/** + * Better Auth client instance for React components. + * Provides hooks like `useSession` and methods like `signIn`, `signUp`, `signOut`. + * + * @example + * const { data: session, isPending } = authClient.useSession() + * + * @example + * await authClient.signIn.email({ email, password }) + */ +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3009', +}) diff --git a/lib/auth.ts b/lib/auth.ts new file mode 100644 index 0000000..abb316e --- /dev/null +++ b/lib/auth.ts @@ -0,0 +1,24 @@ +import { betterAuth } from 'better-auth' +import { drizzleAdapter } from 'better-auth/adapters/drizzle' +import { db } from '@/db' + +/** + * Better Auth server instance. + * Configured with Drizzle adapter for PostgreSQL and email/password authentication. + * + * @example + * // Server-side session check + * const session = await auth.api.getSession({ headers: await headers() }) + * + * @example + * // API route handler + * export const { GET, POST } = toNextJsHandler(auth) + */ +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: 'pg', + }), + emailAndPassword: { + enabled: true, + }, +}) diff --git a/lib/hooks/useTimerSessionSave.ts b/lib/hooks/useTimerSessionSave.ts new file mode 100644 index 0000000..87982ba --- /dev/null +++ b/lib/hooks/useTimerSessionSave.ts @@ -0,0 +1,56 @@ +import { useEffect, useRef, type MutableRefObject } from 'react' +import type { SoundPreset } from '@/lib/stores/settingsStore' +import { authClient } from '@/lib/auth-client' + +/** + * Auto-save completed timer sessions to the server for authenticated users. + * Uses the same completion detection pattern as useTimerCompletion. + * Non-blocking fire-and-forget fetch — never delays sound or notification. + * + * @param timeRemaining - Current time remaining in seconds + * @param initialTime - Original timer duration in seconds + * @param soundPreset - Sound preset used for this session + * @param userSetTimeRef - Ref tracking if user manually set time (avoid false triggers) + * + * @example + * useTimerSessionSave(timeRemaining, initialTime, soundPreset, userSetTimeRef) + */ +export function useTimerSessionSave( + timeRemaining: number, + initialTime: number, + soundPreset: SoundPreset, + userSetTimeRef: MutableRefObject, +): void { + const { data: session } = authClient.useSession() + const previousTimeRef = useRef(timeRemaining) + + useEffect(() => { + // Same completion detection as useTimerCompletion: + // timer just hit 0, but not because user manually set it to 0 + if ( + previousTimeRef.current > 0 && + timeRemaining === 0 && + !userSetTimeRef.current && + session?.user + ) { + // Fire-and-forget: don't block sound or notification + fetch('/api/timer-sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + durationSeconds: initialTime, + completedAt: new Date().toISOString(), + soundPreset, + }), + }) + .then((res) => { + if (!res.ok) + console.error('[TimerSessionSave] Server error:', res.status) + }) + .catch((error) => { + console.error('[TimerSessionSave] Failed to save session:', error) + }) + } + previousTimeRef.current = timeRemaining + }, [timeRemaining, initialTime, soundPreset, userSetTimeRef, session]) +} diff --git a/lib/hooks/useTimerSessions.ts b/lib/hooks/useTimerSessions.ts new file mode 100644 index 0000000..32c28c7 --- /dev/null +++ b/lib/hooks/useTimerSessions.ts @@ -0,0 +1,75 @@ +import { useState, useEffect, useCallback } from 'react' +import type { TimerSessionRecord } from '@/lib/types/timerSession' + +/** + * Custom hook to fetch, edit, and delete timer sessions from the API. + * Manages loading state and provides CRUD operations. + * + * @returns Object with sessions array, loading state, and mutation handlers + * + * @example + * const { sessions, isLoading, editSession, deleteSession } = useTimerSessions() + */ +export function useTimerSessions() { + const [sessions, setSessions] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + let cancelled = false + + fetch('/api/timer-sessions') + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() + }) + .then((data: TimerSessionRecord[]) => { + if (!cancelled) { + setSessions(data) + setIsLoading(false) + } + }) + .catch((error) => { + console.error('[useTimerSessions] Failed to fetch sessions:', error) + if (!cancelled) setIsLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + const editSession = useCallback( + async ( + id: string, + updates: { note?: string; durationSeconds?: number }, + ) => { + try { + const res = await fetch(`/api/timer-sessions/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }) + if (!res.ok) return + const updated: TimerSessionRecord = await res.json() + setSessions((prev) => prev.map((s) => (s.id === id ? updated : s))) + } catch (error) { + console.error('[useTimerSessions] Failed to edit session:', error) + } + }, + [], + ) + + const deleteSession = useCallback(async (id: string) => { + try { + const res = await fetch(`/api/timer-sessions/${id}`, { + method: 'DELETE', + }) + if (!res.ok) return + setSessions((prev) => prev.filter((s) => s.id !== id)) + } catch (error) { + console.error('[useTimerSessions] Failed to delete session:', error) + } + }, []) + + return { sessions, isLoading, editSession, deleteSession } +} diff --git a/lib/types/timerSession.ts b/lib/types/timerSession.ts new file mode 100644 index 0000000..00e5113 --- /dev/null +++ b/lib/types/timerSession.ts @@ -0,0 +1,16 @@ +/** + * Timer session as returned from the API. + * + * @example + * { id: 'abc123', userId: 'usr_1', durationSeconds: 300, completedAt: '2026-02-16T09:30:00Z', soundPreset: 'ascending-chime', note: null } + */ +export interface TimerSessionRecord { + id: string + userId: string + durationSeconds: number + completedAt: string + soundPreset: string + note: string | null + createdAt: string + updatedAt: string +} diff --git a/messages/en.json b/messages/en.json index 12a6231..c9cce95 100644 --- a/messages/en.json +++ b/messages/en.json @@ -55,6 +55,47 @@ "en": "English", "ja": "日本語" }, + "Auth": { + "signIn": "Sign In", + "signUp": "Sign Up", + "signOut": "Sign Out", + "email": "Email", + "password": "Password", + "name": "Name", + "noAccount": "Don't have an account?", + "hasAccount": "Already have an account?", + "signingIn": "Signing in...", + "signingUp": "Signing up...", + "myPage": "My Page", + "signInError": "Invalid email or password", + "signUpError": "Sign up failed. Please try again." + }, + "MyPage": { + "title": "My Page", + "back": "Back", + "previousMonth": "Previous month", + "nextMonth": "Next month", + "heatmapLess": "Less", + "heatmapMore": "More", + "noSessions": "No coffee breaks recorded on this day.", + "timeline": "Timeline", + "edit": "Edit", + "delete": "Delete", + "confirmDelete": "Are you sure you want to delete this session?", + "cancel": "Cancel", + "save": "Save", + "duration": "Duration", + "note": "Note", + "notePlaceholder": "Add a note...", + "today": "Today", + "thisWeek": "This Week", + "thisMonth": "This Month", + "totalTime": "Total time", + "breaks": "{count} breaks", + "break": "1 break", + "noBreaksYet": "No breaks yet", + "actions": "Actions" + }, "Notifications": { "title": "Notifications", "enabled": "Enable notifications", diff --git a/messages/ja.json b/messages/ja.json index fe61440..38dc953 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -55,6 +55,47 @@ "en": "English", "ja": "日本語" }, + "Auth": { + "signIn": "サインイン", + "signUp": "サインアップ", + "signOut": "サインアウト", + "email": "メールアドレス", + "password": "パスワード", + "name": "名前", + "noAccount": "アカウントをお持ちでないですか?", + "hasAccount": "すでにアカウントをお持ちですか?", + "signingIn": "サインイン中...", + "signingUp": "サインアップ中...", + "myPage": "マイページ", + "signInError": "メールアドレスまたはパスワードが無効です", + "signUpError": "サインアップに失敗しました。もう一度お試しください。" + }, + "MyPage": { + "title": "マイページ", + "back": "戻る", + "previousMonth": "前月", + "nextMonth": "翌月", + "heatmapLess": "少ない", + "heatmapMore": "多い", + "noSessions": "この日のコーヒーブレイク記録はありません。", + "timeline": "タイムライン", + "edit": "編集", + "delete": "削除", + "confirmDelete": "このセッションを削除してもよろしいですか?", + "cancel": "キャンセル", + "save": "保存", + "duration": "時間", + "note": "メモ", + "notePlaceholder": "メモを追加...", + "today": "今日", + "thisWeek": "今週", + "thisMonth": "今月", + "totalTime": "合計時間", + "breaks": "{count}回", + "break": "1回", + "noBreaksYet": "まだ記録はありません", + "actions": "アクション" + }, "Notifications": { "title": "通知", "enabled": "通知を有効にする", diff --git a/package.json b/package.json index c8b5106..9fee86d 100644 --- a/package.json +++ b/package.json @@ -22,18 +22,28 @@ "e2e:mobile": "playwright test --project=\"Mobile Chrome\"", "prepare": "husky", "prettier": "prettier --ignore-unknown --write .", - "clean": "rm -rf .next node_modules .playwright-mcp test-results playwright-report .pnpm-store" + "clean": "rm -rf .next node_modules .playwright-mcp test-results playwright-report .pnpm-store", + "db:start": "docker compose up -d", + "db:stop": "docker compose down", + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@vercel/analytics": "^1.6.1", + "better-auth": "^1.4.18", + "drizzle-orm": "^0.45.1", "framer-motion": "^12.29.2", "lucide-react": "^0.563.0", + "nanoid": "^5.1.6", "next": "^16.1.6", "next-intl": "^4.8.1", "next-themes": "^0.4.6", + "pg": "^8.18.0", "react": "^19.2.4", "react-dom": "^19.2.4", "zustand": "^5.0.10" @@ -45,9 +55,11 @@ "@playwright/test": "^1.58.1", "@tailwindcss/postcss": "^4.1.18", "@types/node": "^25.1.0", + "@types/pg": "^8.16.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", "axe-core": "^4.11.1", + "drizzle-kit": "^0.31.9", "eslint": "^9.39.2", "eslint-config-next": "^16.1.6", "husky": "^9.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ce82ec..23ee637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,21 @@ importers: '@vercel/analytics': specifier: ^1.6.1 version: 1.6.1(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + better-auth: + specifier: ^1.4.18 + version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0))(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0) framer-motion: specifier: ^12.29.2 version: 12.29.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) lucide-react: specifier: ^0.563.0 version: 0.563.0(react@19.2.4) + nanoid: + specifier: ^5.1.6 + version: 5.1.6 next: specifier: ^16.1.6 version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -35,6 +44,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pg: + specifier: ^8.18.0 + version: 8.18.0 react: specifier: ^19.2.4 version: 19.2.4 @@ -63,6 +75,9 @@ importers: '@types/node': specifier: ^25.1.0 version: 25.1.0 + '@types/pg': + specifier: ^8.16.0 + version: 8.16.0 '@types/react': specifier: ^19.2.10 version: 19.2.10 @@ -72,6 +87,9 @@ importers: axe-core: specifier: ^4.11.1 version: 4.11.1 + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -175,6 +193,30 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@better-auth/core@1.4.18': + resolution: {integrity: sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.18': + resolution: {integrity: sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ==} + peerDependencies: + '@better-auth/core': 1.4.18 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -184,6 +226,302 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -487,6 +825,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -887,6 +1233,9 @@ packages: '@schummar/icu-type-parser@1.21.5': resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/core-darwin-arm64@1.15.11': resolution: {integrity: sha512-QoIupRWVH8AF1TgxYyeA5nS18dtqMuxNwchjBIwJo3RdwLEFiJq6onOx9JAxHtuPwUkIVuU2Xbp+jCJ7Vzmgtg==} engines: {node: '>=10'} @@ -1068,6 +1417,9 @@ packages: '@types/node@25.1.0': resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} + '@types/pg@8.16.0': + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1354,6 +1706,76 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + better-auth@1.4.18: + resolution: {integrity: sha512-bnyifLWBPcYVltH3RhS7CM62MoelEqC6Q+GnZwfiDWNfepXoQZBjEvn4urcERC7NTKgKq5zNBM8rvPvRBa6xcg==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: {integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1369,6 +1791,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1476,6 +1901,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -1487,6 +1915,102 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + drizzle-kit@0.31.9: + resolution: {integrity: sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==} + hasBin: true + + drizzle-orm@0.45.1: + resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1540,6 +2064,21 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1987,6 +2526,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2024,6 +2566,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kysely@0.28.11: + resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} + engines: {node: '>=20.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -2184,6 +2730,15 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.6: + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} + engines: {node: ^18 || >=20} + hasBin: true + + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.4: resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -2309,6 +2864,40 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + + pg-connection-string@2.11.0: + resolution: {integrity: sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.11.0: + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.18.0: + resolution: {integrity: sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2350,6 +2939,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -2446,6 +3051,9 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2473,6 +3081,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2525,6 +3136,17 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -2732,6 +3354,10 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2880,6 +3506,29 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@standard-schema/spec': 1.1.0 + better-call: 1.1.8(zod@4.3.6) + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.6 + + '@better-auth/telemetry@1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.21': {} + + '@drizzle-team/brocli@0.10.2': {} + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -2896,6 +3545,160 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.1 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -3158,6 +3961,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.1.6': optional: true + '@noble/ciphers@2.1.1': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3509,6 +4316,8 @@ snapshots: '@schummar/icu-type-parser@1.21.5': {} + '@standard-schema/spec@1.1.0': {} + '@swc/core-darwin-arm64@1.15.11': optional: true @@ -3649,6 +4458,12 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/pg@8.16.0': + dependencies: + '@types/node': 25.1.0 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.10)': dependencies: '@types/react': 19.2.10 @@ -3928,6 +4743,37 @@ snapshots: baseline-browser-mapping@2.9.19: {} + better-auth@1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0))(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.18.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 + '@noble/hashes': 2.0.1 + better-call: 1.1.8(zod@4.3.6) + defu: 6.1.4 + jose: 6.1.3 + kysely: 0.28.11 + nanostores: 1.1.0 + zod: 4.3.6 + optionalDependencies: + drizzle-kit: 0.31.9 + drizzle-orm: 0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0) + next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pg: 8.18.0 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + better-call@1.1.8(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 2.7.2 + optionalDependencies: + zod: 4.3.6 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3949,6 +4795,8 @@ snapshots: node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) + buffer-from@1.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4052,6 +4900,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -4060,6 +4910,21 @@ snapshots: dependencies: esutils: 2.0.3 + drizzle-kit@0.31.9: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.45.1(@types/pg@8.16.0)(kysely@0.28.11)(pg@8.18.0): + optionalDependencies: + '@types/pg': 8.16.0 + kysely: 0.28.11 + pg: 8.18.0 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4180,6 +5045,67 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + esbuild-register@3.6.0(esbuild@0.25.12): + dependencies: + debug: 4.4.3 + esbuild: 0.25.12 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} @@ -4706,6 +5632,8 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-tokens@4.0.0: {} js-yaml@4.1.1: @@ -4737,6 +5665,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kysely@0.28.11: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -4879,6 +5809,10 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.6: {} + + nanostores@1.1.0: {} + napi-postinstall@0.3.4: {} natural-compare@1.4.0: {} @@ -5017,6 +5951,41 @@ snapshots: path-parse@1.0.7: {} + pg-cloudflare@1.3.0: + optional: true + + pg-connection-string@2.11.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.11.0(pg@8.18.0): + dependencies: + pg: 8.18.0 + + pg-protocol@1.11.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.18.0: + dependencies: + pg-connection-string: 2.11.0 + pg-pool: 3.11.0(pg@8.18.0) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5049,6 +6018,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} prettier@3.8.1: {} @@ -5144,6 +6123,8 @@ snapshots: rfdc@1.4.1: {} + rou3@0.7.12: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -5173,6 +6154,8 @@ snapshots: semver@7.7.3: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5270,6 +6253,15 @@ snapshots: source-map-js@1.2.1: {} + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + split2@4.2.0: {} + stable-hash@0.0.5: {} stop-iteration-iterator@1.1.0: @@ -5556,6 +6548,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + xtend@4.0.2: {} + yallist@3.1.1: {} yaml@2.8.2: {} diff --git a/proxy.ts b/proxy.ts index 1239afc..ce12626 100644 --- a/proxy.ts +++ b/proxy.ts @@ -1,7 +1,48 @@ +import { type NextRequest, NextResponse } from 'next/server' import createMiddleware from 'next-intl/middleware' import { routing } from './i18n/routing' -export default createMiddleware(routing) +const intlMiddleware = createMiddleware(routing) + +/** Route paths (without locale prefix) that require authentication. */ +const protectedPaths = ['/mypage'] + +/** + * Check if a pathname (after stripping locale prefix) matches a protected route. + * + * @param pathname - Full request pathname e.g. "/en/mypage" + * @returns true if the route requires auth + * + * @example + * isProtectedRoute('/en/mypage') // => true + * isProtectedRoute('/ja/') // => false + */ +function isProtectedRoute(pathname: string): boolean { + const withoutLocale = pathname.replace(/^\/(en|ja)/, '') || '/' + return protectedPaths.some((p) => withoutLocale.startsWith(p)) +} + +/** + * Next.js 16 proxy (middleware) combining auth protection with i18n routing. + * - Protected routes redirect to sign-in if no session cookie present. + * - All non-API routes pass through next-intl middleware. + */ +export default function proxy(request: NextRequest) { + const { pathname } = request.nextUrl + + // Lightweight cookie check for protected routes (no DB call) + if (isProtectedRoute(pathname)) { + const sessionCookie = + request.cookies.get('better-auth.session_token') ?? + request.cookies.get('__Secure-better-auth.session_token') + if (!sessionCookie) { + const locale = pathname.match(/^\/(en|ja)/)?.[1] ?? 'en' + return NextResponse.redirect(new URL(`/${locale}/sign-in`, request.url)) + } + } + + return intlMiddleware(request) +} export const config = { // Match all pathnames except for diff --git a/tailwind.config.ts b/tailwind.config.ts index d51a3e5..03451d9 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -24,6 +24,14 @@ const config: Config = { blue: 'var(--color-accent-blue)', amber: 'var(--color-accent-amber)', }, + heatmap: { + empty: 'var(--heatmap-empty)', + 'level-1': 'var(--heatmap-level-1)', + 'level-2': 'var(--heatmap-level-2)', + 'level-3': 'var(--heatmap-level-3)', + 'level-4': 'var(--heatmap-level-4)', + selected: 'var(--heatmap-selected)', + }, }, boxShadow: { soft: 'var(--shadow-soft)',