Compare commits
2 Commits
f34c01afdf
...
af194d1b9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af194d1b9c | ||
|
|
8131ec7af2 |
@@ -1,7 +1,24 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
output: 'standalone'
|
output: 'standalone',
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/sw.js',
|
||||||
|
headers: [
|
||||||
|
{
|
||||||
|
key: 'Cache-Control',
|
||||||
|
value: 'public, max-age=0, must-revalidate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'Service-Worker-Allowed',
|
||||||
|
value: '/',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
BIN
frontend/public/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
frontend/public/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
frontend/public/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
frontend/public/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
frontend/public/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
frontend/public/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
frontend/public/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
frontend/public/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/notify_icon.png
Normal file
|
After Width: | Height: | Size: 993 KiB |
90
frontend/public/sw.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const CACHE_NAME = "notify-v1";
|
||||||
|
|
||||||
|
const PRECACHE_URLS = ["/"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(CACHE_NAME)
|
||||||
|
.then((cache) => cache.addAll(PRECACHE_URLS))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((names) =>
|
||||||
|
Promise.all(
|
||||||
|
names
|
||||||
|
.filter((name) => name !== CACHE_NAME)
|
||||||
|
.map((name) => caches.delete(name))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Skip cross-origin and non-GET requests
|
||||||
|
if (request.method !== "GET" || !request.url.startsWith(self.location.origin)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip API calls
|
||||||
|
if (url.pathname.startsWith("/api/")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Next.js static chunks — stale-while-revalidate
|
||||||
|
if (url.pathname.startsWith("/_next/")) {
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then((cache) =>
|
||||||
|
cache.match(request).then((cached) => {
|
||||||
|
const fetched = fetch(request).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
return cached || fetched;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation requests — network-first with cache fallback
|
||||||
|
if (request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other static assets — cache-first
|
||||||
|
event.respondWith(
|
||||||
|
caches.open(CACHE_NAME).then((cache) =>
|
||||||
|
cache.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
cache.put(request, response.clone());
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
BIN
frontend/src/app/apple-icon.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/src/app/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 220 20% 97%;
|
--background: 220 14% 96%;
|
||||||
--foreground: 220 20% 10%;
|
--foreground: 220 20% 10%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 220 20% 10%;
|
--card-foreground: 220 20% 10%;
|
||||||
@@ -17,27 +17,27 @@
|
|||||||
--secondary: 220 15% 94%;
|
--secondary: 220 15% 94%;
|
||||||
--secondary-foreground: 220 20% 20%;
|
--secondary-foreground: 220 20% 20%;
|
||||||
--muted: 220 15% 94%;
|
--muted: 220 15% 94%;
|
||||||
--muted-foreground: 220 10% 50%;
|
--muted-foreground: 220 10% 46%;
|
||||||
--accent: 220 15% 94%;
|
--accent: 220 15% 94%;
|
||||||
--accent-foreground: 220 20% 20%;
|
--accent-foreground: 220 20% 20%;
|
||||||
--destructive: 0 72% 55%;
|
--destructive: 0 72% 55%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 220 15% 92%;
|
--border: 220 15% 90%;
|
||||||
--input: 220 15% 90%;
|
--input: 220 15% 88%;
|
||||||
--ring: 220 80% 55%;
|
--ring: 220 80% 55%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.75rem;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 0 0% 100%;
|
||||||
--sidebar-foreground: 220 20% 20%;
|
--sidebar-foreground: 220 20% 20%;
|
||||||
--sidebar-primary: 220 80% 55%;
|
--sidebar-primary: 220 80% 55%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 220 15% 96%;
|
--sidebar-accent: 220 15% 96%;
|
||||||
--sidebar-accent-foreground: 220 20% 20%;
|
--sidebar-accent-foreground: 220 20% 20%;
|
||||||
--sidebar-border: 220 15% 94%;
|
--sidebar-border: 220 15% 93%;
|
||||||
--sidebar-ring: 220 80% 55%;
|
--sidebar-ring: 220 80% 55%;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 28px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -47,6 +47,9 @@
|
|||||||
body {
|
body {
|
||||||
background-color: hsl(var(--background));
|
background-color: hsl(var(--background));
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,38 +95,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.625rem;
|
--radius: 0.75rem;
|
||||||
--background: oklch(1 0 0);
|
--background: hsl(220 14% 96%);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: hsl(220 20% 10%);
|
||||||
--card: oklch(1 0 0);
|
--card: hsl(0 0% 100%);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: hsl(220 20% 10%);
|
||||||
--popover: oklch(1 0 0);
|
--popover: hsl(0 0% 100%);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: hsl(220 20% 10%);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: hsl(220 80% 55%);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: hsl(0 0% 100%);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: hsl(220 15% 94%);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: hsl(220 20% 20%);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: hsl(220 15% 94%);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: hsl(220 10% 46%);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: hsl(220 15% 94%);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: hsl(220 20% 20%);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: hsl(0 72% 55%);
|
||||||
--border: oklch(0.922 0 0);
|
--border: hsl(220 15% 90%);
|
||||||
--input: oklch(0.922 0 0);
|
--input: hsl(220 15% 88%);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: hsl(220 80% 55%);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: hsl(0 0% 100%);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: hsl(220 20% 20%);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: hsl(220 80% 55%);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: hsl(0 0% 100%);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: hsl(220 15% 96%);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: hsl(220 20% 20%);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: hsl(220 15% 93%);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: hsl(220 80% 55%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
|||||||
BIN
frontend/src/app/icon.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
@@ -115,7 +115,7 @@ const InvitesPage = () => {
|
|||||||
return { key: "statusRevoked", color: "bg-red-100 text-red-700" };
|
return { key: "statusRevoked", color: "bg-red-100 text-red-700" };
|
||||||
}
|
}
|
||||||
if (new Date(invite.expires_at) < new Date()) {
|
if (new Date(invite.expires_at) < new Date()) {
|
||||||
return { key: "statusExpired", color: "bg-slate-100 text-slate-700" };
|
return { key: "statusExpired", color: "bg-muted text-muted-foreground" };
|
||||||
}
|
}
|
||||||
if (invite.used_count >= invite.max_uses) {
|
if (invite.used_count >= invite.max_uses) {
|
||||||
return { key: "statusExhausted", color: "bg-amber-100 text-amber-700" };
|
return { key: "statusExhausted", color: "bg-amber-100 text-amber-700" };
|
||||||
@@ -125,8 +125,12 @@ const InvitesPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-foreground">{t("navInvites")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("createInviteDesc")}</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card className="bg-white">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("createInvite")}</CardTitle>
|
<CardTitle>{t("createInvite")}</CardTitle>
|
||||||
<CardDescription>{t("createInviteDesc")}</CardDescription>
|
<CardDescription>{t("createInviteDesc")}</CardDescription>
|
||||||
@@ -160,7 +164,7 @@ const InvitesPage = () => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="bg-white">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("myInvites")}</CardTitle>
|
<CardTitle>{t("myInvites")}</CardTitle>
|
||||||
<CardDescription>{t("myInvitesDesc")}</CardDescription>
|
<CardDescription>{t("myInvitesDesc")}</CardDescription>
|
||||||
@@ -173,17 +177,17 @@ const InvitesPage = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={invite.id}
|
key={invite.id}
|
||||||
className="flex items-center justify-between rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
className="flex items-center justify-between rounded-lg bg-muted/50 px-4 py-3 transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<code className="rounded bg-slate-200 px-2 py-0.5 text-sm font-semibold text-slate-800">
|
<code className="rounded bg-muted px-2 py-0.5 text-sm font-semibold text-foreground">
|
||||||
{invite.code}
|
{invite.code}
|
||||||
</code>
|
</code>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => copyCode(invite.code, invite.id)}
|
onClick={() => copyCode(invite.code, invite.id)}
|
||||||
className="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600"
|
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground/80"
|
||||||
title={t("copyCode")}
|
title={t("copyCode")}
|
||||||
>
|
>
|
||||||
{copiedId === invite.id ? (
|
{copiedId === invite.id ? (
|
||||||
@@ -196,7 +200,7 @@ const InvitesPage = () => {
|
|||||||
{t(status.key as keyof ReturnType<typeof useTranslation>)}
|
{t(status.key as keyof ReturnType<typeof useTranslation>)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex items-center gap-3 text-xs text-slate-500">
|
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
{invite.used_count}/{invite.max_uses}
|
{invite.used_count}/{invite.max_uses}
|
||||||
@@ -209,7 +213,7 @@ const InvitesPage = () => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => viewDetails(invite.id)}
|
onClick={() => viewDetails(invite.id)}
|
||||||
className="text-slate-400 hover:text-blue-500"
|
className="text-muted-foreground hover:text-blue-500"
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -219,7 +223,7 @@ const InvitesPage = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-slate-400 hover:text-red-500"
|
className="text-muted-foreground hover:text-red-500"
|
||||||
>
|
>
|
||||||
{t("revoke")}
|
{t("revoke")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -248,7 +252,7 @@ const InvitesPage = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{invites.length === 0 && (
|
{invites.length === 0 && (
|
||||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
<div className="rounded-lg bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||||
{t("noInvites")}
|
{t("noInvites")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -262,7 +266,7 @@ const InvitesPage = () => {
|
|||||||
<DialogContent className="max-w-md">
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<code className="rounded bg-slate-200 px-2 py-0.5 text-base">
|
<code className="rounded bg-muted px-2 py-0.5 text-base">
|
||||||
{selectedInvite?.code}
|
{selectedInvite?.code}
|
||||||
</code>
|
</code>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@@ -275,19 +279,19 @@ const InvitesPage = () => {
|
|||||||
selectedInvite.registeredUsers.map((user) => (
|
selectedInvite.registeredUsers.map((user) => (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
className="flex items-center gap-3 rounded-lg bg-slate-50 p-3"
|
className="flex items-center gap-3 rounded-lg bg-muted/50 p-3"
|
||||||
>
|
>
|
||||||
<Avatar username={user.username} src={user.avatar} size="sm" />
|
<Avatar username={user.username} src={user.avatar} size="sm" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium text-slate-800">{user.username}</div>
|
<div className="font-medium text-foreground">{user.username}</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDateTime(user.createdAt)}
|
{formatDateTime(user.createdAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg bg-slate-50 p-6 text-center text-sm text-slate-400">
|
<div className="rounded-lg bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||||
{t("noRegisteredUsers")}
|
{t("noRegisteredUsers")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
import type { Viewport } from "next";
|
||||||
import { I18nProvider } from "@/lib/i18n";
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
|
import { ServiceWorkerRegistrar } from "@/components/sw-registrar";
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#2563EB",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
};
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Notify",
|
title: "Notify",
|
||||||
description: "简洁提醒应用",
|
description: "简洁提醒应用",
|
||||||
|
icons: {
|
||||||
|
icon: "/icon.png",
|
||||||
|
apple: "/icons/icon-192x192.png",
|
||||||
|
},
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Notify",
|
||||||
|
},
|
||||||
|
formatDetection: {
|
||||||
|
telephone: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const RootLayout = ({ children }: { children: React.ReactNode }) => {
|
const RootLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
<html lang="zh" suppressHydrationWarning>
|
<html lang="zh" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link
|
||||||
|
rel="apple-touch-startup-image"
|
||||||
|
href="/icons/icon-512x512.png"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
<body className="min-h-screen bg-background font-sans antialiased">
|
<body className="min-h-screen bg-background font-sans antialiased">
|
||||||
<I18nProvider>{children}</I18nProvider>
|
<I18nProvider>{children}</I18nProvider>
|
||||||
|
<ServiceWorkerRegistrar />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,11 +31,11 @@ const LoginPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100 px-4 py-10">
|
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-muted via-background to-muted/80 px-4 py-10">
|
||||||
<div className="absolute right-4 top-4">
|
<div className="absolute right-4 top-4">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<Card className="w-full max-w-md border-slate-200/80 shadow-lg">
|
<Card className="w-full max-w-md shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">{t("login")}</CardTitle>
|
<CardTitle className="text-2xl">{t("login")}</CardTitle>
|
||||||
<CardDescription>{t("loginWelcome")}</CardDescription>
|
<CardDescription>{t("loginWelcome")}</CardDescription>
|
||||||
@@ -66,7 +66,7 @@ const LoginPage = () => {
|
|||||||
{t("login")}
|
{t("login")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
className="block text-center text-sm text-slate-500 transition hover:text-slate-900"
|
className="block text-center text-sm text-muted-foreground transition hover:text-foreground"
|
||||||
href="/register"
|
href="/register"
|
||||||
>
|
>
|
||||||
{t("noAccount")}
|
{t("noAccount")}
|
||||||
|
|||||||
35
frontend/src/app/manifest.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
|
return {
|
||||||
|
name: "Notify",
|
||||||
|
short_name: "Notify",
|
||||||
|
description: "简洁提醒应用",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#F1F3F8",
|
||||||
|
theme_color: "#2563EB",
|
||||||
|
icons: [
|
||||||
|
{ src: "/icons/icon-72x72.png", sizes: "72x72", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-96x96.png", sizes: "96x96", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-128x128.png", sizes: "128x128", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-144x144.png", sizes: "144x144", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-152x152.png", sizes: "152x152", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-192x192.png", sizes: "192x192", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-384x384.png", sizes: "384x384", type: "image/png" },
|
||||||
|
{ src: "/icons/icon-512x512.png", sizes: "512x512", type: "image/png" },
|
||||||
|
{
|
||||||
|
src: "/icons/icon-192x192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/icons/icon-512x512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png",
|
||||||
|
purpose: "maskable",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,10 +46,10 @@ const NotificationsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Card className="bg-white">
|
<Card>
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{t("notifications")}</CardTitle>
|
<CardTitle className="text-xl">{t("notifications")}</CardTitle>
|
||||||
<CardDescription>{t("notificationsDesc")}</CardDescription>
|
<CardDescription>{t("notificationsDesc")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -66,13 +66,13 @@ const NotificationsPage = () => {
|
|||||||
{notifications.map((item) => (
|
{notifications.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center justify-between rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-semibold text-slate-800">
|
<div className="text-sm font-semibold text-foreground">
|
||||||
{t("triggerTime")}:{new Date(item.triggerAt).toLocaleString()}
|
{t("triggerTime")}:{new Date(item.triggerAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">{t("channel")}:{item.channel}</div>
|
<div className="text-xs text-muted-foreground">{t("channel")}:{item.channel}</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => markRead(item.id)}>
|
<Button variant="outline" size="sm" onClick={() => markRead(item.id)}>
|
||||||
{t("markRead")}
|
{t("markRead")}
|
||||||
@@ -80,7 +80,7 @@ const NotificationsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{notifications.length === 0 && (
|
{notifications.length === 0 && (
|
||||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
<div className="rounded-xl bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||||
{t("noNotification")}
|
{t("noNotification")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,11 +32,11 @@ const RegisterPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-slate-100 px-4 py-10">
|
<div className="relative flex min-h-screen items-center justify-center bg-gradient-to-br from-muted via-background to-muted/80 px-4 py-10">
|
||||||
<div className="absolute right-4 top-4">
|
<div className="absolute right-4 top-4">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
<Card className="w-full max-w-md border-slate-200/80 shadow-lg">
|
<Card className="w-full max-w-md shadow-lg">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl">{t("registerTitle")}</CardTitle>
|
<CardTitle className="text-2xl">{t("registerTitle")}</CardTitle>
|
||||||
<CardDescription>{t("registerDesc")}</CardDescription>
|
<CardDescription>{t("registerDesc")}</CardDescription>
|
||||||
@@ -76,7 +76,7 @@ const RegisterPage = () => {
|
|||||||
{t("register")}
|
{t("register")}
|
||||||
</Button>
|
</Button>
|
||||||
<Link
|
<Link
|
||||||
className="block text-center text-sm text-slate-500 transition hover:text-slate-900"
|
className="block text-center text-sm text-muted-foreground transition hover:text-foreground"
|
||||||
href="/login"
|
href="/login"
|
||||||
>
|
>
|
||||||
{t("hasAccount")}
|
{t("hasAccount")}
|
||||||
|
|||||||
@@ -160,8 +160,12 @@ const RemindersPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight text-foreground">{t("navReminder")}</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">{t("createReminderDesc")}</p>
|
||||||
|
</div>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<Card className="bg-white">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("createReminder")}</CardTitle>
|
<CardTitle>{t("createReminder")}</CardTitle>
|
||||||
<CardDescription>{t("createReminderDesc")}</CardDescription>
|
<CardDescription>{t("createReminderDesc")}</CardDescription>
|
||||||
@@ -279,14 +283,14 @@ const RemindersPage = () => {
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={id}
|
key={id}
|
||||||
className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-2 py-1 text-sm text-slate-700"
|
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-1 text-sm text-foreground/80"
|
||||||
>
|
>
|
||||||
<Avatar username={user.username} src={user.avatar} size="sm" className="h-5 w-5 text-xs" />
|
<Avatar username={user.username} src={user.avatar} size="sm" className="h-5 w-5 text-xs" />
|
||||||
{user.username}
|
{user.username}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeRecipient(id)}
|
onClick={() => removeRecipient(id)}
|
||||||
className="rounded-full p-0.5 hover:bg-slate-200"
|
className="rounded-full p-0.5 hover:bg-muted/80"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -307,8 +311,8 @@ const RemindersPage = () => {
|
|||||||
<Label htmlFor="showBarkSettings">{t("barkSettings")}</Label>
|
<Label htmlFor="showBarkSettings">{t("barkSettings")}</Label>
|
||||||
</div>
|
</div>
|
||||||
{showBarkSettings && (
|
{showBarkSettings && (
|
||||||
<div className="ml-6 space-y-4 rounded-lg border border-slate-200 bg-slate-50/50 p-4">
|
<div className="ml-6 space-y-4 rounded-xl border border-border/60 bg-muted/30 p-4">
|
||||||
<p className="text-sm text-slate-500">{t("barkSettingsDesc")}</p>
|
<p className="text-sm text-muted-foreground">{t("barkSettingsDesc")}</p>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="barkTitle">{t("barkTitle")}</Label>
|
<Label htmlFor="barkTitle">{t("barkTitle")}</Label>
|
||||||
@@ -393,7 +397,7 @@ const RemindersPage = () => {
|
|||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-white">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("reminderList")}</CardTitle>
|
<CardTitle>{t("reminderList")}</CardTitle>
|
||||||
<CardDescription>{t("reminderListDesc")}</CardDescription>
|
<CardDescription>{t("reminderListDesc")}</CardDescription>
|
||||||
@@ -403,29 +407,29 @@ const RemindersPage = () => {
|
|||||||
{tasks.map((task) => (
|
{tasks.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
className="flex items-center justify-between rounded-lg bg-slate-50/80 px-4 py-3 transition-colors hover:bg-slate-100/80"
|
className="flex items-center justify-between rounded-xl bg-muted/50 px-4 py-3 transition-colors hover:bg-muted"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-slate-800">{task.title}</span>
|
<span className="text-sm font-semibold text-foreground">{task.title}</span>
|
||||||
{task.recurrenceRule && (
|
{task.recurrenceRule && (
|
||||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
{getRecurrenceLabel(task.recurrenceRule)}
|
{getRecurrenceLabel(task.recurrenceRule)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-muted-foreground">
|
||||||
{formatDateTime(task.dueAt)}
|
{formatDateTime(task.dueAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-slate-500">{t("reminder")}</span>
|
<span className="text-xs font-medium text-muted-foreground">{t("reminder")}</span>
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-slate-400 hover:text-red-500"
|
className="text-muted-foreground hover:text-red-500"
|
||||||
>
|
>
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -452,7 +456,7 @@ const RemindersPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
<div className="rounded-xl bg-muted/50 p-6 text-center text-sm text-muted-foreground">
|
||||||
{t("noReminder")}
|
{t("noReminder")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { RecurrencePicker, type RecurrenceRule as RecurrenceRuleInput } from "@/components/ui/recurrence-picker";
|
import { RecurrencePicker, type RecurrenceRule as RecurrenceRuleInput } from "@/components/ui/recurrence-picker";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { useTranslation } from "@/lib/i18n";
|
import { useTranslation } from "@/lib/i18n";
|
||||||
|
import { Bell, CalendarCheck, CheckCircle2, ListTodo, Plus, Repeat, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
type RecurrenceType = "hourly" | "daily" | "weekly" | "monthly" | "yearly";
|
type RecurrenceType = "hourly" | "daily" | "weekly" | "monthly" | "yearly";
|
||||||
|
|
||||||
@@ -54,9 +55,16 @@ type Todo = {
|
|||||||
checkInAt?: string | null;
|
checkInAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ReminderTask = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
dueAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
const TodosPage = () => {
|
const TodosPage = () => {
|
||||||
const t = useTranslation();
|
const t = useTranslation();
|
||||||
const [todos, setTodos] = useState<Todo[]>([]);
|
const [todos, setTodos] = useState<Todo[]>([]);
|
||||||
|
const [reminders, setReminders] = useState<ReminderTask[]>([]);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [dueAt, setDueAt] = useState<Date | undefined>(undefined);
|
const [dueAt, setDueAt] = useState<Date | undefined>(undefined);
|
||||||
const [offsetMinutes, setOffsetMinutes] = useState(10);
|
const [offsetMinutes, setOffsetMinutes] = useState(10);
|
||||||
@@ -70,8 +78,14 @@ const TodosPage = () => {
|
|||||||
setTodos(data);
|
setTodos(data);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadReminders = async () => {
|
||||||
|
const data = (await api.getReminderTasks()) as ReminderTask[];
|
||||||
|
setReminders(data);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTodos().catch(() => null);
|
loadTodos().catch(() => null);
|
||||||
|
loadReminders().catch(() => null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -118,8 +132,6 @@ const TodosPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const colorDots = ["bg-emerald-500", "bg-amber-500", "bg-blue-500", "bg-rose-500"];
|
|
||||||
|
|
||||||
const getRecurrenceLabel = (rule: RecurrenceRule): string => {
|
const getRecurrenceLabel = (rule: RecurrenceRule): string => {
|
||||||
const interval = rule.interval || 1;
|
const interval = rule.interval || 1;
|
||||||
if (rule.type === "weekly" && interval === 2) {
|
if (rule.type === "weekly" && interval === 2) {
|
||||||
@@ -134,17 +146,62 @@ const TodosPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDueDate = (dateStr: string): string => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const isToday = d.toDateString() === now.toDateString();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const isTomorrow = d.toDateString() === tomorrow.toDateString();
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
|
const timeStr = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
|
||||||
|
if (isToday) return `Today ${timeStr}`;
|
||||||
|
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||||
|
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${timeStr}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDueStatus = (dateStr: string, isCheckedIn: boolean): { label: string; className: string } => {
|
||||||
|
if (isCheckedIn) {
|
||||||
|
return { label: t("checkInSuccess"), className: "bg-emerald-50 text-emerald-700 border-emerald-200" };
|
||||||
|
}
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
if (d < now) {
|
||||||
|
return { label: "Overdue", className: "bg-red-50 text-red-700 border-red-200" };
|
||||||
|
}
|
||||||
|
const hoursUntil = (d.getTime() - now.getTime()) / (1000 * 60 * 60);
|
||||||
|
if (hoursUntil <= 24) {
|
||||||
|
return { label: "Due Soon", className: "bg-amber-50 text-amber-700 border-amber-200" };
|
||||||
|
}
|
||||||
|
return { label: "Upcoming", className: "bg-blue-50 text-blue-700 border-blue-200" };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
const totalTasks = todos.length;
|
||||||
|
const checkedInCount = todos.filter((t) => t.isCheckedIn).length;
|
||||||
|
const recurringCount = todos.filter((t) => t.recurrenceRule).length;
|
||||||
|
|
||||||
|
// Upcoming reminders (next 3, sorted by dueAt)
|
||||||
|
const upcomingReminders = reminders
|
||||||
|
.filter((r) => new Date(r.dueAt) >= new Date())
|
||||||
|
.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell>
|
<AppShell>
|
||||||
<Card className="bg-white">
|
<div className="grid gap-5 lg:grid-cols-[1fr_280px]">
|
||||||
|
{/* Left column - Todo list */}
|
||||||
|
<Card>
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>{t("myTodoList")}</CardTitle>
|
<CardTitle className="text-xl">{t("myTodoList")}</CardTitle>
|
||||||
<CardDescription>{t("myTodoListDesc")}</CardDescription>
|
<CardDescription>{t("myTodoListDesc")}</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" type="button">
|
<Button type="button">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
{t("addTask")}
|
{t("addTask")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -212,59 +269,77 @@ const TodosPage = () => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-3">
|
{todos.length > 0 ? (
|
||||||
{todos.map((todo, index) => (
|
<div className="divide-y divide-border/50">
|
||||||
|
{/* Table header */}
|
||||||
|
<div className="grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 pb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
<div className="w-5" />
|
||||||
|
<div>{t("title")}</div>
|
||||||
|
<div>{t("due")}</div>
|
||||||
|
<div>{t("status")}</div>
|
||||||
|
<div className="w-16" />
|
||||||
|
</div>
|
||||||
|
{todos.map((todo) => {
|
||||||
|
const status = getDueStatus(todo.dueAt, todo.isCheckedIn);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={todo.id}
|
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"
|
className="group grid grid-cols-[auto_1fr_auto_auto_auto] items-center gap-4 px-3 py-3 transition-colors hover:bg-muted/40 rounded-lg"
|
||||||
>
|
>
|
||||||
<span
|
{/* Checkbox / Check-in */}
|
||||||
className={`h-3 w-3 rounded-sm ${colorDots[index % colorDots.length]}`}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<div className="flex-1">
|
className={`flex h-5 w-5 items-center justify-center rounded-full border-2 transition-colors ${
|
||||||
|
todo.isCheckedIn
|
||||||
|
? "border-emerald-500 bg-emerald-500 text-white"
|
||||||
|
: "border-border hover:border-emerald-400"
|
||||||
|
}`}
|
||||||
|
onClick={() => checkInTodo(todo.id)}
|
||||||
|
disabled={checkingIn === todo.id || todo.isCheckedIn}
|
||||||
|
>
|
||||||
|
{todo.isCheckedIn && <CheckCircle2 className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Title + badges */}
|
||||||
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-slate-800">{todo.title}</span>
|
<span className={`truncate text-sm font-medium ${todo.isCheckedIn ? "text-muted-foreground line-through" : "text-foreground"}`}>
|
||||||
|
{todo.title}
|
||||||
|
</span>
|
||||||
{todo.recurrenceRule && (
|
{todo.recurrenceRule && (
|
||||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">
|
||||||
|
<Repeat className="h-3 w-3" />
|
||||||
{getRecurrenceLabel(todo.recurrenceRule)}
|
{getRecurrenceLabel(todo.recurrenceRule)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{todo.checkInCount > 0 && (
|
{todo.checkInCount > 0 && (
|
||||||
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">
|
||||||
{todo.checkInCount}x
|
{todo.checkInCount}x
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
{t("due")} {new Date(todo.dueAt).toLocaleString()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Due date */}
|
||||||
|
<div className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{formatDueDate(todo.dueAt)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
variant="outline"
|
{/* Status pill */}
|
||||||
size="sm"
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap ${status.className}`}>
|
||||||
className={
|
{status.label}
|
||||||
todo.isCheckedIn
|
</span>
|
||||||
? "bg-emerald-50 text-emerald-600"
|
|
||||||
: "text-emerald-600 hover:bg-emerald-50 hover:text-emerald-700"
|
{/* Delete */}
|
||||||
}
|
<div className="flex justify-end w-16">
|
||||||
onClick={() => checkInTodo(todo.id)}
|
|
||||||
disabled={checkingIn === todo.id || todo.isCheckedIn}
|
|
||||||
>
|
|
||||||
{checkingIn === todo.id
|
|
||||||
? "..."
|
|
||||||
: todo.isCheckedIn
|
|
||||||
? t("checkInSuccess")
|
|
||||||
: t("checkIn")}
|
|
||||||
</Button>
|
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="sm"
|
className="rounded-lg p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-red-50 hover:text-red-500 group-hover:opacity-100"
|
||||||
className="text-slate-400 hover:text-red-500"
|
|
||||||
>
|
>
|
||||||
{t("delete")}
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
@@ -285,15 +360,93 @@ const TodosPage = () => {
|
|||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
{todos.length === 0 && (
|
);
|
||||||
<div className="rounded-lg bg-slate-50/80 p-6 text-center text-sm text-slate-400">
|
})}
|
||||||
{t("noTodo")}
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Empty state */
|
||||||
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||||
|
<ListTodo className="h-7 w-7" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-foreground">{t("todoEmptyTitle")}</h3>
|
||||||
|
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">{t("todoEmptyDesc")}</p>
|
||||||
|
<Button className="mt-5" onClick={() => setIsDialogOpen(true)}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
{t("addTask")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Right column - Stats & Upcoming */}
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
{/* Stats cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||||
|
<ListTodo className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground">{totalTasks}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("totalTasks")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600">
|
||||||
|
<CalendarCheck className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground">{checkedInCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("checkedInToday")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600">
|
||||||
|
<Repeat className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold text-foreground">{recurringCount}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("recurringTasks")}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming reminders */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="p-4 pb-3">
|
||||||
|
<CardTitle className="text-[15px] font-semibold">{t("upcomingReminders")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
{upcomingReminders.length > 0 ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{upcomingReminders.map((reminder) => (
|
||||||
|
<div key={reminder.id} className="flex items-start gap-2.5">
|
||||||
|
<div className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 text-amber-600">
|
||||||
|
<Bell className="h-3.5 w-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-foreground">{reminder.title}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{formatDueDate(reminder.dueAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("noUpcomingReminders")}</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,38 +2,18 @@
|
|||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import AppSidebar from "@/components/AppSidebar";
|
import AppSidebar from "@/components/AppSidebar";
|
||||||
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
|
||||||
import { clearToken } from "@/lib/auth";
|
|
||||||
import { useTranslation } from "@/lib/i18n";
|
|
||||||
import { NotificationProvider } from "@/lib/notification-context";
|
import { NotificationProvider } from "@/lib/notification-context";
|
||||||
import { UserProvider } from "@/lib/user-context";
|
import { UserProvider } from "@/lib/user-context";
|
||||||
|
|
||||||
const AppShellContent = ({ children }: { children: ReactNode }) => {
|
const AppShellContent = ({ children }: { children: ReactNode }) => {
|
||||||
const t = useTranslation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider defaultOpen>
|
<SidebarProvider defaultOpen>
|
||||||
<div className="flex min-h-screen bg-slate-100 p-6">
|
<div className="flex min-h-screen bg-muted/50 p-6">
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 px-6">
|
<div className="w-full max-w-[1200px] py-6 px-6">
|
||||||
<div className="rounded-xl bg-white p-4 shadow-sm">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold leading-tight text-slate-900">Notify</h1>
|
|
||||||
<p className="text-sm text-slate-500">{t("appDesc")}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<Button variant="outline" onClick={() => clearToken()}>
|
|
||||||
{t("logout")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-5">{children}</div>
|
<div className="grid gap-5">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { Bell, BellDot, ListTodo, Settings, UserPlus } from "lucide-react";
|
import { Bell, BellDot, ListTodo, LogOut, Settings, UserPlus } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
SidebarFooter,
|
SidebarFooter,
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
SidebarHeader,
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
SidebarSeparator,
|
SidebarSeparator,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import Avatar from "@/components/ui/avatar";
|
import Avatar from "@/components/ui/avatar";
|
||||||
|
import LanguageSwitcher from "@/components/LanguageSwitcher";
|
||||||
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
import { useTranslation, type TranslationKey } from "@/lib/i18n";
|
||||||
import { useNotification } from "@/lib/notification-context";
|
import { useNotification } from "@/lib/notification-context";
|
||||||
import { useUser } from "@/lib/user-context";
|
import { useUser } from "@/lib/user-context";
|
||||||
|
import { clearToken } from "@/lib/auth";
|
||||||
|
|
||||||
const navItems: { href: string; labelKey: TranslationKey; icon: typeof ListTodo }[] = [
|
const navItems: { href: string; labelKey: TranslationKey; icon: typeof ListTodo }[] = [
|
||||||
{ href: "/todos", labelKey: "navTodo", icon: ListTodo },
|
{ href: "/todos", labelKey: "navTodo", icon: ListTodo },
|
||||||
@@ -40,19 +42,14 @@ const AppSidebar = () => {
|
|||||||
<Sidebar variant="inset" className="h-[calc(100vh-3rem)] self-start bg-white">
|
<Sidebar variant="inset" className="h-[calc(100vh-3rem)] self-start bg-white">
|
||||||
<SidebarHeader className="gap-2 px-3 py-4">
|
<SidebarHeader className="gap-2 px-3 py-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500/10 text-blue-600">
|
<Image src="/notify_icon.png" alt="Notify" width={28} height={28} className="h-7 w-7 rounded-lg" />
|
||||||
<span className="text-base font-semibold">◎</span>
|
<span className="text-[15px] font-semibold tracking-tight text-foreground group-data-[state=collapsed]/sidebar:hidden">
|
||||||
</span>
|
|
||||||
<span className="text-base font-semibold group-data-[state=collapsed]/sidebar:hidden">
|
|
||||||
notify
|
notify
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel className="group-data-[state=collapsed]/sidebar:hidden">
|
|
||||||
{t("navigation")}
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
@@ -67,7 +64,7 @@ const AppSidebar = () => {
|
|||||||
>
|
>
|
||||||
<Link href={item.href}>
|
<Link href={item.href}>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className={`h-[18px] w-[18px] ${isActive ? "text-sidebar-primary" : "text-muted-foreground"}`} />
|
||||||
{isNotifications && unreadCount > 0 && (
|
{isNotifications && unreadCount > 0 && (
|
||||||
<span className="absolute -right-1 -top-1 hidden h-2 w-2 rounded-full bg-red-500 group-data-[state=collapsed]/sidebar:inline-flex" />
|
<span className="absolute -right-1 -top-1 hidden h-2 w-2 rounded-full bg-red-500 group-data-[state=collapsed]/sidebar:inline-flex" />
|
||||||
)}
|
)}
|
||||||
@@ -90,13 +87,26 @@ const AppSidebar = () => {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarFooter className="px-3 py-4">
|
<SidebarFooter className="px-3 py-3">
|
||||||
<div className="flex items-center gap-2 group-data-[state=collapsed]/sidebar:justify-center">
|
<div className="flex items-center justify-between group-data-[state=collapsed]/sidebar:justify-center">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Avatar username={user?.username} src={user?.avatar} size="sm" />
|
<Avatar username={user?.username} src={user?.avatar} size="sm" />
|
||||||
<span className="text-sm font-medium text-slate-700 group-data-[state=collapsed]/sidebar:hidden">
|
<span className="truncate text-sm font-medium text-foreground/80 group-data-[state=collapsed]/sidebar:hidden">
|
||||||
{user?.username}
|
{user?.username}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 group-data-[state=collapsed]/sidebar:hidden">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => clearToken()}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
title={t("logout")}
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
@@ -17,28 +16,28 @@ const languages: { value: Locale; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const LanguageSwitcher = () => {
|
const LanguageSwitcher = () => {
|
||||||
const { locale, setLocale, t } = useI18n();
|
const { locale, setLocale } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
|
>
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">
|
</button>
|
||||||
{languages.find((l) => l.value === locale)?.label}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-32 p-1" align="end">
|
<PopoverContent className="w-32 p-1" align="end">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-0.5">
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<button
|
<button
|
||||||
key={lang.value}
|
key={lang.value}
|
||||||
className={`w-full rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-slate-100 ${
|
className={`w-full rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted ${
|
||||||
locale === lang.value
|
locale === lang.value
|
||||||
? "bg-slate-100 font-medium text-slate-900"
|
? "bg-muted font-medium text-foreground"
|
||||||
: "text-slate-600"
|
: "text-muted-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLocale(lang.value);
|
setLocale(lang.value);
|
||||||
|
|||||||
@@ -138,9 +138,9 @@ const SettingsPanel = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="bg-white">
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t("settings")}</CardTitle>
|
<CardTitle className="text-xl">{t("settings")}</CardTitle>
|
||||||
<CardDescription>{t("settingsDesc")}</CardDescription>
|
<CardDescription>{t("settingsDesc")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -176,7 +176,7 @@ const SettingsPanel = () => {
|
|||||||
{t("removeAvatar")}
|
{t("removeAvatar")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-slate-500">{t("avatarHint")}</p>
|
<p className="text-xs text-muted-foreground">{t("avatarHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,11 +201,11 @@ const SettingsPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("notificationChannels")}</Label>
|
<Label>{t("notificationChannels")}</Label>
|
||||||
<div className="rounded-lg bg-slate-50/80">
|
<div className="rounded-xl bg-muted/50 border border-border/60">
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-slate-800">{t("webNotifications")}</div>
|
<div className="text-sm font-medium text-foreground">{t("webNotifications")}</div>
|
||||||
<div className="text-xs text-slate-500">{t("webNotificationsDesc")}</div>
|
<div className="text-xs leading-relaxed text-muted-foreground">{t("webNotificationsDesc")}</div>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="inapp"
|
id="inapp"
|
||||||
@@ -215,11 +215,11 @@ const SettingsPanel = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-4 h-px bg-slate-200/50" />
|
<div className="mx-4 h-px bg-border/60" />
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-slate-800">{t("barkAlerts")}</div>
|
<div className="text-sm font-medium text-foreground">{t("barkAlerts")}</div>
|
||||||
<div className="text-xs text-slate-500">{t("barkAlertsDesc")}</div>
|
<div className="text-xs leading-relaxed text-muted-foreground">{t("barkAlertsDesc")}</div>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="bark"
|
id="bark"
|
||||||
@@ -242,8 +242,8 @@ const SettingsPanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Change Password Section */}
|
{/* Change Password Section */}
|
||||||
<div className="mt-8 pt-6 border-t border-slate-200">
|
<div className="mt-8 pt-6 border-t border-border">
|
||||||
<h3 className="text-lg font-medium mb-4">{t("changePassword")}</h3>
|
<h3 className="text-lg font-semibold tracking-tight mb-4">{t("changePassword")}</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
|
<Label htmlFor="currentPassword">{t("currentPassword")}</Label>
|
||||||
|
|||||||
15
frontend/src/components/sw-registrar.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ServiceWorkerRegistrar() {
|
||||||
|
useEffect(() => {
|
||||||
|
if ("serviceWorker" in navigator && process.env.NODE_ENV === "production") {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch((err) => {
|
||||||
|
console.warn("SW registration failed:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-white p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-xl",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-card p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-2xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -64,7 +64,7 @@ const AlertDialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Title
|
<AlertDialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold text-slate-900", className)}
|
className={cn("text-lg font-semibold text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -76,7 +76,7 @@ const AlertDialogDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Description
|
<AlertDialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-slate-500", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default:
|
||||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||||
outline:
|
outline:
|
||||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
"border border-border/80 bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||||
secondary:
|
secondary:
|
||||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
@@ -22,8 +22,8 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2",
|
default: "h-9 px-4 py-2",
|
||||||
sm: "h-8 rounded-md px-3 text-xs",
|
sm: "h-8 rounded-lg px-3 text-xs",
|
||||||
lg: "h-10 rounded-md px-8",
|
lg: "h-10 rounded-lg px-8",
|
||||||
icon: "h-9 w-9",
|
icon: "h-9 w-9",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("rounded-xl bg-card text-card-foreground shadow-sm", className)}
|
className={cn("rounded-2xl border border-border/60 bg-card text-card-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.04)]", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -15,14 +15,14 @@ Card.displayName = "Card";
|
|||||||
|
|
||||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6 pb-4", className)} {...props} />
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
CardHeader.displayName = "CardHeader";
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
|
<h3 ref={ref} className={cn("text-lg font-semibold leading-snug tracking-tight text-foreground", className)} {...props} />
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
CardTitle.displayName = "CardTitle";
|
CardTitle.displayName = "CardTitle";
|
||||||
@@ -31,7 +31,7 @@ const CardDescription = React.forwardRef<
|
|||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
|
<p ref={ref} className={cn("mt-1 text-sm text-muted-foreground", className)} {...props} />
|
||||||
));
|
));
|
||||||
CardDescription.displayName = "CardDescription";
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"peer h-[18px] w-[18px] shrink-0 rounded-[5px] border border-border transition-colors duration-150 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-primary data-[state=checked]:text-primary-foreground",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" strokeWidth={3} />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-white p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-xl",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-card p-6 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-2xl",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-950 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-slate-100 data-[state=open]:text-slate-500">
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary/20 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-muted data-[state=open]:text-muted-foreground">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -72,7 +72,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-lg font-semibold leading-none tracking-tight text-slate-900", className)}
|
className={cn("text-lg font-semibold leading-none tracking-tight text-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -84,7 +84,7 @@ const DialogDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-slate-500", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full rounded-lg border border-input/60 bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 disabled:cursor-not-allowed disabled:opacity-50",
|
"flex h-10 w-full rounded-xl border border-border bg-background px-3 py-2 text-sm transition-colors duration-150 ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary/60 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const Sidebar = React.forwardRef<
|
|||||||
data-state={open ? "expanded" : "collapsed"}
|
data-state={open ? "expanded" : "collapsed"}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": "16rem",
|
"--sidebar-width": "14rem",
|
||||||
"--sidebar-width-collapsed": "4.25rem",
|
"--sidebar-width-collapsed": "4.25rem",
|
||||||
...style,
|
...style,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
@@ -116,7 +116,7 @@ const SidebarGroupLabel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("px-2 text-xs font-medium uppercase text-sidebar-foreground/60", className)}
|
className={cn("px-2 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -145,12 +145,12 @@ const SidebarMenuItem = React.forwardRef<HTMLLIElement, React.HTMLAttributes<HTM
|
|||||||
SidebarMenuItem.displayName = "SidebarMenuItem";
|
SidebarMenuItem.displayName = "SidebarMenuItem";
|
||||||
|
|
||||||
const sidebarMenuButtonVariants = cva(
|
const sidebarMenuButtonVariants = cva(
|
||||||
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
"flex w-full items-center gap-2 rounded-lg px-3 py-2 text-[14px] font-medium text-sidebar-foreground/80 transition-all duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
isActive: {
|
isActive: {
|
||||||
true: "bg-sidebar-accent text-sidebar-primary font-semibold",
|
true: "border-l-2 border-sidebar-primary bg-sidebar-primary/[0.06] text-sidebar-primary font-semibold",
|
||||||
false: "",
|
false: "border-l-2 border-transparent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -177,7 +177,7 @@ SidebarMenuButton.displayName = "SidebarMenuButton";
|
|||||||
|
|
||||||
const SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
const SidebarSeparator = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("mx-3 my-2 h-px bg-sidebar-border/50", className)} {...props} />
|
<div ref={ref} className={cn("mx-3 my-2 h-px bg-sidebar-border/40", className)} {...props} />
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
SidebarSeparator.displayName = "SidebarSeparator";
|
SidebarSeparator.displayName = "SidebarSeparator";
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ export const translations = {
|
|||||||
addTask: "+ 添加任务",
|
addTask: "+ 添加任务",
|
||||||
due: "截止",
|
due: "截止",
|
||||||
noTodo: "暂无 Todo",
|
noTodo: "暂无 Todo",
|
||||||
|
todoEmptyTitle: "还没有任何任务",
|
||||||
|
todoEmptyDesc: "创建你的第一个任务,开始高效管理时间",
|
||||||
|
totalTasks: "总任务",
|
||||||
|
checkedInToday: "今日打卡",
|
||||||
|
recurringTasks: "周期任务",
|
||||||
|
upcomingReminders: "即将到来的提醒",
|
||||||
|
noUpcomingReminders: "暂无即将到来的提醒",
|
||||||
confirmDelete: "确认删除",
|
confirmDelete: "确认删除",
|
||||||
confirmDeleteDesc: "确定要删除「{title}」吗?此操作无法撤销。",
|
confirmDeleteDesc: "确定要删除「{title}」吗?此操作无法撤销。",
|
||||||
checkIn: "打卡",
|
checkIn: "打卡",
|
||||||
@@ -275,6 +282,13 @@ export const translations = {
|
|||||||
addTask: "+ Add Task",
|
addTask: "+ Add Task",
|
||||||
due: "Due",
|
due: "Due",
|
||||||
noTodo: "No Todo items",
|
noTodo: "No Todo items",
|
||||||
|
todoEmptyTitle: "No tasks yet",
|
||||||
|
todoEmptyDesc: "Create your first task and start managing your time efficiently",
|
||||||
|
totalTasks: "Total Tasks",
|
||||||
|
checkedInToday: "Checked In",
|
||||||
|
recurringTasks: "Recurring",
|
||||||
|
upcomingReminders: "Upcoming Reminders",
|
||||||
|
noUpcomingReminders: "No upcoming reminders",
|
||||||
confirmDelete: "Confirm Delete",
|
confirmDelete: "Confirm Delete",
|
||||||
confirmDeleteDesc: "Are you sure you want to delete \"{title}\"? This action cannot be undone.",
|
confirmDeleteDesc: "Are you sure you want to delete \"{title}\"? This action cannot be undone.",
|
||||||
checkIn: "Check In",
|
checkIn: "Check In",
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
"2xl": "calc(var(--radius) + 4px)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
xl: "var(--radius)",
|
||||||
sm: "calc(var(--radius) - 4px)",
|
lg: "calc(var(--radius) - 2px)",
|
||||||
|
md: "calc(var(--radius) - 4px)",
|
||||||
|
sm: "calc(var(--radius) - 6px)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||