first commit
This commit is contained in:
182
frontend/src/app/globals.css
Normal file
182
frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
301
frontend/src/app/invites/page.tsx
Normal file
301
frontend/src/app/invites/page.tsx
Normal 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;
|
||||
20
frontend/src/app/layout.tsx
Normal file
20
frontend/src/app/layout.tsx
Normal 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;
|
||||
81
frontend/src/app/login/page.tsx
Normal file
81
frontend/src/app/login/page.tsx
Normal 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;
|
||||
94
frontend/src/app/notifications/page.tsx
Normal file
94
frontend/src/app/notifications/page.tsx
Normal 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
23
frontend/src/app/page.tsx
Normal 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;
|
||||
91
frontend/src/app/register/page.tsx
Normal file
91
frontend/src/app/register/page.tsx
Normal 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;
|
||||
467
frontend/src/app/reminders/page.tsx
Normal file
467
frontend/src/app/reminders/page.tsx
Normal 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;
|
||||
14
frontend/src/app/settings/page.tsx
Normal file
14
frontend/src/app/settings/page.tsx
Normal 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;
|
||||
301
frontend/src/app/todos/page.tsx
Normal file
301
frontend/src/app/todos/page.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user