Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions app/[locale]/mypage/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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() {
const session = await auth.api.getSession({
headers: await headers(),
})

if (!session) {
redirect('/sign-in')
}

return <MyPageContent />
}
74 changes: 58 additions & 16 deletions app/[locale]/page.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -41,7 +44,9 @@ const ShortcutHandler = memo(function ShortcutHandler({

const Home = memo(function Home() {
const t = useTranslations('App')
const tAuth = useTranslations('Auth')
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)
Expand Down Expand Up @@ -78,6 +83,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 () => {
Expand Down Expand Up @@ -135,24 +141,60 @@ const Home = memo(function Home() {
</Suspense>

<div className="mx-auto w-full max-w-2xl space-y-12">
{/* Header with Language and Settings */}
<div className="relative text-center">
<h1 className="text-4xl font-bold text-text-primary">{t('title')}</h1>
<p className="mt-2 text-text-secondary">{t('description')}</p>

{/* Language Toggle - Left Side */}
<div className="absolute left-0 top-0">
{/* Header with Language, Auth, and Settings */}
<div className="space-y-2">
{/* Controls Row */}
<div className="flex items-center justify-between">
{/* Language Toggle - Left Side */}
<LanguageToggle />

{/* Auth + Settings - Right Side */}
<div className="flex items-center gap-1">
{session ? (
<>
<Link
href="/mypage"
className="rounded-full p-3 min-w-11 min-h-11 flex items-center justify-center text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-green"
aria-label={tAuth('myPage')}
>
<User className="h-6 w-6" />
</Link>
<button
onClick={async () => {
await authClient.signOut()
window.location.reload()
}}
className="rounded-full p-3 min-w-11 min-h-11 flex items-center justify-center text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-green cursor-pointer"
aria-label={tAuth('signOut')}
>
<LogOut className="h-5 w-5" />
</button>
</>
) : (
<Link
href="/sign-in"
className="rounded-full px-4 py-2 min-h-11 flex items-center justify-center text-sm font-medium text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-green"
>
{tAuth('signIn')}
</Link>
)}
<button
onClick={() => setIsSettingsOpen(true)}
className="rounded-full p-3 min-w-11 min-h-11 flex items-center justify-center text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-green cursor-pointer"
aria-label="Open settings"
>
<Settings className="h-6 w-6" />
</button>
</div>
</div>

{/* Settings Button - Right Side */}
<button
onClick={() => setIsSettingsOpen(true)}
className="absolute right-0 top-0 rounded-full p-3 min-w-11 min-h-11 flex items-center justify-center text-text-secondary transition-colors hover:bg-bg-secondary hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-primary-green"
aria-label="Open settings"
>
<Settings className="h-6 w-6" />
</button>
{/* Title */}
<div className="text-center">
<h1 className="text-4xl font-bold text-text-primary">
{t('title')}
</h1>
<p className="mt-2 text-text-secondary">{t('description')}</p>
</div>
</div>

{/* Timer Display */}
Expand Down
14 changes: 14 additions & 0 deletions app/[locale]/sign-in/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client'

import { memo } from 'react'
import { SignInForm } from '@/components/auth/SignInForm'

const SignInPage = memo(function SignInPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<SignInForm />
</main>
)
})

export default SignInPage
14 changes: 14 additions & 0 deletions app/[locale]/sign-up/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
'use client'

import { memo } from 'react'
import { SignUpForm } from '@/components/auth/SignUpForm'

const SignUpPage = memo(function SignUpPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-8">
<SignUpForm />
</main>
)
})

export default SignUpPage
4 changes: 4 additions & 0 deletions app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'

export const { GET, POST } = toNextJsHandler(auth)
85 changes: 85 additions & 0 deletions app/api/timer-sessions/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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
const body = (await request.json()) as {
note?: string
durationSeconds?: number
}

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 })
}
72 changes: 72 additions & 0 deletions app/api/timer-sessions/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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 })
}

const body = (await request.json()) as {
durationSeconds: number
completedAt: string
soundPreset: string
}

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 })
}
Loading
Loading