Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Dong
6f33c71240 Add responsive mobile layout with bottom tab navigation
Convert sidebar to fixed bottom tab bar on mobile (<768px) with icon-only
nav items and active indicator. Todo list uses stacked card layout on mobile
instead of 5-column grid. Reduce padding throughout for small screens and
enable user scaling for accessibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 13:55:15 +08:00
Michael Dong
af194d1b9c Add PWA support with manifest, service worker, and icons
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:14:47 +08:00
Michael Dong
8131ec7af2 Redesign frontend UI with premium SaaS styling and app icon
- Restyle design system: refined color palette, 16px base font, antialiased
  text rendering, improved typography hierarchy across all pages
- Update base components (button, input, card, checkbox, dialog, sidebar)
  with modern rounded corners, subtle shadows, and smooth transitions
- Redesign layout: remove header bar, move controls to sidebar footer,
  add two-column todo dashboard with stats and upcoming reminders
- Replace hardcoded slate colors with design token system throughout
- Add app icon (favicon, apple-icon, sidebar logo) from notify_icon.png
- Improve typography: page titles 20px, section titles 18px, sidebar
  nav 14px, stats 24px semibold, body text with proper line-height

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:19:14 +08:00
37 changed files with 763 additions and 325 deletions

View File

@@ -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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 KiB

90
frontend/public/sw.js Normal file
View 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;
});
})
)
);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

View File

@@ -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>
)} )}

View File

@@ -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: 5,
userScalable: true,
};
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>
); );

View File

@@ -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")}

View 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",
},
],
};
}

View File

@@ -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>
)} )}

View File

@@ -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")}

View File

@@ -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>
@@ -215,7 +219,7 @@ const RemindersPage = () => {
<Label htmlFor="showAdvanceReminder">{t("enableAdvanceReminder")}</Label> <Label htmlFor="showAdvanceReminder">{t("enableAdvanceReminder")}</Label>
</div> </div>
{showAdvanceReminder && ( {showAdvanceReminder && (
<div className="ml-6 space-y-2"> <div className="ml-4 md:ml-6 space-y-2">
<Label htmlFor="offset">{t("advanceReminder")}</Label> <Label htmlFor="offset">{t("advanceReminder")}</Label>
<Input <Input
id="offset" id="offset"
@@ -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-4 md: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>
)} )}

View File

@@ -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,82 @@ 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 - desktop only */}
<div className="hidden md: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 px-3 py-3 transition-colors hover:bg-muted/40 rounded-lg md:grid md:grid-cols-[auto_1fr_auto_auto_auto] md:items-center md:gap-4"
> >
<span {/* Mobile: Row 1 - Checkbox + Title */}
className={`h-3 w-3 rounded-sm ${colorDots[index % colorDots.length]}`} <div className="flex items-center gap-3 md:contents">
/> {/* Checkbox / Check-in */}
<div className="flex-1"> <button
<div className="flex items-center gap-2"> type="button"
<span className="text-sm font-semibold text-slate-800">{todo.title}</span> className={`flex h-5 w-5 shrink-0 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 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<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>
</div> </div>
<Button
variant="outline" {/* Mobile: Row 2 - Due date + Status + Delete */}
size="sm" <div className="flex items-center gap-2 mt-2 pl-8 md:contents md:mt-0 md:pl-0">
className={ {/* Due date */}
todo.isCheckedIn <div className="text-xs text-muted-foreground whitespace-nowrap">
? "bg-emerald-50 text-emerald-600" {formatDueDate(todo.dueAt)}
: "text-emerald-600 hover:bg-emerald-50 hover:text-emerald-700" </div>
}
onClick={() => checkInTodo(todo.id)} {/* Status pill */}
disabled={checkingIn === todo.id || todo.isCheckedIn} <span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap ${status.className}`}>
> {status.label}
{checkingIn === todo.id </span>
? "..."
: todo.isCheckedIn {/* Delete */}
? t("checkInSuccess") <div className="flex justify-end ml-auto md:w-16">
: 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-100 md: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 +365,94 @@ const TodosPage = () => {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
))} </div>
{todos.length === 0 && ( </div>
<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-3 gap-2 md:grid-cols-1 md:gap-3">
<Card className="p-3 md:p-4">
<div className="flex flex-col items-center gap-1 md:flex-row md:items-center md:gap-3">
<div className="flex h-8 w-8 md:h-9 md:w-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<ListTodo className="h-4 w-4" />
</div>
<div className="text-center md:text-left">
<div className="text-xl md:text-2xl font-semibold text-foreground">{totalTasks}</div>
<div className="text-[10px] md:text-xs text-muted-foreground">{t("totalTasks")}</div>
</div>
</div>
</Card>
<Card className="p-3 md:p-4">
<div className="flex flex-col items-center gap-1 md:flex-row md:items-center md:gap-3">
<div className="flex h-8 w-8 md:h-9 md:w-9 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-600">
<CalendarCheck className="h-4 w-4" />
</div>
<div className="text-center md:text-left">
<div className="text-xl md:text-2xl font-semibold text-foreground">{checkedInCount}</div>
<div className="text-[10px] md:text-xs text-muted-foreground">{t("checkedInToday")}</div>
</div>
</div>
</Card>
<Card className="p-3 md:p-4">
<div className="flex flex-col items-center gap-1 md:flex-row md:items-center md:gap-3">
<div className="flex h-8 w-8 md:h-9 md:w-9 items-center justify-center rounded-xl bg-blue-500/10 text-blue-600">
<Repeat className="h-4 w-4" />
</div>
<div className="text-center md:text-left">
<div className="text-xl md:text-2xl font-semibold text-foreground">{recurringCount}</div>
<div className="text-[10px] md: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>
); );
}; };

