Merge pull request 'Redesign UI theming, typography, and board polish' (#1) from UI-Overhaul-Test2 into main
Reviewed-on: #1
This commit is contained in:
commit
fbf43c57ae
|
|
@ -37,9 +37,9 @@ export default async function AdminPage() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Admin</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="px-1">
|
||||
<h1 className="font-heading text-xl font-bold tracking-tight">Admin</h1>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Everyone with an account on this site. Click a role to change it.
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,11 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||
<ProjectsProvider initialProjects={projects}>
|
||||
<BoardViewProvider>
|
||||
<HoldOnCompleteProvider>
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
{/* On small screens the three-column shell would crush the
|
||||
board; give it a sensible minimum width and let the page
|
||||
scroll horizontally instead, and drop the Scheduled panel
|
||||
(auxiliary on a phone) until the viewport can hold it. */}
|
||||
<div className="flex h-screen min-w-[860px] overflow-x-auto">
|
||||
<SideNav userEmail={session.user.email ?? ""} role={session.user.role} />
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
<ScheduledPanel />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import { NotebookPen } from "lucide-react";
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/40 p-4">
|
||||
<div className="w-full max-w-sm">{children}</div>
|
||||
<div className="flex min-h-screen items-center justify-center bg-muted/30 p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<span className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground shadow-md">
|
||||
<NotebookPen className="size-6" />
|
||||
</span>
|
||||
<span className="font-heading text-xl font-bold tracking-tight">Organize</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
327
app/globals.css
327
app/globals.css
|
|
@ -4,14 +4,16 @@
|
|||
@import "@uiw/react-md-editor/markdown-editor.css";
|
||||
@import "@uiw/react-markdown-preview/markdown.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
/* "dark:" must be true for every dark-surfaced theme, not just .dark --
|
||||
Ocean is a second dark look, so both classes turn on dark utilities. */
|
||||
@custom-variant dark (&:is(.dark *, .ocean *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--font-heading: var(--font-heading);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
@ -41,6 +43,10 @@
|
|||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
/* One more surface: the "lane" behind a category's cards -- sits between
|
||||
the page background and the card, so cards read as objects resting on a
|
||||
tray instead of floating on the page. */
|
||||
--color-lane: var(--lane);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
|
|
@ -50,77 +56,173 @@
|
|||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
/* ---------------------------------------------------------------------------
|
||||
Themes
|
||||
The app ships four looks, chosen via the theme picker (next-themes):
|
||||
default -- calm, cool neutral light theme (the safe, focused one)
|
||||
sunset -- warm paper + persimmon accent, a cozier light theme
|
||||
dark -- deep charcoal with an indigo accent
|
||||
ocean -- deep blue-green night theme with a seafoam accent
|
||||
Each is a complete token set; components only ever use tokens, so adding
|
||||
a fifth theme later is just another block like these.
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
:root,
|
||||
.theme-default {
|
||||
color-scheme: light;
|
||||
--background: oklch(0.985 0.002 260);
|
||||
--foreground: oklch(0.235 0.02 265);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--card-foreground: oklch(0.235 0.02 265);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
/* Single calm accent (indigo/violet) for interactive chrome -- buttons,
|
||||
active nav, focus rings, links -- kept deliberately separate from the
|
||||
curated vibrant Group palette in lib/colors.ts so the group colors stay
|
||||
the only "loud" color on screen. */
|
||||
--primary: oklch(0.53 0.24 274);
|
||||
--popover-foreground: oklch(0.235 0.02 265);
|
||||
/* Single calm accent (indigo) for interactive chrome -- buttons, active
|
||||
nav, focus rings -- kept deliberately separate from the curated vibrant
|
||||
Group palette in lib/colors.ts so the group colors stay the only
|
||||
"loud" color on screen. */
|
||||
--primary: oklch(0.53 0.2 272);
|
||||
--primary-foreground: oklch(0.98 0.01 274);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.94 0.03 274);
|
||||
--accent-foreground: oklch(0.33 0.16 274);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.53 0.24 274);
|
||||
--secondary: oklch(0.955 0.004 260);
|
||||
--secondary-foreground: oklch(0.31 0.02 265);
|
||||
--muted: oklch(0.95 0.004 260);
|
||||
--muted-foreground: oklch(0.52 0.02 265);
|
||||
--accent: oklch(0.95 0.022 272);
|
||||
--accent-foreground: oklch(0.35 0.12 272);
|
||||
--destructive: oklch(0.58 0.21 27);
|
||||
--border: oklch(0.905 0.004 260);
|
||||
--input: oklch(0.885 0.004 260);
|
||||
--ring: oklch(0.58 0.15 272);
|
||||
--lane: oklch(0.95 0.005 260);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.53 0.24 274);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(0.975 0.003 260);
|
||||
--sidebar-foreground: oklch(0.235 0.02 265);
|
||||
--sidebar-primary: oklch(0.53 0.2 272);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 274);
|
||||
--sidebar-accent: oklch(0.94 0.03 274);
|
||||
--sidebar-accent-foreground: oklch(0.33 0.16 274);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.53 0.24 274);
|
||||
--sidebar-accent: oklch(0.95 0.022 272);
|
||||
--sidebar-accent-foreground: oklch(0.35 0.12 272);
|
||||
--sidebar-border: oklch(0.905 0.004 260);
|
||||
--sidebar-ring: oklch(0.58 0.15 272);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.73 0.17 276);
|
||||
--primary-foreground: oklch(0.17 0.05 274);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.3 0.08 274);
|
||||
--accent-foreground: oklch(0.9 0.04 274);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
color-scheme: dark;
|
||||
--background: oklch(0.175 0.012 265);
|
||||
--foreground: oklch(0.93 0.01 265);
|
||||
--card: oklch(0.22 0.013 265);
|
||||
--card-foreground: oklch(0.93 0.01 265);
|
||||
--popover: oklch(0.23 0.014 265);
|
||||
--popover-foreground: oklch(0.93 0.01 265);
|
||||
--primary: oklch(0.7 0.14 272);
|
||||
--primary-foreground: oklch(0.16 0.04 272);
|
||||
--secondary: oklch(0.27 0.012 265);
|
||||
--secondary-foreground: oklch(0.93 0.01 265);
|
||||
--muted: oklch(0.26 0.013 265);
|
||||
--muted-foreground: oklch(0.68 0.02 265);
|
||||
--accent: oklch(0.31 0.04 272);
|
||||
--accent-foreground: oklch(0.9 0.03 272);
|
||||
--destructive: oklch(0.68 0.19 24);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.73 0.17 276);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.68 0.14 272);
|
||||
--lane: oklch(1 0 0 / 5%);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.73 0.17 276);
|
||||
--sidebar-primary-foreground: oklch(0.17 0.05 274);
|
||||
--sidebar-accent: oklch(0.3 0.08 274);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.04 274);
|
||||
--radius: 0.75rem;
|
||||
--sidebar: oklch(0.205 0.013 265);
|
||||
--sidebar-foreground: oklch(0.93 0.01 265);
|
||||
--sidebar-primary: oklch(0.7 0.14 272);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.04 272);
|
||||
--sidebar-accent: oklch(0.31 0.04 272);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.03 272);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.73 0.17 276);
|
||||
--sidebar-ring: oklch(0.68 0.14 272);
|
||||
}
|
||||
|
||||
.theme-sunset {
|
||||
color-scheme: light;
|
||||
--background: oklch(0.968 0.016 75);
|
||||
--foreground: oklch(0.27 0.032 52);
|
||||
--card: oklch(0.988 0.008 80);
|
||||
--card-foreground: oklch(0.27 0.032 52);
|
||||
--popover: oklch(0.99 0.007 80);
|
||||
--popover-foreground: oklch(0.27 0.032 52);
|
||||
/* Persimmon -- warm enough to feel like part of the paper, saturated
|
||||
enough to stay unmistakably "the accent". */
|
||||
--primary: oklch(0.58 0.19 42);
|
||||
--primary-foreground: oklch(0.98 0.01 60);
|
||||
--secondary: oklch(0.945 0.02 72);
|
||||
--secondary-foreground: oklch(0.32 0.05 50);
|
||||
--muted: oklch(0.938 0.02 72);
|
||||
--muted-foreground: oklch(0.54 0.045 58);
|
||||
--accent: oklch(0.928 0.035 62);
|
||||
--accent-foreground: oklch(0.36 0.11 44);
|
||||
--destructive: oklch(0.57 0.21 25);
|
||||
--border: oklch(0.89 0.022 72);
|
||||
--input: oklch(0.865 0.024 72);
|
||||
--ring: oklch(0.6 0.16 44);
|
||||
--lane: oklch(0.942 0.018 72);
|
||||
--chart-1: oklch(0.79 0.12 58);
|
||||
--chart-2: oklch(0.66 0.14 48);
|
||||
--chart-3: oklch(0.53 0.13 42);
|
||||
--chart-4: oklch(0.42 0.1 52);
|
||||
--chart-5: oklch(0.31 0.07 58);
|
||||
/* Slightly rounder than the default theme -- a touch softer overall. */
|
||||
--radius: 0.9rem;
|
||||
--sidebar: oklch(0.958 0.013 75);
|
||||
--sidebar-foreground: oklch(0.27 0.032 52);
|
||||
--sidebar-primary: oklch(0.58 0.19 42);
|
||||
--sidebar-primary-foreground: oklch(0.98 0.01 60);
|
||||
--sidebar-accent: oklch(0.928 0.035 62);
|
||||
--sidebar-accent-foreground: oklch(0.36 0.11 44);
|
||||
--sidebar-border: oklch(0.89 0.022 72);
|
||||
--sidebar-ring: oklch(0.6 0.16 44);
|
||||
}
|
||||
|
||||
.ocean {
|
||||
color-scheme: dark;
|
||||
--background: oklch(0.2 0.035 224);
|
||||
--foreground: oklch(0.92 0.02 215);
|
||||
--card: oklch(0.245 0.04 220);
|
||||
--card-foreground: oklch(0.92 0.02 215);
|
||||
--popover: oklch(0.255 0.042 218);
|
||||
--popover-foreground: oklch(0.92 0.02 215);
|
||||
/* Seafoam -- a cool, luminous accent that reads "water at night" against
|
||||
the deep blue-green surfaces without screaming. */
|
||||
--primary: oklch(0.76 0.11 192);
|
||||
--primary-foreground: oklch(0.21 0.05 200);
|
||||
--secondary: oklch(0.295 0.042 222);
|
||||
--secondary-foreground: oklch(0.92 0.02 215);
|
||||
--muted: oklch(0.285 0.042 222);
|
||||
--muted-foreground: oklch(0.7 0.03 215);
|
||||
--accent: oklch(0.33 0.05 214);
|
||||
--accent-foreground: oklch(0.9 0.03 192);
|
||||
--destructive: oklch(0.7 0.17 25);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 14%);
|
||||
--ring: oklch(0.72 0.11 192);
|
||||
--lane: oklch(1 0 0 / 6%);
|
||||
--chart-1: oklch(0.76 0.1 192);
|
||||
--chart-2: oklch(0.63 0.11 200);
|
||||
--chart-3: oklch(0.5 0.11 215);
|
||||
--chart-4: oklch(0.4 0.1 228);
|
||||
--chart-5: oklch(0.3 0.08 240);
|
||||
--radius: 0.8rem;
|
||||
--sidebar: oklch(0.225 0.042 220);
|
||||
--sidebar-foreground: oklch(0.92 0.02 215);
|
||||
--sidebar-primary: oklch(0.76 0.11 192);
|
||||
--sidebar-primary-foreground: oklch(0.21 0.05 200);
|
||||
--sidebar-accent: oklch(0.33 0.05 214);
|
||||
--sidebar-accent-foreground: oklch(0.9 0.03 192);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.72 0.11 192);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -129,8 +231,117 @@
|
|||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Gentle, calm type for a tool that sits on screen all day. */
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
/* Headings use the display face (see --font-heading / Figtree). */
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
}
|
||||
/* Smooth, low-key hover/selection feel: fade color changes instead of
|
||||
snapping. Components that set their own transition-* utility override
|
||||
this (utilities win on specificity). */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition:
|
||||
background-color 200ms ease,
|
||||
border-color 200ms ease,
|
||||
color 200ms ease,
|
||||
fill 200ms ease,
|
||||
stroke 200ms ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--ring) 28%, transparent);
|
||||
}
|
||||
/* Quiet scrollbars that match the theme. */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in srgb, var(--foreground) 22%, transparent) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
*::-webkit-scrollbar-track,
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in srgb, var(--foreground) 20%, transparent);
|
||||
border: 3px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in srgb, var(--foreground) 32%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Markdown editor / preview (third-party CSS) -- fit the theme's tokens and
|
||||
the app's type scale so notes blend into dialogs instead of looking
|
||||
pasted in.
|
||||
--------------------------------------------------------------------------- */
|
||||
.wmde-markdown {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: -0.006em;
|
||||
}
|
||||
.w-md-editor {
|
||||
--md-editor-font-family: var(--font-sans);
|
||||
font-size: 0.875rem;
|
||||
color: var(--foreground);
|
||||
background: var(--card);
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.w-md-editor .w-md-editor-toolbar {
|
||||
background: color-mix(in srgb, var(--foreground) 5%, transparent);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.w-md-editor-toolbar-item,
|
||||
.w-md-editor-toolbar-item-icon {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.w-md-editor-toolbar-item-active,
|
||||
.w-md-editor-toolbar-item-active .w-md-editor-toolbar-item-icon {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.w-md-editor-text,
|
||||
.w-md-editor-text-pre,
|
||||
.w-md-editor-text-input {
|
||||
color: var(--foreground);
|
||||
}
|
||||
.w-md-editor-text-input::placeholder {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
/* Dark-surfaced themes (Dark, Ocean) -- the editor's stock CSS is
|
||||
light-only, so override it where we render with data-color-mode="dark". */
|
||||
[data-color-mode="dark"] .w-md-editor {
|
||||
background: var(--card);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-color-mode="dark"] .w-md-editor .w-md-editor-toolbar {
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent);
|
||||
border-color: var(--border);
|
||||
}
|
||||
[data-color-mode="dark"] .w-md-editor .w-md-editor-text,
|
||||
[data-color-mode="dark"] .w-md-editor .w-md-editor-text-pre,
|
||||
[data-color-mode="dark"] .w-md-editor .w-md-editor-text-input {
|
||||
color: var(--foreground);
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Figtree, Inter, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
|
@ -7,13 +7,23 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { RegisterServiceWorker } from "@/components/pwa/register-sw";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
/** UI type: Inter -- a neutral, highly legible humanist sans that reads
|
||||
* cleanly at small sizes (to-do lists, tables, nav). */
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
/** Display type: Figtree -- a slightly friendlier, rounder geometric sans
|
||||
* for headings and wordmarks. Same family, one step of personality. */
|
||||
const figtree = Figtree({
|
||||
variable: "--font-heading",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
/** Code: JetBrains Mono, used for the markdown editor's monospace bits. */
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
variable: "--font-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
|
|
@ -34,8 +44,8 @@ export const metadata: Metadata = {
|
|||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#171717" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#22252b" },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -43,11 +53,21 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
|
|||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={`${inter.variable} ${figtree.variable} ${jetbrainsMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
{/* Four looks: Default (calm light), Sunset (warm light), Dark, Ocean
|
||||
(deep blue-green) -- complete token sets in app/globals.css.
|
||||
"system" resolves to Default/Dark by OS preference.
|
||||
No-FOUC theme restore: runs before first paint, applies the
|
||||
stored preference to <html> (class + color-scheme). */}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `!(function(){try{var r=document.documentElement,c=['theme-default','theme-sunset','dark','ocean','light'];for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}var v=null;try{v=localStorage.getItem('theme');}catch(e){}var m={default:'theme-default',sunset:'theme-sunset',dark:'dark',ocean:'ocean'};if(v==='system'||!v){v=window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'default';}if(m[v]){r.classList.add(m[v]);r.style.colorScheme=(v==='dark'||v==='ocean')?'dark':'light';}}catch(e){}})();`,
|
||||
}}
|
||||
/>
|
||||
<ThemeProvider defaultTheme="system">
|
||||
<TooltipProvider delay={200}>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
|
|
|||
|
|
@ -49,12 +49,21 @@ export function UserRoleMenu({ userId, role }: { userId: string; role: Role }) {
|
|||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Badge
|
||||
variant={ROLE_BADGE_VARIANT[current]}
|
||||
className="cursor-pointer select-none"
|
||||
// Base UI requires a native <button> trigger -- so the Badge
|
||||
// (a <span>) sits inside a bare reset button instead of being
|
||||
// the trigger itself.
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex appearance-none border-0 bg-transparent p-0"
|
||||
aria-label={`Change role (currently ${ROLE_LABEL[current]})`}
|
||||
>
|
||||
{ROLE_LABEL[current]}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={ROLE_BADGE_VARIANT[current]}
|
||||
className="cursor-pointer select-none"
|
||||
>
|
||||
{ROLE_LABEL[current]}
|
||||
</Badge>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start">
|
||||
|
|
|
|||
|
|
@ -38,11 +38,13 @@ export function AddCategoryLane() {
|
|||
<DialogTrigger
|
||||
render={
|
||||
<button
|
||||
className="flex h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
className="flex h-full min-h-32 w-72 shrink-0 flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-border/80 text-muted-foreground transition-colors hover:border-primary/60 hover:bg-primary/5 hover:text-primary"
|
||||
aria-label="Add category"
|
||||
>
|
||||
<Plus className="size-6" />
|
||||
<span className="text-sm font-medium">Add Category</span>
|
||||
<span className="flex size-9 items-center justify-center rounded-full bg-foreground/5">
|
||||
<Plus className="size-5" />
|
||||
</span>
|
||||
<span className="text-sm font-medium">Add lane</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,10 @@ export function AddGroupPopover({ categoryId }: { categoryId: string }) {
|
|||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button variant="ghost" className="w-full justify-start gap-2 text-muted-foreground">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2 rounded-lg text-muted-foreground/90 hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Group
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { useSortable } from "@dnd-kit/sortable";
|
|||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { useDndContext, useDroppable } from "@dnd-kit/core";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { GripVertical, MoreVertical, Trash2 } from "lucide-react";
|
||||
import { GripVertical, MoreVertical, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
|
|
@ -86,6 +86,12 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
|||
});
|
||||
|
||||
const groupIds = visibleGroups.map((g) => g.id);
|
||||
// Small open-count for the header -- "what's still to do in this lane"
|
||||
// at a glance, the number a user organizing their day actually wants.
|
||||
const openTodos = category.groups.reduce(
|
||||
(n, g) => n + g.todos.filter((t) => !t.completed).length,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -93,21 +99,34 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
|||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn(
|
||||
"flex h-full w-72 shrink-0 flex-col rounded-xl bg-muted/40",
|
||||
"flex h-full w-72 shrink-0 flex-col overflow-hidden rounded-2xl border bg-lane",
|
||||
isDragging && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 p-3">
|
||||
<div className="flex items-center gap-1.5 px-3 pt-3 pb-2">
|
||||
<button
|
||||
ref={setActivatorNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||
className="-ml-1 cursor-grab touch-none rounded-md p-1 text-muted-foreground/70 hover:bg-accent hover:text-foreground active:cursor-grabbing"
|
||||
aria-label={`Drag to reorder ${category.name}`}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
<GripVertical className="size-3.5" />
|
||||
</button>
|
||||
<h2 className="min-w-0 flex-1 truncate font-semibold">{category.name}</h2>
|
||||
<h2 className="min-w-0 flex-1 truncate font-heading text-[15px] font-semibold tracking-tight">
|
||||
{category.name}
|
||||
</h2>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums",
|
||||
openTodos > 0
|
||||
? "bg-foreground/8 text-foreground"
|
||||
: "bg-foreground/5 text-muted-foreground"
|
||||
)}
|
||||
aria-label={`${openTodos} open to-dos in ${category.name}`}
|
||||
>
|
||||
{openTodos}
|
||||
</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
|
|
@ -139,9 +158,8 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
|||
// leaves enough clearance that a card's hover lift
|
||||
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
|
||||
// under the lane header above it.
|
||||
"flex-1 space-y-2 overflow-y-auto rounded-lg px-3 pt-2 pb-2 transition-colors",
|
||||
isDropTargetLane &&
|
||||
"bg-primary/10 outline-2 outline-dashed outline-primary/50 -outline-offset-2"
|
||||
"flex-1 space-y-2 overflow-y-auto rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
|
||||
isDropTargetLane && "bg-primary/10"
|
||||
)}
|
||||
>
|
||||
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { GROUP_COLORS, type GroupColorKey } from "@/lib/colors";
|
||||
import { isDarkTheme } from "@/components/theme/use-dark-theme";
|
||||
|
||||
export function ColorSwatchPicker({
|
||||
value,
|
||||
|
|
@ -13,8 +13,7 @@ export function ColorSwatchPicker({
|
|||
value: GroupColorKey;
|
||||
onChange: (key: GroupColorKey) => void;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const isDark = isDarkTheme();
|
||||
|
||||
return (
|
||||
<div role="radiogroup" aria-label="Card color" className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -2,15 +2,17 @@ import { LayoutDashboard } from "lucide-react";
|
|||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-12 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<LayoutDashboard className="size-8" />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-border/80 p-10 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<LayoutDashboard className="size-7" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-heading text-lg font-semibold">Your board is empty</h2>
|
||||
<p className="mx-auto max-w-xs text-sm leading-relaxed text-muted-foreground">
|
||||
Lanes hold your groups of notes and to-dos. Add your first lane to
|
||||
start organizing.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">Your board is empty</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Create your first category to get started — categories are the lanes that hold your
|
||||
groups of notes and to-dos.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { Mic, Send, Sparkles, Square } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
|
|
@ -31,8 +31,7 @@ export function GroupAiDialog({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { saveNote } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [messages, setMessages] = useState<GroupAiMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { NotebookPen } from "lucide-react";
|
||||
|
||||
import { getGroupColor } from "@/lib/colors";
|
||||
import { isDarkTheme } from "@/components/theme/use-dark-theme";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupDTO } from "@/types/board";
|
||||
|
||||
|
|
@ -14,18 +14,15 @@ import type { GroupDTO } from "@/types/board";
|
|||
* would register a second useSortable/useDraggable for the same id.
|
||||
*/
|
||||
export function GroupCardOverlay({ group }: { group: GroupDTO }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = isDarkTheme();
|
||||
const color = getGroupColor(group.color);
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const borderColor = isDark ? color.dark : color.light;
|
||||
const backgroundColor = isDark
|
||||
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
|
||||
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
|
||||
const backgroundColor = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, var(--card))`;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ borderColor, backgroundColor }}
|
||||
className={cn("rotate-1 scale-105 cursor-grabbing rounded-xl border-[3px] p-3 shadow-lg")}
|
||||
className={cn("rotate-1 scale-105 cursor-grabbing rounded-2xl border-[3px] p-3 shadow-lg")}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useState } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
Archive,
|
||||
ChevronRight,
|
||||
|
|
@ -19,6 +18,7 @@ import {
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TodoCheckbox } from "@/components/board/todo-checkbox";
|
||||
import { MouseFollowTooltip } from "@/components/board/mouse-follow-tooltip";
|
||||
import {
|
||||
|
|
@ -29,6 +29,7 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { getBrighterColor, getComplementaryColor, getGroupColor } from "@/lib/colors";
|
||||
import { isDarkTheme } from "@/components/theme/use-dark-theme";
|
||||
import { useBoard } from "@/components/board/board-context";
|
||||
import { useBoardView } from "@/components/board/board-view-provider";
|
||||
import { useHoldOnComplete } from "@/components/hold-on-complete";
|
||||
|
|
@ -90,7 +91,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
const { view } = useBoardView();
|
||||
const { holds, beginHold, cancelHold } = useHoldOnComplete();
|
||||
const compact = view === "compact";
|
||||
const { resolvedTheme } = useTheme();
|
||||
// True for any theme whose surfaces are dark -- Dark and Ocean both use
|
||||
// their `dark` color variant (see lib/colors.ts); light themes use `light`.
|
||||
const isDark = isDarkTheme();
|
||||
const [editingTodo, setEditingTodo] = useState<TodoDTO | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
|
@ -119,15 +122,14 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
const isDropTarget = isOver && !isDragging;
|
||||
|
||||
const color = getGroupColor(group.color);
|
||||
const isDark = resolvedTheme === "dark";
|
||||
const borderColor = isDark ? color.dark : color.light;
|
||||
// A subdued tint of the same stroke color, blended into the theme's own
|
||||
// card surface -- not a fixed pastel, so it automatically stays correct
|
||||
// if the neutral card token ever changes, and needs no separate
|
||||
// light/dark background palette to hand-tune.
|
||||
const backgroundColor = isDark
|
||||
? `color-mix(in srgb, ${color.dark} 18%, var(--card))`
|
||||
: `color-mix(in srgb, ${color.light} 12%, var(--card))`;
|
||||
// The card fill is a calm, hand-picked tint of the same stroke color
|
||||
// (`soft` / `softDark` in lib/colors.ts), blended a little into the theme's
|
||||
// own card surface so it sits naturally on any theme -- the 85% weight
|
||||
// keeps the tint in charge while still letting a tinted theme surface show
|
||||
// through. Dark themes use `softDark`: the same hue at a dark-surface
|
||||
// lightness, instead of the near-white pastel of `soft`.
|
||||
const backgroundColor = `color-mix(in srgb, ${isDark ? color.softDark : color.soft} 85%, var(--card))`;
|
||||
// Opposing hue from the group's own color, for the progress pie below --
|
||||
// so it reads as a distinct accent rather than blending into the border.
|
||||
const pieAccentColor = getComplementaryColor(
|
||||
|
|
@ -191,7 +193,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
backgroundColor,
|
||||
}}
|
||||
className={cn(
|
||||
"relative rounded-xl border-[3px] p-3 shadow-sm transition-shadow",
|
||||
"relative rounded-2xl border-[3px] p-3 shadow-sm transition-shadow",
|
||||
"hover:-translate-y-0.5 hover:shadow-md",
|
||||
isDragging && "opacity-50 shadow-lg",
|
||||
isDropTarget && "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
|
|
@ -225,7 +227,9 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
)}
|
||||
<h3 className="min-w-0 flex-1 truncate pt-1 text-sm font-semibold">{group.title}</h3>
|
||||
<h3 className="min-w-0 flex-1 truncate pt-1 font-heading text-[15px] font-semibold tracking-tight">
|
||||
{group.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<NotesDialog group={group} />
|
||||
|
|
@ -263,7 +267,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<ul className="mt-1 space-y-1">
|
||||
<ul className="mt-2 space-y-0.5">
|
||||
{visibleTodos.map((todo, index) => {
|
||||
const todoButton = (
|
||||
<button
|
||||
|
|
@ -271,8 +275,8 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
onClick={() => setEditingTodo(todo)}
|
||||
style={todo.completed ? undefined : { color: todoTextColor }}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-1 text-left text-sm hover:underline",
|
||||
todo.completed && "text-muted-foreground line-through"
|
||||
"-mx-1 flex min-w-0 flex-1 items-center gap-1 rounded-md px-1 text-left text-sm transition-colors hover:bg-foreground/5",
|
||||
todo.completed && "text-muted-foreground line-through hover:bg-transparent"
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{todo.title}</span>
|
||||
|
|
@ -361,7 +365,7 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-1">
|
||||
<div className="mt-2">
|
||||
<TodoCreatePopover groupId={group.id} categoryId={group.categoryId} />
|
||||
</div>
|
||||
|
||||
|
|
@ -377,16 +381,30 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
|||
</Button>
|
||||
)}
|
||||
|
||||
{canArchive && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
onClick={() => archiveGroup(group.id, group.categoryId)}
|
||||
>
|
||||
<Archive className="size-3.5" />
|
||||
Archive
|
||||
</Button>
|
||||
{(canArchive || group.todos.length > 0) && (
|
||||
<div className="mt-1 flex items-center gap-1 border-t border-foreground/[0.07] pt-1.5">
|
||||
{canArchive && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
onClick={() => archiveGroup(group.id, group.categoryId)}
|
||||
aria-label="Archive group"
|
||||
>
|
||||
<Archive className="size-3.5" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<span className="ml-auto text-[11px] font-medium tabular-nums text-muted-foreground">
|
||||
{completedCount}/{group.todos.length} done
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TodoProgressPie
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
type DragStartEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { LayoutDashboard, Plus } from "lucide-react";
|
||||
|
||||
import { BoardProvider, useBoard } from "@/components/board/board-context";
|
||||
import { CategoryLane } from "@/components/board/category-lane";
|
||||
|
|
@ -20,11 +21,21 @@ import { AddCategoryLane } from "@/components/board/add-category-lane";
|
|||
import { GroupCardOverlay } from "@/components/board/group-card-overlay";
|
||||
import { EmptyState } from "@/components/board/empty-state";
|
||||
import { ViewSwitcher } from "@/components/board/view-switcher";
|
||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { CategoryDTO, GroupDTO } from "@/types/board";
|
||||
|
||||
function Board({ title }: { title: string }) {
|
||||
const { categories, reorderLanes, reorderGroups, moveGroup } = useBoard();
|
||||
const [draggedGroup, setDraggedGroup] = useState<GroupDTO | null>(null);
|
||||
const [quickAddOpen, setQuickAddOpen] = useState(false);
|
||||
|
||||
// The "+ to-do" quick action targets the first group it can find -- the
|
||||
// goal is one click from a keyboard or pointer to start typing a to-do
|
||||
// somewhere sensible, not a journey through lanes and cards.
|
||||
const quickTargetCategory = categories.find((c) => c.groups.length > 0);
|
||||
const quickTargetGroup = quickTargetCategory?.groups[0];
|
||||
|
||||
const sensors = useSensors(
|
||||
// A short activation distance lets a plain click (checkbox, notes icon,
|
||||
|
|
@ -110,15 +121,57 @@ function Board({ title }: { title: string }) {
|
|||
|
||||
return (
|
||||
<DndContext
|
||||
id="board-dnd"
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div className="flex items-center justify-between gap-2 px-1">
|
||||
<h1 className="truncate text-2xl font-bold">{title}</h1>
|
||||
<ViewSwitcher />
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-1">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="hidden size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:flex">
|
||||
<LayoutDashboard className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
|
||||
<p className="truncate text-[13px] text-muted-foreground">
|
||||
{categories.length === 0
|
||||
? "Add a lane to get started"
|
||||
: `${categories.length} ${categories.length === 1 ? "lane" : "lanes"} · ${categories.reduce(
|
||||
(n, c) => n + c.groups.length,
|
||||
0
|
||||
)} groups · ${categories.reduce(
|
||||
(n, c) =>
|
||||
n +
|
||||
c.groups.reduce((m, g) => m + g.todos.filter((t) => !t.completed).length, 0),
|
||||
0
|
||||
)} open to-dos`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
disabled={!quickTargetGroup}
|
||||
onClick={() => setQuickAddOpen(true)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
To-do
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{quickTargetGroup ? "Quickly add a to-do to the first group" : "Add a group first, then quickly add to-dos"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ViewSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{categories.length === 0 ? (
|
||||
|
|
@ -139,6 +192,15 @@ function Board({ title }: { title: string }) {
|
|||
</div>
|
||||
|
||||
<DragOverlay>{draggedGroup ? <GroupCardOverlay group={draggedGroup} /> : null}</DragOverlay>
|
||||
|
||||
{quickTargetCategory && quickTargetGroup && (
|
||||
<TodoCreateDialog
|
||||
groupId={quickTargetGroup.id}
|
||||
categoryId={quickTargetCategory.id}
|
||||
open={quickAddOpen}
|
||||
onOpenChange={setQuickAddOpen}
|
||||
/>
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { MarkdownPreview } from "@/components/markdown/markdown-widgets";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
|
||||
const OFFSET = 16;
|
||||
// Rough size assumptions used to keep the tooltip on-screen -- cheaper than
|
||||
|
|
@ -29,8 +29,7 @@ export function MouseFollowTooltip({
|
|||
content: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
function handleMove(e: React.MouseEvent) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { NotebookPen, Pencil, Sparkles } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
|
@ -19,8 +19,7 @@ import type { GroupDTO } from "@/types/board";
|
|||
|
||||
export function NotesDialog({ group }: { group: GroupDTO }) {
|
||||
const { saveNote } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { Check, Copy, Sparkles } from "lucide-react";
|
||||
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -30,8 +31,7 @@ export function StatusUpdateDialog({
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
// The generated summary, shown permanently once produced -- there's no
|
||||
// accept/discard step, since nothing here is ever saved back to the
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -28,8 +28,7 @@ export function TodoCreateDialog({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { addTodo } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [details, setDetails] = useState("");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -21,8 +21,7 @@ export function TodoCreatePopover({
|
|||
categoryId: string;
|
||||
}) {
|
||||
const { addTodo } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
|
|
@ -50,7 +49,7 @@ export function TodoCreatePopover({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-muted-foreground"
|
||||
className="w-full justify-start gap-2 text-muted-foreground/90 hover:text-foreground"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add to-do
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -35,8 +35,7 @@ export function TodoEditDialog({
|
|||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { editTodo, removeTodo } = useBoard();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [title, setTitle] = useState(todo.title);
|
||||
const [details, setDetails] = useState(todo.details ?? "");
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
|
||||
import { ChatMessageList, ChatComposer, type ChatBubbleMessage } from "@/components/ai/chat-thread";
|
||||
import { colorModeFromTheme } from "@/components/theme/use-dark-theme";
|
||||
import { useSpeechRecognition } from "@/hooks/use-speech-recognition";
|
||||
import { sendChatMessage, type ChatMessage } from "@/lib/actions/chat-ai";
|
||||
|
||||
export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const colorMode = resolvedTheme === "dark" ? "dark" : "light";
|
||||
const colorMode = colorModeFromTheme();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState("");
|
||||
|
|
@ -45,15 +44,17 @@ export function ChatPageClient({ aiConfigured }: { aiConfigured: boolean }) {
|
|||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col gap-4 p-4">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<MessageCircle className="size-6 text-primary" />
|
||||
Chat
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ask about anything across your boards — notes, open to-dos, and things you've
|
||||
already finished.
|
||||
</p>
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
||||
<MessageCircle className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate font-heading text-xl font-bold tracking-tight">Chat</h1>
|
||||
<p className="truncate text-[13px] text-muted-foreground">
|
||||
Ask about anything across your boards — notes, open to-dos, and things you've
|
||||
already finished.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!aiConfigured ? (
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export function ProjectsNavSection({ collapsed }: { collapsed: boolean }) {
|
|||
"flex flex-1 items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
collapsed && "justify-center px-0",
|
||||
isListActive && "bg-accent text-accent-foreground"
|
||||
isListActive && "bg-accent text-accent-foreground shadow-[inset_2px_0_0_var(--primary)]"
|
||||
)}
|
||||
>
|
||||
<FolderKanban className="size-5 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function SideNavItem({ item, collapsed }: { item: NavItem; collapsed: boo
|
|||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
collapsed && "justify-center px-0",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
isActive && "bg-accent text-accent-foreground shadow-[inset_2px_0_0_var(--primary)]"
|
||||
)}
|
||||
>
|
||||
<item.icon className="size-5 shrink-0" />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { ChevronLeft, ChevronRight, LogOut, NotebookPen } from "lucide-react";
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useSideNav } from "@/components/nav/side-nav-provider";
|
||||
import { SideNavItem } from "@/components/nav/side-nav-item";
|
||||
|
|
@ -21,18 +20,22 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
|||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-200",
|
||||
collapsed ? "w-16" : "w-56"
|
||||
"flex h-screen flex-col border-r bg-sidebar text-sidebar-foreground transition-[width] duration-300 ease-out",
|
||||
collapsed ? "w-16" : "w-60"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center gap-2 p-3", collapsed && "justify-center")}>
|
||||
<NotebookPen className="size-6 shrink-0 text-primary" />
|
||||
{!collapsed && <span className="truncate text-lg font-semibold">Organize</span>}
|
||||
<div className={cn("flex items-center gap-2.5 px-4 py-4", collapsed && "justify-center px-0")}>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
|
||||
<NotebookPen className="size-4.5" />
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="truncate font-heading text-[17px] font-bold tracking-tight">
|
||||
Organize
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-2">
|
||||
<nav className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-2.5 pt-1 pb-2">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<SideNavItem key={item.href} item={item} collapsed={collapsed} />
|
||||
))}
|
||||
|
|
@ -42,9 +45,7 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
|||
))}
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-1 p-2">
|
||||
<div className="space-y-0.5 px-2.5 pb-2">
|
||||
<ThemeToggle collapsed={collapsed} />
|
||||
|
||||
{collapsed ? (
|
||||
|
|
@ -62,21 +63,24 @@ export function SideNav({ userEmail, role }: { userEmail: string; role: Role })
|
|||
</form>
|
||||
) : (
|
||||
<form action={logout}>
|
||||
<Button variant="ghost" type="submit" className="w-full justify-start gap-2">
|
||||
<Button variant="ghost" type="submit" className="w-full justify-start gap-2.5">
|
||||
<LogOut className="size-4" />
|
||||
<span className="truncate">Log out</span>
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn("flex items-center gap-2 border-t px-4 py-3", collapsed && "justify-center px-0")}>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-bold text-primary uppercase">
|
||||
{(userEmail[0] ?? "?").slice(0, 1)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<p className="truncate px-3 text-xs text-muted-foreground">{userEmail}</p>
|
||||
<span className="truncate text-[13px] font-medium text-muted-foreground">{userEmail}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={cn("flex p-2", collapsed ? "justify-center" : "justify-end")}>
|
||||
<div className={cn("flex border-t px-2 py-1.5", collapsed ? "justify-center" : "justify-end")}>
|
||||
<Button variant="ghost" size="icon" onClick={toggle} aria-label="Toggle sidebar">
|
||||
{collapsed ? <ChevronRight className="size-4" /> : <ChevronLeft className="size-4" />}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function ProjectCard({ project }: { project: ProjectDTO }) {
|
|||
element paints after (on top of) non-positioned in-flow content
|
||||
regardless of DOM order, so without it the Link would still win
|
||||
every hit-test even under a pointer-events-none ancestor. */}
|
||||
<div className="group relative flex h-32 flex-col justify-between rounded-xl border p-4 shadow-sm transition-shadow hover:-translate-y-0.5 hover:shadow-md">
|
||||
<div className="group relative flex h-32 flex-col justify-between rounded-2xl border p-4 shadow-sm transition-all hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-md">
|
||||
<Link
|
||||
href={`/projects/${project.id}`}
|
||||
className="absolute inset-0 rounded-xl"
|
||||
|
|
@ -64,7 +64,7 @@ export function ProjectCard({ project }: { project: ProjectDTO }) {
|
|||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="pointer-events-none min-w-0 truncate text-sm font-semibold">
|
||||
<h3 className="pointer-events-none min-w-0 truncate font-heading text-[15px] font-semibold tracking-tight">
|
||||
{project.title}
|
||||
</h3>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,23 +11,31 @@ export function ProjectsListView() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Projects</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Each project is its own isolated board -- separate categories, groups, and to-dos from
|
||||
Home and from every other project.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3 px-1">
|
||||
<div>
|
||||
<h1 className="font-heading text-xl font-bold tracking-tight">Projects</h1>
|
||||
<p className="mt-0.5 text-[13px] text-muted-foreground">
|
||||
Each project is its own isolated board, separate from Home.
|
||||
</p>
|
||||
</div>
|
||||
{projects.length > 0 && (
|
||||
<p className="rounded-full bg-foreground/5 px-3 py-1 text-xs font-medium text-muted-foreground tabular-nums">
|
||||
{projects.length} project{projects.length === 1 ? "" : "s"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{projects.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 p-8 text-center">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<FolderKanban className="size-8" />
|
||||
<div className="flex flex-col items-center gap-3 rounded-2xl border border-dashed border-border/80 p-10 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<FolderKanban className="size-7" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h2 className="font-heading text-lg font-semibold">No projects yet</h2>
|
||||
<p className="max-w-xs text-sm text-muted-foreground">
|
||||
Create one to get its own board, separate from Home.
|
||||
</p>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold">No projects yet</h2>
|
||||
<p className="max-w-sm text-sm text-muted-foreground">
|
||||
Create one to get its own board, separate from Home.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export function ScheduledPanel() {
|
|||
<>
|
||||
<aside
|
||||
className={cn(
|
||||
"flex h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200",
|
||||
"hidden h-screen flex-col border-l bg-sidebar text-sidebar-foreground transition-[width] duration-200 sm:flex",
|
||||
collapsed ? "w-16" : "w-80"
|
||||
)}
|
||||
>
|
||||
|
|
@ -276,10 +276,12 @@ function ExpandedPanel({
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 p-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<CalendarClock className="size-5 shrink-0 text-primary" />
|
||||
<span className="truncate text-lg font-semibold">Scheduled</span>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<CalendarClock className="size-4" />
|
||||
</span>
|
||||
<span className="truncate font-heading text-[15px] font-semibold tracking-tight">Scheduled</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
|
|||
|
|
@ -1,11 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* App theme provider (replaces next-themes).
|
||||
*
|
||||
* next-themes injects its no-FOUC script as a <script> inside the React
|
||||
* component tree, which React 19 (dev) rejects: "Encountered a script tag
|
||||
* while rendering React component". Everything it does is small, so this
|
||||
* provider implements the same API ({ theme, setTheme, resolvedTheme })
|
||||
* directly, and the equivalent inline script lives in app/layout.tsx where
|
||||
* it renders as plain server HTML and actually executes.
|
||||
*
|
||||
* Semantics (kept compatible for all existing consumers):
|
||||
* - `theme` the stored preference: default | sunset | dark | ocean | system
|
||||
* - `resolvedTheme` the theme actually applied (system resolved via
|
||||
* prefers-color-scheme), always one of the four
|
||||
* - html classes .theme-default | .theme-sunset | .dark | .ocean (see globals.css)
|
||||
*/
|
||||
|
||||
export const THEMES = ["default", "sunset", "dark", "ocean"] as const;
|
||||
export type ThemeName = (typeof THEMES)[number];
|
||||
export type RawTheme = ThemeName | "system";
|
||||
|
||||
const STORAGE_KEY = "theme";
|
||||
const THEME_CLASSES: Record<ThemeName, string> = {
|
||||
default: "theme-default",
|
||||
sunset: "theme-sunset",
|
||||
dark: "dark",
|
||||
ocean: "ocean",
|
||||
};
|
||||
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
||||
const DARK_SURFACES: ThemeName[] = ["dark", "ocean"];
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: RawTheme;
|
||||
resolvedTheme: ThemeName;
|
||||
setTheme: (theme: RawTheme) => void;
|
||||
themes: readonly ThemeName[];
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: "system",
|
||||
resolvedTheme: "default",
|
||||
setTheme: () => {},
|
||||
themes: THEMES,
|
||||
});
|
||||
|
||||
function systemPrefersDark(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
!!window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
);
|
||||
}
|
||||
|
||||
function resolve(theme: RawTheme): ThemeName {
|
||||
return theme === "system" ? (systemPrefersDark() ? "dark" : "default") : theme;
|
||||
}
|
||||
|
||||
/** Exported so consumers (e.g. isDarkTheme) derive the applied theme from
|
||||
* the same source of truth the provider uses -- and stay reactive when it
|
||||
* changes. */
|
||||
export function resolveTheme(theme: RawTheme): ThemeName {
|
||||
return resolve(theme);
|
||||
}
|
||||
|
||||
function applyToDom(theme: RawTheme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const name = resolve(theme);
|
||||
const root = document.documentElement;
|
||||
// Also drop "light": a class next-themes applied to <html> in earlier
|
||||
// versions, harmless but stale.
|
||||
root.classList.remove(...ALL_CLASSES, "light");
|
||||
root.classList.add(THEME_CLASSES[name]);
|
||||
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
defaultTheme = "system",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
defaultTheme?: RawTheme;
|
||||
}) {
|
||||
// Both the server and the first client render agree on `defaultTheme`
|
||||
// (hydration-safe); the stored preference is picked up right after mount.
|
||||
// The inline script in app/layout.tsx already applied the right classes
|
||||
// before first paint, so there is no visible jump either way.
|
||||
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) setThemeState(stored as RawTheme);
|
||||
} catch {
|
||||
/* storage unavailable -- stay on defaultTheme */
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Keep the DOM in sync whenever the preference changes -- synchronously
|
||||
// (layout effect) so the CSS classes land in the same frame as the
|
||||
// JS-driven colors that derive from this state: no flash of stale colors.
|
||||
useLayoutEffect(() => {
|
||||
applyToDom(theme);
|
||||
}, [theme]);
|
||||
|
||||
// Follow the OS while in "system" mode.
|
||||
useEffect(() => {
|
||||
if (theme !== "system" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const onChange = () => applyToDom("system");
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, [theme]);
|
||||
|
||||
// Keep multiple tabs in sync.
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key === STORAGE_KEY) setThemeState((e.newValue || defaultTheme) as RawTheme);
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, [defaultTheme]);
|
||||
|
||||
const setTheme = useCallback((t: RawTheme) => {
|
||||
setThemeState(t);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
} catch {
|
||||
/* ignore -- preference just won't persist */
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
|
||||
[theme, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,43 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Moon, Sun, Monitor } from "lucide-react";
|
||||
import { useTheme } from "@/components/theme/theme-provider";
|
||||
import { Moon, Sun, Monitor, Sunset, Waves, Check } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// The four looks, shown as two "modes" with a shared System row. Each entry
|
||||
// is one next-themes value (see app/layout.tsx for the class mapping); the
|
||||
// little dot is the theme's own accent so the picker previews the vibe.
|
||||
const OPTIONS = [
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
|
||||
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
|
||||
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
|
||||
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
|
||||
] as const;
|
||||
|
||||
const CURRENT_ICONS = {
|
||||
default: Sun,
|
||||
sunset: Sunset,
|
||||
dark: Moon,
|
||||
ocean: Waves,
|
||||
system: Monitor,
|
||||
} as const;
|
||||
|
||||
export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[2];
|
||||
const Icon = current.icon;
|
||||
// The picker always shows the four real themes; "system" is handled by a
|
||||
// dedicated row since it resolves to one of them per device preference.
|
||||
const known = OPTIONS.find((o) => o.value === theme);
|
||||
const isSystem = theme === "system" || theme === undefined;
|
||||
const Icon = (isSystem ? CURRENT_ICONS.system : known?.icon) ?? CURRENT_ICONS.system;
|
||||
const label = isSystem ? "System" : known?.label ?? "Default";
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
|
@ -36,13 +53,42 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
|
|||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" side="right">
|
||||
<DropdownMenuContent align="start" side="right" className="w-52">
|
||||
<DropdownMenuLabel>Appearance</DropdownMenuLabel>
|
||||
{OPTIONS.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onClick={() => setTheme(option.value)}>
|
||||
<option.icon className="size-4" />
|
||||
{option.label}
|
||||
</DropdownMenuItem>
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setTheme(option.value)}
|
||||
className="group relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
|
||||
>
|
||||
<option.icon className="size-4 text-muted-foreground group-focus:text-current" />
|
||||
<span>{option.label}</span>
|
||||
<span
|
||||
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
||||
style={{ backgroundColor: option.swatch }}
|
||||
aria-hidden
|
||||
/>
|
||||
{theme === option.value && <Check className="absolute right-2 -mr-4 size-4" />}
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme("system")}
|
||||
className="relative flex w-full cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
|
||||
>
|
||||
<Monitor className="size-4 text-muted-foreground" />
|
||||
<span>Match system</span>
|
||||
<span
|
||||
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
||||
style={{
|
||||
background: "linear-gradient(90deg, #4f56e0 50%, #7c83f2 50%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
{isSystem && <Check className="absolute right-2 -mr-4 size-4" />}
|
||||
</button>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,16 +60,21 @@ function DropdownMenuLabel({
|
|||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
// GroupLabel requires a Menu.Group context (Base UI throws
|
||||
// "MenuGroupContext is missing" without it), so wrap itself in one --
|
||||
// callers don't need to remember to add a Group around the label.
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<MenuPrimitive.Group>
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Group>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,19 @@
|
|||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { useTheme } from "@/components/theme/theme-provider"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
const { resolvedTheme } = useTheme()
|
||||
// Sonner only knows light/dark -- map the app's looks (default/sunset are
|
||||
// light; dark/ocean are dark) onto what it understands.
|
||||
const theme: ToasterProps["theme"] =
|
||||
resolvedTheme === "dark" || resolvedTheme === "ocean" ? "dark" : "light"
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={theme}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
|
|
|
|||
|
|
@ -4,8 +4,19 @@
|
|||
// palette *key* on the Group record (not a raw hex) so this list can be
|
||||
// retinted centrally without a data migration.
|
||||
//
|
||||
// Each color has a slightly deeper/less-saturated `dark` variant so a thick
|
||||
// border doesn't glare against a dark background.
|
||||
// Each color ships four variants:
|
||||
// - `light` -- the stroke for light surfaces (default / Sunset themes)
|
||||
// - `dark` -- a slightly brighter stroke for dark surfaces (Dark / Ocean),
|
||||
// so the border reads against a deep background without glaring
|
||||
// - `soft` -- a calm, low-chroma tint of the same hue used as the card's
|
||||
// fill on light themes. Derived per-color (not a generic gray)
|
||||
// so every card carries a whisper of its own identity behind
|
||||
// the stroke.
|
||||
// - `softDark` -- the card fill on dark themes. It's the same hue as `soft`
|
||||
// but at a lightness that reads as a *subtle tint over a dark
|
||||
// surface* (blended with --card via color-mix), instead of the
|
||||
// near-white pastel that `soft` is -- which would otherwise
|
||||
// make every card a washed-out pale rectangle at night.
|
||||
|
||||
export type GroupColorKey =
|
||||
| "coral"
|
||||
|
|
@ -22,17 +33,19 @@ export interface GroupColor {
|
|||
label: string;
|
||||
light: string;
|
||||
dark: string;
|
||||
soft: string;
|
||||
softDark: string;
|
||||
}
|
||||
|
||||
export const GROUP_COLORS: GroupColor[] = [
|
||||
{ key: "coral", label: "Coral", light: "#f4574a", dark: "#ff7a6e" },
|
||||
{ key: "amber", label: "Amber", light: "#e8940f", dark: "#f5ad3d" },
|
||||
{ key: "lime", label: "Lime", light: "#4c9a2a", dark: "#78c94e" },
|
||||
{ key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf" },
|
||||
{ key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee" },
|
||||
{ key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0" },
|
||||
{ key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc" },
|
||||
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6" },
|
||||
{ key: "coral", label: "Coral", light: "#e8574a", dark: "#ff8177", soft: "#fae4e1", softDark: "#3a2320" },
|
||||
{ key: "amber", label: "Amber", light: "#dd8f0e", dark: "#f2ab3e", soft: "#f8edd6", softDark: "#3a301c" },
|
||||
{ key: "lime", label: "Lime", light: "#4c9a2a", dark: "#7cc954", soft: "#e5f0dc", softDark: "#25331d" },
|
||||
{ key: "teal", label: "Teal", light: "#0f9488", dark: "#2dd4bf", soft: "#daf1ed", softDark: "#16332f" },
|
||||
{ key: "sky", label: "Sky", light: "#2f7fe0", dark: "#5b9dee", soft: "#e0eafa", softDark: "#1a2b40" },
|
||||
{ key: "indigo", label: "Indigo", light: "#5b5bd6", dark: "#8c8cf0", soft: "#e4e4f7", softDark: "#23233c" },
|
||||
{ key: "violet", label: "Violet", light: "#9440d1", dark: "#c084fc", soft: "#eddff9", softDark: "#2e2136" },
|
||||
{ key: "pink", label: "Pink", light: "#d63f8f", dark: "#f472b6", soft: "#fadfee", softDark: "#37202e" },
|
||||
];
|
||||
|
||||
const GROUP_COLOR_MAP: Record<string, GroupColor> = Object.fromEntries(
|
||||
|
|
@ -116,7 +129,7 @@ function hslToHex(h: number, s: number, l: number): string {
|
|||
|
||||
const complementaryCache = new Map<string, string>();
|
||||
|
||||
/** The opposing accent color for a given base hex, tuned per light/dark theme. */
|
||||
/** The opposing accent color for a given base hex, tuned per light/dark surface. */
|
||||
export function getComplementaryColor(hex: string, variant: "light" | "dark"): string {
|
||||
const cacheKey = `${hex}:${variant}`;
|
||||
const cached = complementaryCache.get(cacheKey);
|
||||
|
|
@ -137,7 +150,7 @@ const brighterCache = new Map<string, string>();
|
|||
/**
|
||||
* A brighter tint of a Group's own color, same hue, for un-checked to-do
|
||||
* text. Deliberately distinct from the plain foreground color (which would
|
||||
* otherwise render pure white in dark mode) so the group title -- which
|
||||
* otherwise render near-white on dark themes) so the group title -- which
|
||||
* keeps the default foreground color -- stays the most prominent text on
|
||||
* the card instead of competing with its own to-dos.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in New Issue