first commit

This commit is contained in:
Michael Dong
2026-02-05 11:24:40 +08:00
commit a98e12f286
144 changed files with 26459 additions and 0 deletions

View File

@@ -0,0 +1,182 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@layer base {
:root {
--background: 220 20% 97%;
--foreground: 220 20% 10%;
--card: 0 0% 100%;
--card-foreground: 220 20% 10%;
--popover: 0 0% 100%;
--popover-foreground: 220 20% 10%;
--primary: 220 80% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 220 15% 94%;
--secondary-foreground: 220 20% 20%;
--muted: 220 15% 94%;
--muted-foreground: 220 10% 50%;
--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%;
--ring: 220 80% 55%;
--radius: 0.5rem;
--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-ring: 220 80% 55%;
}
html {
font-size: 28px;
}
* {
border-color: hsl(var(--border) / 0.6);
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
: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);
--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);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@layer utilities {
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
}

View File

@@ -0,0 +1,301 @@
"use client";
import { useEffect, useState } from "react";
import AppShell from "@/components/AppShell";
import Avatar from "@/components/ui/avatar";
import { Check, Copy, Eye, Users } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
type RegisteredUser = {
id: string;
username: string;
avatar?: string | null;
createdAt: string;
};
type Invite = {
id: string;
code: string;
creator_id: string;
max_uses: number;
used_count: number;
expires_at: string;
revoked_at: string | null;
created_at: string;
};
type InviteWithUsers = {
id: string;
code: string;
creatorId: string;
maxUses: number;
usedCount: number;
expiresAt: string;
revokedAt: string | null;
createdAt: string;
registeredUsers: RegisteredUser[];
};
const InvitesPage = () => {
const t = useTranslation();
const [invites, setInvites] = useState<Invite[]>([]);
const [maxUses, setMaxUses] = useState(5);
const [expiresInDays, setExpiresInDays] = useState(7);
const [selectedInvite, setSelectedInvite] = useState<InviteWithUsers | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const load = async () => {
const data = await api.getInvites();
setInvites(data as Invite[]);
};
useEffect(() => {
load().catch(() => null);
}, []);
const createInvite = async (event: React.FormEvent) => {
event.preventDefault();
await api.createInvite({ maxUses, expiresInDays });
setMaxUses(5);
setExpiresInDays(7);
await load();
};
const revokeInvite = async (id: string) => {
await api.revokeInvite(id);
await load();
};
const viewDetails = async (id: string) => {
const data = await api.getInvite(id);
setSelectedInvite(data as InviteWithUsers);
setDetailsOpen(true);
};
const copyCode = async (code: string, id: string) => {
await navigator.clipboard.writeText(code);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
};
const formatDateTime = (dateStr: string): string => {
const d = new Date(dateStr);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const getInviteStatus = (invite: Invite): { key: string; color: string } => {
if (invite.revoked_at) {
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" };
}
if (invite.used_count >= invite.max_uses) {
return { key: "statusExhausted", color: "bg-amber-100 text-amber-700" };
}
return { key: "statusActive", color: "bg-green-100 text-green-700" };
};
return (
<AppShell>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="bg-white">
<CardHeader>
<CardTitle>{t("createInvite")}</CardTitle>
<CardDescription>{t("createInviteDesc")}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={createInvite}>
<div className="space-y-2">
<Label htmlFor="maxUses">{t("maxUses")}</Label>
<Input
id="maxUses"
type="number"
min={1}
max={20}
value={maxUses}
onChange={(event) => setMaxUses(Number(event.target.value))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="expiresInDays">{t("expiresInDays")}</Label>
<Input
id="expiresInDays"
type="number"
min={1}
max={30}
value={expiresInDays}
onChange={(event) => setExpiresInDays(Number(event.target.value))}
/>
</div>
<Button type="submit">{t("generateInvite")}</Button>
</form>
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<CardTitle>{t("myInvites")}</CardTitle>
<CardDescription>{t("myInvitesDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{invites.map((invite) => {
const status = getInviteStatus(invite);
const isActive = status.key === "statusActive";
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"
>
<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">
{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"
title={t("copyCode")}
>
{copiedId === invite.id ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${status.color}`}>
{t(status.key as keyof ReturnType<typeof useTranslation>)}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{invite.used_count}/{invite.max_uses}
</span>
<span>{t("expiresAt")}: {formatDateTime(invite.expires_at)}</span>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => viewDetails(invite.id)}
className="text-slate-400 hover:text-blue-500"
>
<Eye className="h-4 w-4" />
</Button>
{isActive && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-red-500"
>
{t("revoke")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("revokeInvite")}</AlertDialogTitle>
<AlertDialogDescription>
{t("revokeInviteDesc", { code: invite.code })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-500 hover:bg-red-600"
onClick={() => revokeInvite(invite.id)}
>
{t("revoke")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
);
})}
{invites.length === 0 && (
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
{t("noInvites")}
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Details Dialog */}
<Dialog open={detailsOpen} onOpenChange={setDetailsOpen}>
<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">
{selectedInvite?.code}
</code>
</DialogTitle>
<DialogDescription>
{t("registeredUsers")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{selectedInvite?.registeredUsers && selectedInvite.registeredUsers.length > 0 ? (
selectedInvite.registeredUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-3 rounded-lg bg-slate-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">
{formatDateTime(user.createdAt)}
</div>
</div>
</div>
))
) : (
<div className="rounded-lg bg-slate-50 p-6 text-center text-sm text-slate-400">
{t("noRegisteredUsers")}
</div>
)}
</div>
</DialogContent>
</Dialog>
</AppShell>
);
};
export default InvitesPage;

View File

@@ -0,0 +1,20 @@
import "./globals.css";
import { I18nProvider } from "@/lib/i18n";
export const metadata = {
title: "Notify",
description: "简洁提醒应用",
};
const RootLayout = ({ children }: { children: React.ReactNode }) => {
return (
<html lang="zh" suppressHydrationWarning>
<body className="min-h-screen bg-background font-sans antialiased">
<I18nProvider>{children}</I18nProvider>
</body>
</html>
);
};
export default RootLayout;

View File

@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { setToken } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
const LoginPage = () => {
const t = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError("");
try {
const result = await api.login({ username, password });
setToken(result.token);
window.location.href = "/todos";
} catch (err) {
setError(t("loginFailed"));
}
};
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="absolute right-4 top-4">
<LanguageSwitcher />
</div>
<Card className="w-full max-w-md border-slate-200/80 shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">{t("login")}</CardTitle>
<CardDescription>{t("loginWelcome")}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
placeholder={t("enterUsername")}
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button className="w-full" type="submit">
{t("login")}
</Button>
<Link
className="block text-center text-sm text-slate-500 transition hover:text-slate-900"
href="/register"
>
{t("noAccount")}
</Link>
</form>
</CardContent>
</Card>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,94 @@
"use client";
import { useEffect, useState } from "react";
import AppShell from "@/components/AppShell";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
import { useNotification } from "@/lib/notification-context";
type Notification = {
id: string;
triggerAt: string;
status: string;
channel: string;
readAt?: string | null;
};
const NotificationsPage = () => {
const t = useTranslation();
const [notifications, setNotifications] = useState<Notification[]>([]);
const { refreshUnreadCount } = useNotification();
const load = async () => {
const data = (await api.getNotifications()) as Notification[];
setNotifications(data);
};
useEffect(() => {
load().catch(() => null);
}, []);
const markRead = async (id: string) => {
await api.markNotificationRead(id);
await load();
await refreshUnreadCount();
};
const markAllRead = async () => {
await api.markAllNotificationsRead();
await load();
await refreshUnreadCount();
};
return (
<AppShell>
<Card className="bg-white">
<CardHeader className="flex-row items-center justify-between space-y-0">
<div>
<CardTitle>{t("notifications")}</CardTitle>
<CardDescription>{t("notificationsDesc")}</CardDescription>
</div>
<div className="flex items-center gap-2">
<Input className="max-w-xs" placeholder={t("searchNotifications")} />
{notifications.length > 0 && (
<Button variant="outline" size="sm" onClick={markAllRead}>
{t("markAllRead")}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{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"
>
<div>
<div className="text-sm font-semibold text-slate-800">
{t("triggerTime")}{new Date(item.triggerAt).toLocaleString()}
</div>
<div className="text-xs text-slate-500">{t("channel")}{item.channel}</div>
</div>
<Button variant="outline" size="sm" onClick={() => markRead(item.id)}>
{t("markRead")}
</Button>
</div>
))}
{notifications.length === 0 && (
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
{t("noNotification")}
</div>
)}
</div>
</CardContent>
</Card>
</AppShell>
);
};
export default NotificationsPage;

23
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { getToken } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
const HomePage = () => {
const t = useTranslation();
useEffect(() => {
const token = getToken();
window.location.href = token ? "/todos" : "/login";
}, []);
return (
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
{t("loading")}
</div>
);
};
export default HomePage;

View File

@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import { setToken } from "@/lib/auth";
import { useTranslation } from "@/lib/i18n";
const RegisterPage = () => {
const t = useTranslation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [inviteCode, setInviteCode] = useState("");
const [error, setError] = useState("");
const onSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError("");
try {
const result = await api.register({ username, password, inviteCode });
setToken(result.token);
window.location.href = "/todos";
} catch (err) {
setError(t("registerFailed"));
}
};
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="absolute right-4 top-4">
<LanguageSwitcher />
</div>
<Card className="w-full max-w-md border-slate-200/80 shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">{t("registerTitle")}</CardTitle>
<CardDescription>{t("registerDesc")}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={onSubmit}>
<div className="space-y-2">
<Label htmlFor="invite">{t("inviteCode")}</Label>
<Input
id="invite"
placeholder={t("enterInviteCode")}
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="username">{t("username")}</Label>
<Input
id="username"
placeholder={t("enterUsername")}
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
type="password"
placeholder={t("enterPassword")}
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button className="w-full" type="submit">
{t("register")}
</Button>
<Link
className="block text-center text-sm text-slate-500 transition hover:text-slate-900"
href="/login"
>
{t("hasAccount")}
</Link>
</form>
</CardContent>
</Card>
</div>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,467 @@
"use client";
import { useEffect, useState } from "react";
import AppShell from "@/components/AppShell";
import Avatar from "@/components/ui/avatar";
import { X } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/ui/datetime-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RecurrencePicker, type RecurrenceRule as RecurrenceRuleInput } from "@/components/ui/recurrence-picker";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/lib/api";
import { useTranslation } from "@/lib/i18n";
type RecurrenceType = "hourly" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceRule = {
id: string;
type: RecurrenceType;
interval: number;
byWeekday?: number | null;
byMonthday?: number | null;
};
type User = { id: string; username: string; avatar?: string | null };
type Task = { id: string; title: string; dueAt: string; recurrenceRule?: RecurrenceRule | null };
const RemindersPage = () => {
const t = useTranslation();
const [tasks, setTasks] = useState<Task[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [dueAt, setDueAt] = useState<Date | undefined>(undefined);
const [offsetMinutes, setOffsetMinutes] = useState(0);
const [showAdvanceReminder, setShowAdvanceReminder] = useState(false);
const [recipientIds, setRecipientIds] = useState<string[]>([]);
const [isRecurring, setIsRecurring] = useState(false);
const [recurrenceRule, setRecurrenceRule] = useState<RecurrenceRuleInput>({ type: "daily", interval: 1 });
// Bark settings
const [showBarkSettings, setShowBarkSettings] = useState(false);
const [barkTitle, setBarkTitle] = useState("");
const [barkSubtitle, setBarkSubtitle] = useState("");
const [barkUseMarkdown, setBarkUseMarkdown] = useState(false);
const [barkMarkdownContent, setBarkMarkdownContent] = useState("");
const [barkLevel, setBarkLevel] = useState<string>("");
const [barkIcon, setBarkIcon] = useState("");
const load = async () => {
const [tasksData, usersData] = await Promise.all([api.getReminderTasks(), api.getUsers()]);
setTasks(tasksData as Task[]);
setUsers(usersData as User[]);
};
useEffect(() => {
load().catch(() => null);
}, []);
const addRecipient = (id: string) => {
setRecipientIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
};
const removeRecipient = (id: string) => {
setRecipientIds((prev) => prev.filter((item) => item !== id));
};
const createTask = async (event: React.FormEvent) => {
event.preventDefault();
if (!dueAt || recipientIds.length === 0) return;
await api.createReminderTask({
title,
content: barkUseMarkdown && barkMarkdownContent ? barkMarkdownContent : content,
dueAt: dueAt.toISOString(),
recipientIds,
offsets: [{
offsetMinutes,
channelInapp: true,
channelBark: true,
...(barkTitle && { barkTitle }),
...(barkSubtitle && { barkSubtitle }),
...(barkUseMarkdown && barkMarkdownContent && { barkBodyMarkdown: barkMarkdownContent }),
...(barkLevel && { barkLevel }),
...(barkIcon && { barkIcon }),
}],
...(isRecurring && {
recurrenceRule: {
type: recurrenceRule.type,
interval: recurrenceRule.interval,
by_weekday: recurrenceRule.byWeekday,
by_monthday: recurrenceRule.byMonthday,
},
}),
});
setTitle("");
setContent("");
setDueAt(undefined);
setOffsetMinutes(0);
setShowAdvanceReminder(false);
setRecipientIds([]);
setIsRecurring(false);
setRecurrenceRule({ type: "daily", interval: 1 });
// Reset Bark settings
setShowBarkSettings(false);
setBarkTitle("");
setBarkSubtitle("");
setBarkUseMarkdown(false);
setBarkMarkdownContent("");
setBarkLevel("");
setBarkIcon("");
await load();
};
const deleteTask = async (id: string) => {
await api.deleteReminderTask(id);
await load();
};
const formatDateTime = (dateStr: string): string => {
const d = new Date(dateStr);
const pad = (n: number) => n.toString().padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
};
const getRecurrenceLabel = (rule: RecurrenceRule): string => {
const interval = rule.interval || 1;
if (rule.type === "weekly" && interval === 2) {
return t("modeBiweekly");
}
switch (rule.type) {
case "daily": return t("modeDaily");
case "weekly": return t("modeWeekly");
case "monthly": return t("modeMonthly");
case "yearly": return t("modeYearly");
default: return rule.type;
}
};
return (
<AppShell>
<div className="grid gap-6 lg:grid-cols-2">
<Card className="bg-white">
<CardHeader>
<CardTitle>{t("createReminder")}</CardTitle>
<CardDescription>{t("createReminderDesc")}</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={createTask}>
<div className="space-y-2">
<Label htmlFor="title">{t("taskTitle")}</Label>
<Input
id="title"
placeholder={t("enterTaskTitle")}
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="content">{t("reminderContent")}</Label>
<textarea
id="content"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t("enterReminderContent")}
value={content}
onChange={(event) => setContent(event.target.value)}
disabled={barkUseMarkdown && !!barkMarkdownContent}
/>
{barkUseMarkdown && barkMarkdownContent && (
<p className="text-xs text-amber-600">{t("contentReplacedByMarkdown")}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dueAt">
{t("reminderTime")}
<span className="ml-1 text-red-500">*</span>
</Label>
<DateTimePicker
value={dueAt}
onChange={setDueAt}
placeholder={t("selectReminderTime")}
/>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="showAdvanceReminder"
checked={showAdvanceReminder}
onCheckedChange={(checked) => {
setShowAdvanceReminder(checked === true);
if (!checked) setOffsetMinutes(0);
}}
/>
<Label htmlFor="showAdvanceReminder">{t("enableAdvanceReminder")}</Label>
</div>
{showAdvanceReminder && (
<div className="ml-6 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>
<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("setRecurringReminder")}</Label>
</div>
{isRecurring && (
<div className="mt-3">
<RecurrencePicker
value={recurrenceRule}
onChange={setRecurrenceRule}
dueDate={dueAt}
/>
</div>
)}
</div>
<div className="space-y-3">
<Label>
{t("reminderFor")}
<span className="ml-1 text-red-500">*</span>
</Label>
<Select
value=""
onValueChange={(value) => addRecipient(value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={t("selectUser")} />
</SelectTrigger>
<SelectContent>
{users
.filter((user) => !recipientIds.includes(user.id))
.map((user) => (
<SelectItem key={user.id} value={user.id}>
<div className="flex items-center gap-2">
<Avatar username={user.username} src={user.avatar} size="sm" className="h-5 w-5 text-xs" />
<span>{user.username}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{recipientIds.length > 0 && (
<div className="flex flex-wrap gap-2">
{recipientIds.map((id) => {
const user = users.find((u) => u.id === id);
if (!user) return null;
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"
>
<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"
>
<X className="h-3 w-3" />
</button>
</span>
);
})}
</div>
)}
</div>
{/* Bark Settings */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="showBarkSettings"
checked={showBarkSettings}
onCheckedChange={(checked) => setShowBarkSettings(checked === true)}
/>
<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="space-y-2">
<Label htmlFor="barkTitle">{t("barkTitle")}</Label>
<Input
id="barkTitle"
placeholder={t("barkTitlePlaceholder")}
value={barkTitle}
onChange={(e) => setBarkTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="barkSubtitle">{t("barkSubtitle")}</Label>
<Input
id="barkSubtitle"
placeholder={t("barkSubtitlePlaceholder")}
value={barkSubtitle}
onChange={(e) => setBarkSubtitle(e.target.value)}
/>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="barkUseMarkdown"
checked={barkUseMarkdown}
onCheckedChange={(checked) => setBarkUseMarkdown(checked === true)}
/>
<Label htmlFor="barkUseMarkdown">{t("barkUseMarkdown")}</Label>
</div>
{barkUseMarkdown && (
<div className="space-y-2">
<p className="text-xs text-amber-600">{t("markdownWillReplaceContent")}</p>
<Label htmlFor="barkMarkdown">{t("barkMarkdownContent")}</Label>
<textarea
id="barkMarkdown"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t("barkMarkdownPlaceholder")}
value={barkMarkdownContent}
onChange={(e) => setBarkMarkdownContent(e.target.value)}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="barkLevel">{t("barkLevel")}</Label>
<Select value={barkLevel} onValueChange={setBarkLevel}>
<SelectTrigger>
<SelectValue placeholder={t("barkLevelActive")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{t("barkLevelActive")}</SelectItem>
<SelectItem value="timeSensitive">{t("barkLevelTimeSensitive")}</SelectItem>
<SelectItem value="passive">{t("barkLevelPassive")}</SelectItem>
<SelectItem value="critical">{t("barkLevelCritical")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="barkIcon">{t("barkIcon")}</Label>
<Input
id="barkIcon"
placeholder={t("barkIconPlaceholder")}
value={barkIcon}
onChange={(e) => setBarkIcon(e.target.value)}
/>
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button type="submit" disabled={!dueAt || recipientIds.length === 0}>
{t("save")}
</Button>
<Button type="button" variant="outline">
{t("cancel")}
</Button>
</div>
</form>
</CardContent>
</Card>
<Card className="bg-white">
<CardHeader>
<CardTitle>{t("reminderList")}</CardTitle>
<CardDescription>{t("reminderListDesc")}</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3">
{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"
>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-800">{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">
{formatDateTime(task.dueAt)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-slate-500">{t("reminder")}</span>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-red-500"
>
{t("delete")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("confirmDelete")}</AlertDialogTitle>
<AlertDialogDescription>
{t("confirmDeleteDesc", { title: task.title })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-red-500 hover:bg-red-600"
onClick={() => deleteTask(task.id)}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
))}
{tasks.length === 0 && (
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
{t("noReminder")}
</div>
)}
</div>
</CardContent>
</Card>
</div>
</AppShell>
);
};
export default RemindersPage;

View File

@@ -0,0 +1,14 @@
import AppShell from "@/components/AppShell";
import SettingsPanel from "@/components/SettingsPanel";
const SettingsPage = () => {
return (
<AppShell>
<div className="max-w-xl">
<SettingsPanel />
</div>
</AppShell>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,301 @@
"use client";
import { useEffect, useState } from "react";
import AppShell from "@/components/AppShell";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DateTimePicker } from "@/components/ui/datetime-picker";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
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";
type RecurrenceType = "hourly" | "daily" | "weekly" | "monthly" | "yearly";
type RecurrenceRule = {
id: string;
type: RecurrenceType;
interval: number;
byWeekday?: number | null;
byMonthday?: number | null;
};
type Todo = {
id: string;
title: string;
description?: string | null;
dueAt: string;
recurrenceRule?: RecurrenceRule | null;
isCheckedIn: boolean;
checkInCount: number;
checkInAt?: string | null;
};
const TodosPage = () => {
const t = useTranslation();
const [todos, setTodos] = useState<Todo[]>([]);
const [title, setTitle] = useState("");
const [dueAt, setDueAt] = useState<Date | undefined>(undefined);
const [offsetMinutes, setOffsetMinutes] = useState(10);
const [isRecurring, setIsRecurring] = useState(false);
const [recurrenceRule, setRecurrenceRule] = useState<RecurrenceRuleInput>({ type: "daily", interval: 1 });
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [checkingIn, setCheckingIn] = useState<string | null>(null);
const loadTodos = async () => {
const data = (await api.getTodos()) as Todo[];
setTodos(data);
};
useEffect(() => {
loadTodos().catch(() => null);
}, []);
const resetForm = () => {
setTitle("");
setDueAt(undefined);
setIsRecurring(false);
setRecurrenceRule({ type: "daily", interval: 1 });
setOffsetMinutes(10);
};
const createTodo = async (event: React.FormEvent) => {
event.preventDefault();
if (!dueAt) return;
await api.createTodo({
title,
dueAt: dueAt.toISOString(),
offsets: [{ offsetMinutes, channelInapp: true, channelBark: true }],
...(isRecurring && {
recurrenceRule: {
type: recurrenceRule.type,
interval: recurrenceRule.interval,
by_weekday: recurrenceRule.byWeekday,
by_monthday: recurrenceRule.byMonthday,
},
}),
});
resetForm();
setIsDialogOpen(false);
await loadTodos();
};
const deleteTodo = async (id: string) => {
await api.deleteTodo(id);
await loadTodos();
};
const checkInTodo = async (id: string) => {
setCheckingIn(id);
try {
await api.checkInTodo(id);
await loadTodos();
} finally {
setCheckingIn(null);
}
};
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) {
return t("modeBiweekly");
}
switch (rule.type) {
case "daily": return t("modeDaily");
case "weekly": return t("modeWeekly");
case "monthly": return t("modeMonthly");
case "yearly": return t("modeYearly");
default: return rule.type;
}
};
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")}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-red-500"
>
{t("delete")}
</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>
))}
{todos.length === 0 && (
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
{t("noTodo")}
</div>
)}
</div>
</CardContent>
</Card>
</AppShell>
);
};
export default TodosPage;