View File

@@ -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-2 md: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-3 px-3 md:py-6 md: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>

View File

@@ -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,17 +11,19 @@ import {
SidebarFooter, SidebarFooter,
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
SidebarSeparator, SidebarSeparator,
useSidebar,
} 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 },
@@ -35,24 +38,55 @@ const AppSidebar = () => {
const { unreadCount } = useNotification(); const { unreadCount } = useNotification();
const { user } = useUser(); const { user } = useUser();
const t = useTranslation(); const t = useTranslation();
const { isMobile } = useSidebar();
if (isMobile) {
return (
<Sidebar>
<nav className="flex items-center justify-around px-1 py-1.5 safe-bottom">
{navItems.map((item) => {
const isActive = pathname?.startsWith(item.href);
const isNotifications = item.href === "/notifications";
return (
<Link
key={item.href}
href={item.href}
className={`relative flex flex-col items-center gap-0.5 rounded-lg px-3 py-1.5 text-[10px] font-medium transition-colors ${
isActive
? "text-primary"
: "text-muted-foreground"
}`}
>
<span className="relative">
<item.icon className="h-5 w-5" />
{isNotifications && unreadCount > 0 && (
<span className="absolute -right-1.5 -top-1 h-2 w-2 rounded-full bg-red-500" />
)}
</span>
<span>{t(item.labelKey)}</span>
{isActive && (
<span className="absolute -bottom-1 h-0.5 w-4 rounded-full bg-primary" />
)}
</Link>
);
})}
</nav>
</Sidebar>
);
}
return ( return (
<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 +101,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 +124,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>
); );

View File

@@ -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);

View File

@@ -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>

View 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;
}

View File

@@ -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}
/> />
)); ));

View File

@@ -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",
}, },
}, },

View File

@@ -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";

View File

@@ -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>
)); ));

View File

@@ -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}
/> />
)); ));

View File

@@ -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}

View File

@@ -1,3 +1,5 @@
"use client";
import * as React from "react"; import * as React from "react";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
@@ -8,6 +10,7 @@ type SidebarContextValue = {
open: boolean; open: boolean;
setOpen: (value: boolean) => void; setOpen: (value: boolean) => void;
toggle: () => void; toggle: () => void;
isMobile: boolean;
}; };
const SidebarContext = React.createContext<SidebarContextValue | null>(null); const SidebarContext = React.createContext<SidebarContextValue | null>(null);
@@ -20,6 +23,8 @@ const useSidebar = () => {
return context; return context;
}; };
const MOBILE_BREAKPOINT = 768;
const SidebarProvider = ({ const SidebarProvider = ({
children, children,
defaultOpen = true, defaultOpen = true,
@@ -28,9 +33,21 @@ const SidebarProvider = ({
defaultOpen?: boolean; defaultOpen?: boolean;
}) => { }) => {
const [open, setOpen] = React.useState(defaultOpen); const [open, setOpen] = React.useState(defaultOpen);
const [isMobile, setIsMobile] = React.useState(false);
const toggle = React.useCallback(() => setOpen((prev) => !prev), []); const toggle = React.useCallback(() => setOpen((prev) => !prev), []);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = (e: MediaQueryListEvent | MediaQueryList) => {
setIsMobile(e.matches);
};
onChange(mql);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return ( return (
<SidebarContext.Provider value={{ open, setOpen, toggle }}> <SidebarContext.Provider value={{ open, setOpen, toggle, isMobile }}>
{children} {children}
</SidebarContext.Provider> </SidebarContext.Provider>
); );
@@ -57,8 +74,24 @@ const sidebarVariants = cva(
const Sidebar = React.forwardRef< const Sidebar = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof sidebarVariants> React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof sidebarVariants>
>(({ className, variant, style, ...props }, ref) => { >(({ className, variant, style, children, ...props }, ref) => {
const { open } = useSidebar(); const { open, isMobile } = useSidebar();
if (isMobile) {
return (
<div
ref={ref}
className={cn(
"fixed bottom-0 left-0 right-0 z-50 border-t border-border/40 bg-white",
className
)}
{...props}
>
{children}
</div>
);
}
return ( return (
<div <div
ref={ref} ref={ref}
@@ -66,7 +99,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
@@ -78,9 +111,16 @@ const Sidebar = React.forwardRef<
Sidebar.displayName = "Sidebar"; Sidebar.displayName = "Sidebar";
const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const SidebarInset = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => {
<div ref={ref} className={cn("min-h-screen flex-1", className)} {...props} /> const { isMobile } = useSidebar();
) return (
<div
ref={ref}
className={cn("min-h-screen flex-1", isMobile && "pb-16", className)}
{...props}
/>
);
}
); );
SidebarInset.displayName = "SidebarInset"; SidebarInset.displayName = "SidebarInset";
@@ -116,7 +156,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 +185,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 +217,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";

View File

@@ -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",

View File

@@ -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)",
}, },
}, },
}, },