Redesign frontend UI with premium SaaS styling and app icon
- Restyle design system: refined color palette, 16px base font, antialiased text rendering, improved typography hierarchy across all pages - Update base components (button, input, card, checkbox, dialog, sidebar) with modern rounded corners, subtle shadows, and smooth transitions - Redesign layout: remove header bar, move controls to sidebar footer, add two-column todo dashboard with stats and upcoming reminders - Replace hardcoded slate colors with design token system throughout - Add app icon (favicon, apple-icon, sidebar logo) from notify_icon.png - Improve typography: page titles 20px, section titles 18px, sidebar nav 14px, stats 24px semibold, body text with proper line-height Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BIN
frontend/src/app/apple-icon.png
Normal file
BIN
frontend/src/app/apple-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/src/app/favicon.ico
Normal file
BIN
frontend/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -6,7 +6,7 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 220 20% 97%;
|
||||
--background: 220 14% 96%;
|
||||
--foreground: 220 20% 10%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 20% 10%;
|
||||
@@ -17,27 +17,27 @@
|
||||
--secondary: 220 15% 94%;
|
||||
--secondary-foreground: 220 20% 20%;
|
||||
--muted: 220 15% 94%;
|
||||
--muted-foreground: 220 10% 50%;
|
||||
--muted-foreground: 220 10% 46%;
|
||||
--accent: 220 15% 94%;
|
||||
--accent-foreground: 220 20% 20%;
|
||||
--destructive: 0 72% 55%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 15% 92%;
|
||||
--input: 220 15% 90%;
|
||||
--border: 220 15% 90%;
|
||||
--input: 220 15% 88%;
|
||||
--ring: 220 80% 55%;
|
||||
--radius: 0.5rem;
|
||||
--radius: 0.75rem;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 220 20% 20%;
|
||||
--sidebar-primary: 220 80% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 220 15% 96%;
|
||||
--sidebar-accent-foreground: 220 20% 20%;
|
||||
--sidebar-border: 220 15% 94%;
|
||||
--sidebar-border: 220 15% 93%;
|
||||
--sidebar-ring: 220 80% 55%;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -47,6 +47,9 @@
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,38 +95,38 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--radius: 0.75rem;
|
||||
--background: hsl(220 14% 96%);
|
||||
--foreground: hsl(220 20% 10%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(220 20% 10%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(220 20% 10%);
|
||||
--primary: hsl(220 80% 55%);
|
||||
--primary-foreground: hsl(0 0% 100%);
|
||||
--secondary: hsl(220 15% 94%);
|
||||
--secondary-foreground: hsl(220 20% 20%);
|
||||
--muted: hsl(220 15% 94%);
|
||||
--muted-foreground: hsl(220 10% 46%);
|
||||
--accent: hsl(220 15% 94%);
|
||||
--accent-foreground: hsl(220 20% 20%);
|
||||
--destructive: hsl(0 72% 55%);
|
||||
--border: hsl(220 15% 90%);
|
||||
--input: hsl(220 15% 88%);
|
||||
--ring: hsl(220 80% 55%);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
--sidebar: hsl(0 0% 100%);
|
||||
--sidebar-foreground: hsl(220 20% 20%);
|
||||
--sidebar-primary: hsl(220 80% 55%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||
--sidebar-accent: hsl(220 15% 96%);
|
||||
--sidebar-accent-foreground: hsl(220 20% 20%);
|
||||
--sidebar-border: hsl(220 15% 93%);
|
||||
--sidebar-ring: hsl(220 80% 55%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
||||
BIN
frontend/src/app/icon.png
Normal file
BIN
frontend/src/app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 304 KiB |
@@ -115,7 +115,7 @@ const InvitesPage = () => {
|
||||
return { key: "statusRevoked", color: "bg-red-100 text-red-700" };
|
||||
}
|
||||
if (new Date(invite.expires_at) < new Date()) {
|
||||
return { key: "statusExpired", color: "bg-slate-100 text-slate-700" };
|
||||
return { key: "statusExpired", color: "bg-muted text-muted-foreground" };
|
||||
}
|
||||
if (invite.used_count >= invite.max_uses) {
|
||||
return { key: "statusExhausted", color: "bg-amber-100 text-amber-700" };
|
||||
@@ -125,8 +125,12 @@ const InvitesPage = () => {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{t("navInvites")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("createInviteDesc")}</p>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="bg-white">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("createInvite")}</CardTitle>
|
||||
<CardDescription>{t("createInviteDesc")}</CardDescription>
|
||||
@@ -160,7 +164,7 @@ const InvitesPage = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-white">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("myInvites")}</CardTitle>
|
||||
<CardDescription>{t("myInvitesDesc")}</CardDescription>
|
||||
@@ -173,17 +177,17 @@ const InvitesPage = () => {
|
||||
return (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="flex items-center justify-between rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
||||
className="flex items-center justify-between rounded-lg bg-muted/50 px-4 py-3 transition-colors hover:bg-muted"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-slate-200 px-2 py-0.5 text-sm font-semibold text-slate-800">
|
||||
<code className="rounded bg-muted px-2 py-0.5 text-sm font-semibold text-foreground">
|
||||
{invite.code}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyCode(invite.code, invite.id)}
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground/80"
|
||||
title={t("copyCode")}
|
||||
>
|
||||
{copiedId === invite.id ? (
|
||||
@@ -196,7 +200,7 @@ const InvitesPage = () => {
|
||||
{t(status.key as keyof ReturnType<typeof useTranslation>)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-slate-500">
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
{invite.used_count}/{invite.max_uses}
|
||||
@@ -209,7 +213,7 @@ const InvitesPage = () => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => viewDetails(invite.id)}
|
||||
className="text-slate-400 hover:text-blue-500"
|
||||
className="text-muted-foreground hover:text-blue-500"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -219,7 +223,7 @@ const InvitesPage = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
{t("revoke")}
|
||||
</Button>
|
||||
@@ -248,7 +252,7 @@ const InvitesPage = () => {
|
||||
);
|
||||
})}
|
||||
{invites.length === 0 && (
|
||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
||||
<div className="rounded-lg bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||
{t("noInvites")}
|
||||
</div>
|
||||
)}
|
||||
@@ -262,7 +266,7 @@ const InvitesPage = () => {
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<code className="rounded bg-slate-200 px-2 py-0.5 text-base">
|
||||
<code className="rounded bg-muted px-2 py-0.5 text-base">
|
||||
{selectedInvite?.code}
|
||||
</code>
|
||||
</DialogTitle>
|
||||
@@ -275,19 +279,19 @@ const InvitesPage = () => {
|
||||
selectedInvite.registeredUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-lg bg-slate-50 p-3"
|
||||
className="flex items-center gap-3 rounded-lg bg-muted/50 p-3"
|
||||
>
|
||||
<Avatar username={user.username} src={user.avatar} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-slate-800">{user.username}</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<div className="font-medium text-foreground">{user.username}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(user.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-lg bg-slate-50 p-6 text-center text-sm text-slate-400">
|
||||
<div className="rounded-lg bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||
{t("noRegisteredUsers")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,10 @@ import { I18nProvider } from "@/lib/i18n";
|
||||
export const metadata = {
|
||||
title: "Notify",
|
||||
description: "简洁提醒应用",
|
||||
icons: {
|
||||
icon: "/icon.png",
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
};
|
||||
|
||||
const RootLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
@@ -31,11 +31,11 @@ const LoginPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100 px-4 py-10">
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-muted via-background to-muted/80 px-4 py-10">
|
||||
<div className="absolute right-4 top-4">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card className="w-full max-w-md border-slate-200/80 shadow-lg">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{t("login")}</CardTitle>
|
||||
<CardDescription>{t("loginWelcome")}</CardDescription>
|
||||
@@ -66,7 +66,7 @@ const LoginPage = () => {
|
||||
{t("login")}
|
||||
</Button>
|
||||
<Link
|
||||
className="block text-center text-sm text-slate-500 transition hover:text-slate-900"
|
||||
className="block text-center text-sm text-muted-foreground transition hover:text-foreground"
|
||||
href="/register"
|
||||
>
|
||||
{t("noAccount")}
|
||||
|
||||
@@ -46,10 +46,10 @@ const NotificationsPage = () => {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Card className="bg-white">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>{t("notifications")}</CardTitle>
|
||||
<CardTitle className="text-xl">{t("notifications")}</CardTitle>
|
||||
<CardDescription>{t("notificationsDesc")}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -66,13 +66,13 @@ const NotificationsPage = () => {
|
||||
{notifications.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 transition-colors hover:bg-muted"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-slate-800">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("triggerTime")}:{new Date(item.triggerAt).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{t("channel")}:{item.channel}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("channel")}:{item.channel}</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => markRead(item.id)}>
|
||||
{t("markRead")}
|
||||
@@ -80,7 +80,7 @@ const NotificationsPage = () => {
|
||||
</div>
|
||||
))}
|
||||
{notifications.length === 0 && (
|
||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
||||
<div className="rounded-xl bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||
{t("noNotification")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,11 +32,11 @@ const RegisterPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100 px-4 py-10">
|
||||
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-muted via-background to-muted/80 px-4 py-10">
|
||||
<div className="absolute right-4 top-4">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
<Card className="w-full max-w-md border-slate-200/80 shadow-lg">
|
||||
<Card className="w-full max-w-md shadow-lg">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">{t("registerTitle")}</CardTitle>
|
||||
<CardDescription>{t("registerDesc")}</CardDescription>
|
||||
@@ -76,7 +76,7 @@ const RegisterPage = () => {
|
||||
{t("register")}
|
||||
</Button>
|
||||
<Link
|
||||
className="block text-center text-sm text-slate-500 transition hover:text-slate-900"
|
||||
className="block text-center text-sm text-muted-foreground transition hover:text-foreground"
|
||||
href="/login"
|
||||
>
|
||||
{t("hasAccount")}
|
||||
|
||||
@@ -160,8 +160,12 @@ const RemindersPage = () => {
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">{t("navReminder")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("createReminderDesc")}</p>
|
||||
</div>
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card className="bg-white">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("createReminder")}</CardTitle>
|
||||
<CardDescription>{t("createReminderDesc")}</CardDescription>
|
||||
@@ -279,14 +283,14 @@ const RemindersPage = () => {
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2 py-1 text-sm text-slate-700"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-sm text-foreground/80"
|
||||
>
|
||||
<Avatar username={user.username} src={user.avatar} size="sm" className="h-5 w-5 text-xs" />
|
||||
{user.username}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRecipient(id)}
|
||||
className="rounded-full p-0.5 hover:bg-slate-200"
|
||||
className="rounded-full p-0.5 hover:bg-muted/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
@@ -307,8 +311,8 @@ const RemindersPage = () => {
|
||||
<Label htmlFor="showBarkSettings">{t("barkSettings")}</Label>
|
||||
</div>
|
||||
{showBarkSettings && (
|
||||
<div className="ml-6 space-y-4 rounded-lg border border-slate-200 bg-slate-50/50 p-4">
|
||||
<p className="text-sm text-slate-500">{t("barkSettingsDesc")}</p>
|
||||
<div className="ml-6 space-y-4 rounded-xl border border-border/60 bg-muted/30 p-4">
|
||||
<p className="text-sm text-muted-foreground">{t("barkSettingsDesc")}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="barkTitle">{t("barkTitle")}</Label>
|
||||
@@ -393,7 +397,7 @@ const RemindersPage = () => {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-white">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("reminderList")}</CardTitle>
|
||||
<CardDescription>{t("reminderListDesc")}</CardDescription>
|
||||
@@ -403,29 +407,29 @@ const RemindersPage = () => {
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="flex items-center justify-between rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
||||
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 transition-colors hover:bg-muted"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{task.title}</span>
|
||||
<span className="text-sm font-semibold text-foreground">{task.title}</span>
|
||||
{task.recurrenceRule && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
{getRecurrenceLabel(task.recurrenceRule)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(task.dueAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-slate-500">{t("reminder")}</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">{t("reminder")}</span>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
className="text-muted-foreground hover:text-red-500"
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
@@ -452,7 +456,7 @@ const RemindersPage = () => {
|
||||
</div>
|
||||
))}
|
||||
{tasks.length === 0 && (
|
||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
||||
<div className="rounded-xl bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||
{t("noReminder")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { RecurrencePicker, type RecurrenceRule as RecurrenceRuleInput } from "@/components/ui/recurrence-picker";
|
||||
import { api } from "@/lib/api";
|
||||
import { useTranslation } from "@/lib/i18n";
|
||||
import { Bell, CalendarCheck, CheckCircle2, ListTodo, Plus, Repeat, Trash2 } from "lucide-react";
|
||||
|
||||
type RecurrenceType = "hourly" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
|
||||
@@ -54,9 +55,16 @@ type Todo = {
|
||||
checkInAt?: string | null;
|
||||
};
|
||||
|
||||
type ReminderTask = {
|
||||
id: string;
|
||||
title: string;
|
||||
dueAt: string;
|
||||
};
|
||||
|
||||
const TodosPage = () => {
|
||||
const t = useTranslation();
|
||||
const [todos, setTodos] = useState<Todo[]>([]);
|
||||
const [reminders, setReminders] = useState<ReminderTask[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [dueAt, setDueAt] = useState<Date | undefined>(undefined);
|
||||
const [offsetMinutes, setOffsetMinutes] = useState(10);
|
||||
@@ -70,8 +78,14 @@ const TodosPage = () => {
|
||||
setTodos(data);
|
||||
};
|
||||
|
||||
const loadReminders = async () => {
|
||||
const data = (await api.getReminderTasks()) as ReminderTask[];
|
||||
setReminders(data);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadTodos().catch(() => null);
|
||||
loadReminders().catch(() => null);
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -118,8 +132,6 @@ const TodosPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const colorDots = ["bg-emerald-500", "bg-amber-500", "bg-blue-500", "bg-rose-500"];
|
||||
|
||||
const getRecurrenceLabel = (rule: RecurrenceRule): string => {
|
||||
const interval = rule.interval || 1;
|
||||
if (rule.type === "weekly" && interval === 2) {
|
||||
@@ -134,166 +146,307 @@ const TodosPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDueDate = (dateStr: string): string => {
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const isToday = d.toDateString() === now.toDateString();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow = d.toDateString() === tomorrow.toDateString();
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${timeStr}`;
|
||||
};
|
||||
|
||||
const getDueStatus = (dateStr: string, isCheckedIn: boolean): { label: string; className: string } => {
|
||||
if (isCheckedIn) {
|
||||
return { label: t("checkInSuccess"), className: "bg-emerald-50 text-emerald-700 border-emerald-200" };
|
||||
}
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
if (d < now) {
|
||||
return { label: "Overdue", className: "bg-red-50 text-red-700 border-red-200" };
|
||||
}
|
||||
const hoursUntil = (d.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||
if (hoursUntil <= 24) {
|
||||
return { label: "Due Soon", className: "bg-amber-50 text-amber-700 border-amber-200" };
|
||||
}
|
||||
return { label: "Upcoming", className: "bg-blue-50 text-blue-700 border-blue-200" };
|
||||
};
|
||||
|
||||
// Stats
|
||||
const totalTasks = todos.length;
|
||||
const checkedInCount = todos.filter((t) => t.isCheckedIn).length;
|
||||
const recurringCount = todos.filter((t) => t.recurrenceRule).length;
|
||||
|
||||
// Upcoming reminders (next 3, sorted by dueAt)
|
||||
const upcomingReminders = reminders
|
||||
.filter((r) => new Date(r.dueAt) >= new Date())
|
||||
.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
|
||||
.slice(0, 3);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<Card className="bg-white">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle>{t("myTodoList")}</CardTitle>
|
||||
<CardDescription>{t("myTodoListDesc")}</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("addTask")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[90vw] max-w-[90vw] sm:w-[40vw] sm:max-w-[40vw]">
|
||||
<form onSubmit={createTodo}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createTask")}</DialogTitle>
|
||||
<DialogDescription>{t("createTaskDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{t("title")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={t("enterTodoTitle")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueAt">{t("dueTime")}</Label>
|
||||
<DateTimePicker
|
||||
value={dueAt}
|
||||
onChange={setDueAt}
|
||||
placeholder="选择截止日期和时间"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offset">{t("advanceReminder")}</Label>
|
||||
<Input
|
||||
id="offset"
|
||||
type="number"
|
||||
min={0}
|
||||
value={offsetMinutes}
|
||||
onChange={(event) => setOffsetMinutes(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="recurring"
|
||||
checked={isRecurring}
|
||||
onCheckedChange={(checked) => setIsRecurring(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="recurring">{t("setRecurring")}</Label>
|
||||
</div>
|
||||
{isRecurring && (
|
||||
<div className="mt-3">
|
||||
<RecurrencePicker
|
||||
value={recurrenceRule}
|
||||
onChange={setRecurrenceRule}
|
||||
dueDate={dueAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!title || !dueAt}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-3">
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="flex items-center gap-3 rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
||||
>
|
||||
<span
|
||||
className={`h-3 w-3 rounded-sm ${colorDots[index % colorDots.length]}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-slate-800">{todo.title}</span>
|
||||
{todo.recurrenceRule && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
{getRecurrenceLabel(todo.recurrenceRule)}
|
||||
</span>
|
||||
)}
|
||||
{todo.checkInCount > 0 && (
|
||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{todo.checkInCount}x
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{t("due")} {new Date(todo.dueAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
todo.isCheckedIn
|
||||
? "bg-emerald-50 text-emerald-600"
|
||||
: "text-emerald-600 hover:bg-emerald-50 hover:text-emerald-700"
|
||||
}
|
||||
onClick={() => checkInTodo(todo.id)}
|
||||
disabled={checkingIn === todo.id || todo.isCheckedIn}
|
||||
>
|
||||
{checkingIn === todo.id
|
||||
? "..."
|
||||
: todo.isCheckedIn
|
||||
? t("checkInSuccess")
|
||||
: t("checkIn")}
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_280px]">
|
||||
{/* Left column - Todo list */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-xl">{t("myTodoList")}</CardTitle>
|
||||
<CardDescription>{t("myTodoListDesc")}</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button">
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("addTask")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
>
|
||||
{t("delete")}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-[90vw] max-w-[90vw] sm:w-[40vw] sm:max-w-[40vw]">
|
||||
<form onSubmit={createTodo}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("createTask")}</DialogTitle>
|
||||
<DialogDescription>{t("createTaskDesc")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">{t("title")}</Label>
|
||||
<Input
|
||||
id="title"
|
||||
placeholder={t("enterTodoTitle")}
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dueAt">{t("dueTime")}</Label>
|
||||
<DateTimePicker
|
||||
value={dueAt}
|
||||
onChange={setDueAt}
|
||||
placeholder="选择截止日期和时间"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="offset">{t("advanceReminder")}</Label>
|
||||
<Input
|
||||
id="offset"
|
||||
type="number"
|
||||
min={0}
|
||||
value={offsetMinutes}
|
||||
onChange={(event) => setOffsetMinutes(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="recurring"
|
||||
checked={isRecurring}
|
||||
onCheckedChange={(checked) => setIsRecurring(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="recurring">{t("setRecurring")}</Label>
|
||||
</div>
|
||||
{isRecurring && (
|
||||
<div className="mt-3">
|
||||
<RecurrencePicker
|
||||
value={recurrenceRule}
|
||||
onChange={setRecurrenceRule}
|
||||
dueDate={dueAt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!title || !dueAt}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("confirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirmDeleteDesc", { title: todo.title })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={() => deleteTodo(todo.id)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{todos.length > 0 ? (
|
||||
<div className="divide-y divide-border/50">
|
||||
{/* Table header */}
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 pb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<div className="w-5" />
|
||||
<div>{t("title")}</div>
|
||||
<div>{t("due")}</div>
|
||||
<div>{t("status")}</div>
|
||||
<div className="w-16" />
|
||||
</div>
|
||||
{todos.map((todo) => {
|
||||
const status = getDueStatus(todo.dueAt, todo.isCheckedIn);
|
||||
return (
|
||||
<div
|
||||
key={todo.id}
|
||||
className="group grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 py-3 transition-colors hover:bg-muted/40 rounded-lg"
|
||||
>
|
||||
{/* Checkbox / Check-in */}
|
||||
<button
|
||||
type="button"
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||
todo.isCheckedIn
|
||||
? "border-emerald-500 bg-emerald-500 text-white"
|
||||
: "border-border hover:border-emerald-400"
|
||||
}`}
|
||||
onClick={() => checkInTodo(todo.id)}
|
||||
disabled={checkingIn === todo.id || todo.isCheckedIn}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{todo.isCheckedIn && <CheckCircle2 className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
{/* Title + badges */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`truncate text-sm font-medium ${todo.isCheckedIn ? "text-muted-foreground line-through" : "text-foreground"}`}>
|
||||
{todo.title}
|
||||
</span>
|
||||
{todo.recurrenceRule && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||
<Repeat className="h-3 w-3" />
|
||||
{getRecurrenceLabel(todo.recurrenceRule)}
|
||||
</span>
|
||||
)}
|
||||
{todo.checkInCount > 0 && (
|
||||
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||
{todo.checkInCount}x
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due date */}
|
||||
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{formatDueDate(todo.dueAt)}
|
||||
</div>
|
||||
|
||||
{/* Status pill */}
|
||||
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap ${status.className}`}>
|
||||
{status.label}
|
||||
</span>
|
||||
|
||||
{/* Delete */}
|
||||
<div className="flex justify-end w-16">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("confirmDelete")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("confirmDeleteDesc", { title: todo.title })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-500 hover:bg-red-600"
|
||||
onClick={() => deleteTodo(todo.id)}
|
||||
>
|
||||
{t("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{todos.length === 0 && (
|
||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
||||
{t("noTodo")}
|
||||
) : (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<ListTodo className="h-7 w-7" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-foreground">{t("todoEmptyTitle")}</h3>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{t("todoEmptyDesc")}</p>
|
||||
<Button className="mt-5" onClick={() => setIsDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("addTask")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right column - Stats & Upcoming */}
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Stats cards */}
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<ListTodo className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{totalTasks}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("totalTasks")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600">
|
||||
<CalendarCheck className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{checkedInCount}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("checkedInToday")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600">
|
||||
<Repeat className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold text-foreground">{recurringCount}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("recurringTasks")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming reminders */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-3">
|
||||
<CardTitle className="text-[15px] font-semibold">{t("upcomingReminders")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
{upcomingReminders.length > 0 ? (
|
||||
<div className="space-y-2.5">
|
||||
{upcomingReminders.map((reminder) => (
|
||||
<div key={reminder.id} className="flex items-start gap-2.5">
|
||||
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-600">
|
||||
<Bell className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-foreground">{reminder.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatDueDate(reminder.dueAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">{t("noUpcomingReminders")}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user