Skip to content
Open

Dev #85

4 changes: 3 additions & 1 deletion app/HomeClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const TAB_LABELS: Record<Tab, string> = {
transit: '流日圖',
}

export default function HomeClient() {
const HomeClient = () => {
const [tab, setTab] = useState<Tab>('personal')

return (
Expand Down Expand Up @@ -49,3 +49,5 @@ export default function HomeClient() {
</div>
)
}

export default HomeClient
4 changes: 3 additions & 1 deletion app/about/AboutClient.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

export default function AboutClient() {
const AboutClient = () => {
return (
<main className="max-w-360 mx-auto px-3 md:px-14 pt-28 pb-20">
<div className="max-w-2xl mx-auto">
Expand Down Expand Up @@ -56,3 +56,5 @@ export default function AboutClient() {
</main>
)
}

export default AboutClient
4 changes: 3 additions & 1 deletion app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const metadata: Metadata = {
},
}

export default function AboutPage() {
const AboutPage = () => {
return <AboutClient />
}

export default AboutPage
577 changes: 44 additions & 533 deletions app/account/AccountClient.tsx

Large diffs are not rendered by default.

173 changes: 173 additions & 0 deletions app/account/NotificationsSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
'use client'

import { useState } from 'react'
import toast from 'react-hot-toast'
import { LoadingSpinner } from '@/components/LoadingSpinner'
import { useNotifications, type NotificationType } from '@/lib/useNotifications'

const NOTIFICATION_TYPE_CFG: Record<NotificationType, { label: string; color: string }> = {
feature: { label: '新功能', color: 'var(--olive-text)' },
bugfix: { label: '問題修正', color: 'var(--crimson)' },
announcement: { label: '公告', color: 'var(--tan-text)' },
}

const formatNotificationDate = (iso: string) => {
const d = new Date(iso)
return `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
}

interface NotificationsSectionProps {
isActive: boolean
}

const NotificationsSection = ({ isActive }: NotificationsSectionProps) => {
const {
notifications, isAdmin: isNotificationsAdmin, loading: notificationsLoading,
createNotification, creating: creatingNotif,
deleteNotification, deletingId: deletingNotifId,
} = useNotifications(isActive)

const [newNotifTitle, setNewNotifTitle] = useState('')
const [newNotifBody, setNewNotifBody] = useState('')
const [newNotifType, setNewNotifType] = useState<NotificationType>('announcement')

const handleCreateNotification = async () => {
if (creatingNotif) return
if (!newNotifTitle.trim() || !newNotifBody.trim()) {
toast.error('標題與內容為必填')
return
}
try {
await createNotification({ title: newNotifTitle.trim(), body: newNotifBody.trim(), type: newNotifType })
setNewNotifTitle('')
setNewNotifBody('')
setNewNotifType('announcement')
toast.success('已新增通知')
} catch (err) {
console.error('[account] handleCreateNotification error:', err)
toast.error(err instanceof Error ? err.message : '新增失敗')
}
}

const handleDeleteNotification = async (id: string) => {
if (deletingNotifId) return
try {
await deleteNotification(id)
} catch (err) {
console.error('[account] handleDeleteNotification error:', err)
toast.error('刪除失敗')
}
}

if (!isActive) return null

return (
<div className="px-5 py-8 md:px-10 md:py-10 max-w-3xl">
<header className="mb-8 pb-3 border-b border-(--ink)">
<h1 className="font-serif italic font-medium text-[clamp(24px,3vw,36px)] leading-none m-0 text-(--ink)">
通知
</h1>
</header>

{isNotificationsAdmin && (
<div className="border border-(--ink) px-5 py-4 mb-6 flex flex-col gap-3">
<div className="font-mono text-[11px] tracking-widest uppercase text-(--ink-soft)">
新增通知
</div>
<input
type="text"
placeholder="標題"
value={newNotifTitle}
onChange={e => setNewNotifTitle(e.target.value)}
disabled={creatingNotif}
className="font-mono text-base tracking-[0.04em] border border-(--ink) bg-(--paper) text-(--ink) px-3 py-1.5 outline-none placeholder:text-(--ink-soft) disabled:opacity-50"
/>
<textarea
placeholder="內容"
value={newNotifBody}
onChange={e => setNewNotifBody(e.target.value)}
disabled={creatingNotif}
rows={3}
className="font-mono text-[13px] leading-relaxed border border-(--ink) bg-(--paper) text-(--ink) px-3 py-1.5 outline-none placeholder:text-(--ink-soft) disabled:opacity-50 resize-y"
/>
<div className="flex items-center gap-3 flex-wrap">
<select
value={newNotifType}
onChange={e => setNewNotifType(e.target.value as NotificationType)}
disabled={creatingNotif}
className="font-mono text-[12px] tracking-widest uppercase border border-(--ink) bg-(--paper) text-(--ink) px-2 py-1.5 outline-none disabled:opacity-50"
>
{(Object.keys(NOTIFICATION_TYPE_CFG) as NotificationType[]).map(t => (
<option key={t} value={t}>{NOTIFICATION_TYPE_CFG[t].label}</option>
))}
</select>
<button
onClick={handleCreateNotification}
disabled={creatingNotif}
className="font-mono text-[12px] md:text-base tracking-widest uppercase text-(--paper) bg-(--ink) border border-(--ink) px-4 py-1.5 cursor-pointer transition-colors duration-120 hover:bg-transparent hover:text-(--ink) disabled:opacity-50 disabled:cursor-not-allowed"
>
{creatingNotif ? '新增中…' : '發布通知'}
</button>
</div>
</div>
)}

{notificationsLoading && (
<div className="flex items-center justify-center py-10">
<LoadingSpinner />
</div>
)}

{!notificationsLoading && notifications.length === 0 && (
<div className="border border-dashed border-(--ink) py-10 px-6 text-center max-w-md">
<div className="font-mono text-[12px] md:text-base tracking-[0.12em] uppercase text-(--ink-soft)">
目前沒有通知
</div>
</div>
)}

{!notificationsLoading && notifications.length > 0 && (
<div className="flex flex-col gap-4">
{notifications.map(n => {
const cfg = NOTIFICATION_TYPE_CFG[n.type] ?? NOTIFICATION_TYPE_CFG.announcement
return (
<div key={n.id} className="border border-(--ink) px-5 py-4">
<div className="flex items-center justify-between mb-2">
<span
className="font-mono text-[11px] tracking-widest uppercase px-2 py-0.5 border"
style={{ color: cfg.color, borderColor: cfg.color }}
>
{cfg.label}
</span>
<div className="flex items-center gap-3">
<span className="font-mono text-[11px] text-(--ink-soft)">
{formatNotificationDate(n.publishedAt)}
</span>
{isNotificationsAdmin && (
<button
onClick={() => handleDeleteNotification(n.id)}
disabled={deletingNotifId === n.id}
className="font-mono text-[12px] text-(--ink-soft) hover:text-(--crimson) cursor-pointer transition-colors duration-120 disabled:opacity-40 disabled:cursor-not-allowed"
title="刪除通知"
>
{deletingNotifId === n.id ? '…' : '✕'}
</button>
)}
</div>
</div>
<div className="font-serif text-[17px] font-medium text-(--ink) mb-1">
{n.title}
</div>
<div className="font-mono text-[13px] leading-relaxed text-(--ink-soft) whitespace-pre-wrap">
{n.body}
</div>
</div>
)
})}
</div>
)}
</div>
)
}

export default NotificationsSection
Loading