Merge pull request 'More-Theme-Stuff' (#5) from More-Theme-Stuff into main
Reviewed-on: #5
This commit is contained in:
commit
a04e9a65bd
|
|
@ -2,6 +2,8 @@ import { redirect } from "next/navigation";
|
||||||
|
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import type { ThemeName } from "@/lib/themes";
|
||||||
|
import type { ProjectDTO } from "@/types/project";
|
||||||
import { SideNavProvider } from "@/components/nav/side-nav-provider";
|
import { SideNavProvider } from "@/components/nav/side-nav-provider";
|
||||||
import { SideNav } from "@/components/nav/side-nav";
|
import { SideNav } from "@/components/nav/side-nav";
|
||||||
import { ProjectsProvider } from "@/components/projects/projects-context";
|
import { ProjectsProvider } from "@/components/projects/projects-context";
|
||||||
|
|
@ -18,11 +20,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
||||||
|
|
||||||
// Just the list for the sidebar/`/projects` page -- each project's own
|
// Just the list for the sidebar/`/projects` page -- each project's own
|
||||||
// board (categories/groups/todos) is fetched separately by its own page.
|
// board (categories/groups/todos) is fetched separately by its own page.
|
||||||
const projects = await prisma.project.findMany({
|
// `theme` is stored as free text; the set of valid names is enforced
|
||||||
|
// client-side (ProjectThemeSchema) so a plain assertion is safe here.
|
||||||
|
const projects: ProjectDTO[] = (
|
||||||
|
await prisma.project.findMany({
|
||||||
where: { ownerId: session.user.id },
|
where: { ownerId: session.user.id },
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
select: { id: true, title: true },
|
select: { id: true, title: true, theme: true },
|
||||||
});
|
})
|
||||||
|
).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
title: p.title,
|
||||||
|
theme: p.theme as ThemeName | null,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideNavProvider>
|
<SideNavProvider>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { prisma } from "@/lib/db";
|
||||||
import { projectAccessFilter } from "@/lib/access";
|
import { projectAccessFilter } from "@/lib/access";
|
||||||
import { getBoard } from "@/lib/board";
|
import { getBoard } from "@/lib/board";
|
||||||
import { getAiSettingsView } from "@/lib/ai-settings";
|
import { getAiSettingsView } from "@/lib/ai-settings";
|
||||||
|
import { themeSwitchScript, type ThemeName } from "@/lib/themes";
|
||||||
import { KanbanBoard } from "@/components/board/kanban-board";
|
import { KanbanBoard } from "@/components/board/kanban-board";
|
||||||
|
import { ProjectThemeScope } from "@/components/theme/project-theme-scope";
|
||||||
|
|
||||||
export default async function ProjectPage({
|
export default async function ProjectPage({
|
||||||
params,
|
params,
|
||||||
|
|
@ -18,7 +20,7 @@ export default async function ProjectPage({
|
||||||
|
|
||||||
const project = await prisma.project.findFirst({
|
const project = await prisma.project.findFirst({
|
||||||
where: { id: projectId, ...projectAccessFilter(session.user.id) },
|
where: { id: projectId, ...projectAccessFilter(session.user.id) },
|
||||||
select: { id: true, title: true },
|
select: { id: true, title: true, theme: true },
|
||||||
});
|
});
|
||||||
// Same response whether the project doesn't exist or just isn't this
|
// Same response whether the project doesn't exist or just isn't this
|
||||||
// user's -- no need to distinguish "not found" from "not yours".
|
// user's -- no need to distinguish "not found" from "not yours".
|
||||||
|
|
@ -30,12 +32,28 @@ export default async function ProjectPage({
|
||||||
]);
|
]);
|
||||||
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
|
const aiConfigured = !!(aiSettings.apiUrl && aiSettings.model);
|
||||||
|
|
||||||
|
// A project's assigned theme (null = "Default Theme", i.e. the user's
|
||||||
|
// global theme-menu choice) applies while this page is open and lifts on
|
||||||
|
// navigation -- ProjectThemeScope owns the client side. The inline script
|
||||||
|
// mirrors it on <html> before first paint on a hard load, so the first
|
||||||
|
// frame is already themed (and leaves the data-project-theme marker the
|
||||||
|
// ThemeProvider reads at hydration). `theme` is free text in the DB; the
|
||||||
|
// set of valid names is enforced by ProjectThemeSchema on write, so the
|
||||||
|
// assertion is safe.
|
||||||
|
const scopedTheme: ThemeName | null = project.theme as ThemeName | null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{scopedTheme && (
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: themeSwitchScript(scopedTheme) }} />
|
||||||
|
)}
|
||||||
|
<ProjectThemeScope projectId={project.id} />
|
||||||
<KanbanBoard
|
<KanbanBoard
|
||||||
initialCategories={board}
|
initialCategories={board}
|
||||||
projectId={project.id}
|
projectId={project.id}
|
||||||
title={project.title}
|
title={project.title}
|
||||||
aiConfigured={aiConfigured}
|
aiConfigured={aiConfigured}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -980,6 +980,34 @@ html.theme-blueprint {
|
||||||
html.theme-blueprint [data-slot="button"] {
|
html.theme-blueprint [data-slot="button"] {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Paper surfaces (dialogs, popovers, dropdown menus) carry cobalt ink:
|
||||||
|
they set `text-popover-foreground`, but the generic button and editor
|
||||||
|
styles also read `--foreground` / `--background` expecting the usual
|
||||||
|
light-theme semantics (dark ink, light surface). In blueprint those
|
||||||
|
tokens are board-level (near-white ink, cobalt surface), so ghost
|
||||||
|
buttons wash to white-on-white, outline buttons go blue-on-blue, and
|
||||||
|
the markdown editor's text is invisible on the sheet. Restoring the
|
||||||
|
paper semantics locally makes every descendant render correctly. */
|
||||||
|
html.theme-blueprint [data-slot="dialog-content"],
|
||||||
|
html.theme-blueprint [data-slot="popover-content"],
|
||||||
|
html.theme-blueprint [data-slot="dropdown-menu-content"] {
|
||||||
|
--foreground: var(--card-foreground);
|
||||||
|
--background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The sidebar and scheduled rail are cobalt board chrome with near-white
|
||||||
|
ink. The ghost buttons there (theme trigger, log out, collapse, rail
|
||||||
|
actions) reference `--muted` / `--foreground` for hover + expanded
|
||||||
|
states, which in blueprint are both paper-bright: a white-on-white
|
||||||
|
wash-out. Re-target those states to the sidebar's own accent pair:
|
||||||
|
a slightly lighter cobalt with the same near-white ink. */
|
||||||
|
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"]):hover,
|
||||||
|
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"]):focus-visible,
|
||||||
|
html.theme-blueprint .bg-sidebar :is([data-slot="button"], [data-slot="dropdown-menu-trigger"])[aria-expanded="true"] {
|
||||||
|
background-color: var(--sidebar-accent);
|
||||||
|
color: var(--sidebar-accent-foreground);
|
||||||
|
}
|
||||||
html.theme-blueprint [data-slot="button"]::before,
|
html.theme-blueprint [data-slot="button"]::before,
|
||||||
html.theme-blueprint [data-slot="button"]::after {
|
html.theme-blueprint [data-slot="button"]::after {
|
||||||
content: "";
|
content: "";
|
||||||
|
|
@ -1720,17 +1748,34 @@ html.theme-vaporwave .vw-palm--right-small {
|
||||||
that inline value (animations sit above inline styles in the cascade) --
|
that inline value (animations sit above inline styles in the cascade) --
|
||||||
the card stops following the pointer. opacity-50 is exactly the class the
|
the card stops following the pointer. opacity-50 is exactly the class the
|
||||||
card gets while dragging, so the hiccup stands down for the duration.
|
card gets while dragging, so the hiccup stands down for the duration.
|
||||||
(overflow: hidden keeps the tracking band clipped to the card while it
|
(The tracking band sweeps inside a .vw-glitch-clip clip box rendered by
|
||||||
sweeps; the hover lift uses the separate `translate` property, so the
|
GroupCard rather than relying on overflow: hidden on the card itself --
|
||||||
jitter doesn't fight it.) */
|
the card must stay overflow-visible so the progress pie, which pokes
|
||||||
|
outside the bottom-right corner, isn't cropped by the card or its
|
||||||
|
rounded-2xl corner. The hover lift uses the separate `translate`
|
||||||
|
property, so the jitter doesn't fight it.) */
|
||||||
html.theme-vaporwave [data-group-card]:not(.opacity-50) {
|
html.theme-vaporwave [data-group-card]:not(.opacity-50) {
|
||||||
overflow: hidden;
|
|
||||||
animation: vw-card-glitch 10s linear infinite;
|
animation: vw-card-glitch 10s linear infinite;
|
||||||
}
|
}
|
||||||
html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 {
|
html.theme-vaporwave [data-group-card]:not(.opacity-50) > div:first-child h3 {
|
||||||
animation: vw-title-glitch 10s linear infinite;
|
animation: vw-title-glitch 10s linear infinite;
|
||||||
}
|
}
|
||||||
html.theme-vaporwave [data-group-card]:not(.opacity-50)::after {
|
/* The band's clip box: a full-card overlay (in GroupCard) that carries the
|
||||||
|
overflow clipping, so the card itself never needs it. inset-0 +
|
||||||
|
border-radius: inherit matches the card's own padding-box clip exactly,
|
||||||
|
sweep included. Hidden in every other theme. */
|
||||||
|
.vw-glitch-clip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html.theme-vaporwave .vw-glitch-clip {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
html.theme-vaporwave [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -1756,14 +1801,14 @@ html.theme-vaporwave [data-group-card]:not(.opacity-50)::after {
|
||||||
first delay elapses, and under reduced motion, everything just sits still.) */
|
first delay elapses, and under reduced motion, everything just sits still.) */
|
||||||
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50),
|
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50),
|
||||||
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) > div:first-child h3,
|
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) > div:first-child h3,
|
||||||
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50)::after {
|
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n + 2) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
|
||||||
animation-duration: 13s;
|
animation-duration: 13s;
|
||||||
animation-delay: 3s;
|
animation-delay: 3s;
|
||||||
}
|
}
|
||||||
/* Phase C: the longest period, offset furthest. */
|
/* Phase C: the longest period, offset furthest. */
|
||||||
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50),
|
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50),
|
||||||
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) > div:first-child h3,
|
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) > div:first-child h3,
|
||||||
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50)::after {
|
html.theme-vaporwave [data-category-lane] .overflow-y-auto > div:nth-child(3n) [data-group-card]:not(.opacity-50) .vw-glitch-clip::after {
|
||||||
animation-duration: 17s;
|
animation-duration: 17s;
|
||||||
animation-delay: 7s;
|
animation-delay: 7s;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,16 @@ export function CategoryLane({ category }: { category: CategoryDTO }) {
|
||||||
// leaves enough clearance that a card's hover lift
|
// leaves enough clearance that a card's hover lift
|
||||||
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
|
// (-translate-y-0.5 in GroupCard) doesn't tuck its top stroke
|
||||||
// under the lane header above it.
|
// under the lane header above it.
|
||||||
"flex-1 space-y-2 overflow-y-auto rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
|
//
|
||||||
|
// overflow-x-clip: the lane is a fixed-width (w-72) panel whose
|
||||||
|
// cards always span it, so horizontal scrolling is never wanted.
|
||||||
|
// The only things that can spill horizontally are corner badges
|
||||||
|
// like the group card's progress pie, which deliberately poke
|
||||||
|
// past the card's right edge. `clip` (not `hidden`) keeps
|
||||||
|
// overflow-y-auto fully functional while simply discarding any
|
||||||
|
// spill, so a future badge that strays a px too far can never
|
||||||
|
// grow a stray horizontal scrollbar here.
|
||||||
|
"flex-1 space-y-2 overflow-y-auto overflow-x-clip rounded-t-lg px-2.5 pt-1.5 pb-2 transition-colors",
|
||||||
isDropTargetLane && "bg-primary/10"
|
isDropTargetLane && "bg-primary/10"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -406,7 +406,11 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
|
<TooltipContent side="top">Archive (all to-dos done)</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
<span className="ml-auto text-[11px] font-medium tabular-nums text-muted-foreground">
|
{/* pr-2.5: the corner progress pie (TodoProgressPie) hugs
|
||||||
|
the card's right edge right here, so the label keeps a
|
||||||
|
little room of its own -- together the two clear each
|
||||||
|
other without either spilling past the lane's edge. */}
|
||||||
|
<span className="ml-auto pr-2.5 text-[11px] font-medium tabular-nums text-muted-foreground">
|
||||||
{completedCount}/{group.todos.length} done
|
{completedCount}/{group.todos.length} done
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -421,6 +425,14 @@ export function GroupCard({ group }: { group: GroupDTO }) {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Vaporwave's VHS tracking band sweeps inside this clip box (see
|
||||||
|
.vw-glitch-clip in app/globals.css) instead of the card itself
|
||||||
|
using overflow: hidden -- the card must stay overflow-visible so
|
||||||
|
the progress pie can poke past the bottom-right corner without
|
||||||
|
being clipped by it or the card's rounded corner. display:none
|
||||||
|
(and thus inert) in every other theme. */}
|
||||||
|
<div className="vw-glitch-clip" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editingTodo && (
|
{editingTodo && (
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import { EmptyState } from "@/components/board/empty-state";
|
||||||
import { ViewSwitcher } from "@/components/board/view-switcher";
|
import { ViewSwitcher } from "@/components/board/view-switcher";
|
||||||
import { SummaryButton } from "@/components/board/summary-button";
|
import { SummaryButton } from "@/components/board/summary-button";
|
||||||
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
import { TodoCreateDialog } from "@/components/board/todo-create-dialog";
|
||||||
|
import { ProjectThemePicker } from "@/components/projects/project-theme-picker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type { CategoryDTO, GroupDTO } from "@/types/board";
|
import type { CategoryDTO, GroupDTO } from "@/types/board";
|
||||||
|
|
@ -146,7 +147,12 @@ function Board({
|
||||||
<LayoutDashboard className="size-5" />
|
<LayoutDashboard className="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
|
<h1 className="truncate font-heading text-xl font-bold leading-tight">{title}</h1>
|
||||||
|
{/* Per-project theme picker, to the right of the project's
|
||||||
|
name -- only projects have their own theme assignment. */}
|
||||||
|
{projectId && <ProjectThemePicker projectId={projectId} />}
|
||||||
|
</div>
|
||||||
<p className="truncate text-[13px] text-muted-foreground">
|
<p className="truncate text-[13px] text-muted-foreground">
|
||||||
{categories.length === 0
|
{categories.length === 0
|
||||||
? "Add a lane to get started"
|
? "Add a lane to get started"
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,20 @@ export function TodoProgressPie({
|
||||||
// context (see hover:-translate-y-0.5 in GroupCard), so
|
// context (see hover:-translate-y-0.5 in GroupCard), so
|
||||||
// without an explicit z-index here it paints on top and crops
|
// without an explicit z-index here it paints on top and crops
|
||||||
// the badge's overlapping edge.
|
// the badge's overlapping edge.
|
||||||
className="absolute -right-1.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
// -right-2.5: the badge's horizontal position is a budget, not
|
||||||
|
// a taste call. From the card's right border there are exactly
|
||||||
|
// 10px of lane (CategoryLane's px-2.5) before the lane's own
|
||||||
|
// clip edge -- or its vertical scrollbar, in classic-scrollbar
|
||||||
|
// browsers, which sits at the same 10px mark and paints over
|
||||||
|
// anything past it. So the badge may stick out at most ~7px
|
||||||
|
// (10px offset from the card's *padding* edge minus the 3px
|
||||||
|
// border) to stay clear of both. In exchange the "N/N done"
|
||||||
|
// footer text carries pr-2.5, which hands this badge the room
|
||||||
|
// it needs to clear the text (see the span in GroupCard).
|
||||||
|
// Moving it further out looks like it "fits" until it doesn't:
|
||||||
|
// past the 10px mark the lane clips the badge's edge or grows
|
||||||
|
// a stray horizontal scrollbar.
|
||||||
|
className="absolute -right-2.5 -bottom-1.5 z-10 size-7 shrink-0 cursor-default rounded-full border-2 shadow-sm outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||||
style={{
|
style={{
|
||||||
borderColor: accentColor,
|
borderColor: accentColor,
|
||||||
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,
|
background: `conic-gradient(${accentColor} ${pct}%, ${emptyColor} ${pct}% 100%)`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,145 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Check, ChevronDown, Monitor, Palette } from "lucide-react";
|
||||||
|
import type { LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { useProjects } from "@/components/projects/projects-context";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
LIGHT_OPTIONS,
|
||||||
|
DARK_OPTIONS,
|
||||||
|
type ThemeOption,
|
||||||
|
} from "@/components/theme/theme-toggle";
|
||||||
|
import { useTheme, type ThemeName } from "@/components/theme/theme-provider";
|
||||||
|
|
||||||
|
const DEFAULT_LABEL = "Default theme";
|
||||||
|
|
||||||
|
/** One named-theme row. Uses DropdownMenuItem (a real menu item) so the
|
||||||
|
* menu closes on selection, matching the app's other menus. */
|
||||||
|
function ThemeOptionRow({
|
||||||
|
option,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
option: ThemeOption;
|
||||||
|
active: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const Icon: LucideIcon = option.icon;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onSelect}
|
||||||
|
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
||||||
|
style={{ backgroundColor: option.swatch }}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{active && <Check className="size-4 shrink-0" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-project theme picker, rendered to the right of a project's name
|
||||||
|
* (see components/board/kanban-board.tsx).
|
||||||
|
*
|
||||||
|
* "Default theme" (project.theme = null) means "whatever the user assigned
|
||||||
|
* in the global theme menu" -- the same value this picker's checkmark and
|
||||||
|
* label track, live. Picking a named theme stores it on the project; the
|
||||||
|
* project page then scopes the app to that theme while it's open
|
||||||
|
* (components/theme/project-theme-scope.tsx).
|
||||||
|
*/
|
||||||
|
export function ProjectThemePicker({ projectId }: { projectId: string }) {
|
||||||
|
const { projects, setProjectTheme } = useProjects();
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const project = projects.find((p) => p.id === projectId);
|
||||||
|
if (!project) return null;
|
||||||
|
|
||||||
|
const theme: ThemeName | null = project.theme;
|
||||||
|
const activeOption = theme
|
||||||
|
? [...LIGHT_OPTIONS, ...DARK_OPTIONS].find((o) => o.value === theme)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1.5 rounded-lg px-2 text-[13px] text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label="Choose this project's theme"
|
||||||
|
>
|
||||||
|
<Palette className="size-3.5" />
|
||||||
|
<span className="max-w-36 truncate">
|
||||||
|
{theme ? activeOption?.label ?? DEFAULT_LABEL : DEFAULT_LABEL}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DropdownMenuContent align="start" className="w-60">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setProjectTheme(projectId, null)}
|
||||||
|
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Monitor className="size-4 text-muted-foreground" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block truncate">Default theme</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
Follows the global theme menu
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-auto size-3 shrink-0 rounded-full ring-1 ring-foreground/15"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(90deg, #4f56e0 50%, #7c83f2 50%)",
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{theme === null && <Check className="size-4 shrink-0" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>Light</DropdownMenuLabel>
|
||||||
|
{LIGHT_OPTIONS.map((option) => (
|
||||||
|
<ThemeOptionRow
|
||||||
|
key={option.value}
|
||||||
|
option={option}
|
||||||
|
active={theme === option.value}
|
||||||
|
onSelect={() => setProjectTheme(projectId, option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuLabel>Dark</DropdownMenuLabel>
|
||||||
|
{DARK_OPTIONS.map((option) => (
|
||||||
|
<ThemeOptionRow
|
||||||
|
key={option.value}
|
||||||
|
option={option}
|
||||||
|
active={theme === option.value}
|
||||||
|
onSelect={() => setProjectTheme(projectId, option.value)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<p className="px-2 py-1.5 text-xs leading-snug text-muted-foreground">
|
||||||
|
{theme === null
|
||||||
|
? `Currently following your global theme (now: ${
|
||||||
|
resolvedTheme.charAt(0).toUpperCase() + resolvedTheme.slice(1)
|
||||||
|
}).`
|
||||||
|
: `This theme applies only while "${project.title}" is open.`}
|
||||||
|
</p>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,13 @@
|
||||||
import { createContext, useContext, useState, useCallback } from "react";
|
import { createContext, useContext, useState, useCallback } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import type { ThemeName } from "@/lib/themes";
|
||||||
import type { ProjectDTO } from "@/types/project";
|
import type { ProjectDTO } from "@/types/project";
|
||||||
import {
|
import {
|
||||||
createProject,
|
createProject,
|
||||||
renameProject as renameProjectAction,
|
renameProject as renameProjectAction,
|
||||||
deleteProject as deleteProjectAction,
|
deleteProject as deleteProjectAction,
|
||||||
|
setProjectTheme as setProjectThemeAction,
|
||||||
} from "@/lib/actions/projects";
|
} from "@/lib/actions/projects";
|
||||||
|
|
||||||
interface ProjectsContextValue {
|
interface ProjectsContextValue {
|
||||||
|
|
@ -15,6 +17,10 @@ interface ProjectsContextValue {
|
||||||
addProject: (title: string) => Promise<ProjectDTO | undefined>;
|
addProject: (title: string) => Promise<ProjectDTO | undefined>;
|
||||||
renameProject: (projectId: string, title: string) => Promise<void>;
|
renameProject: (projectId: string, title: string) => Promise<void>;
|
||||||
removeProject: (projectId: string) => Promise<boolean>;
|
removeProject: (projectId: string) => Promise<boolean>;
|
||||||
|
/** Assign a project theme, or pass null for "Default Theme" (the user's
|
||||||
|
* global preference). Optimistic: the new value lands immediately and
|
||||||
|
* rolls back on failure. */
|
||||||
|
setProjectTheme: (projectId: string, theme: ThemeName | null) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProjectsContext = createContext<ProjectsContextValue | null>(null);
|
const ProjectsContext = createContext<ProjectsContextValue | null>(null);
|
||||||
|
|
@ -75,8 +81,27 @@ export function ProjectsProvider({
|
||||||
return true;
|
return true;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setProjectTheme = useCallback(
|
||||||
|
async (projectId: string, theme: ThemeName | null) => {
|
||||||
|
let prevState: ProjectDTO[] = [];
|
||||||
|
setProjects((prev) => {
|
||||||
|
prevState = prev;
|
||||||
|
return prev.map((p) => (p.id === projectId ? { ...p, theme } : p));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await setProjectThemeAction(projectId, theme);
|
||||||
|
} catch {
|
||||||
|
setProjects(prevState);
|
||||||
|
toast.error("Couldn't save the project theme. Try again.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProjectsContext.Provider value={{ projects, addProject, renameProject, removeProject }}>
|
<ProjectsContext.Provider
|
||||||
|
value={{ projects, addProject, renameProject, removeProject, setProjectTheme }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</ProjectsContext.Provider>
|
</ProjectsContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
import { useProjects } from "@/components/projects/projects-context";
|
||||||
|
import { useThemeScope } from "./theme-provider";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporarily scopes the app's theme (see ThemeProvider in
|
||||||
|
* theme-provider.tsx) to the project's assigned theme while that project's
|
||||||
|
* page is on screen. The theme lifts the moment you navigate away, so the
|
||||||
|
* rest of the app (Home, other projects) keeps the user's global
|
||||||
|
* preference.
|
||||||
|
*
|
||||||
|
* The theme is read from the live ProjectsContext -- not a page prop -- so
|
||||||
|
* picking a new theme in the picker transforms the page *immediately*,
|
||||||
|
* without waiting for the server re-render after the action settles.
|
||||||
|
* `null` (the "Default theme" state) is a no-op: the page follows the
|
||||||
|
* global theme, and changing the global theme from the theme menu while on
|
||||||
|
* the page takes effect here too.
|
||||||
|
*
|
||||||
|
* The provider never learns the preference from this component (the global
|
||||||
|
* `theme` state and localStorage are untouched).
|
||||||
|
*/
|
||||||
|
export function ProjectThemeScope({ projectId }: { projectId: string }) {
|
||||||
|
const { projects } = useProjects();
|
||||||
|
const { setScope } = useThemeScope();
|
||||||
|
|
||||||
|
const theme = projects.find((p) => p.id === projectId)?.theme ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setScope(theme);
|
||||||
|
return () => setScope(null);
|
||||||
|
}, [theme, setScope]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,19 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
THEMES,
|
||||||
|
THEME_CLASSES,
|
||||||
|
DARK_SURFACES,
|
||||||
|
themeColorScheme,
|
||||||
|
type ThemeName,
|
||||||
|
type RawTheme,
|
||||||
|
} from "@/lib/themes";
|
||||||
|
|
||||||
|
// Re-exported so existing imports keep working.
|
||||||
|
export { THEMES, DARK_SURFACES };
|
||||||
|
export type { ThemeName, RawTheme };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App theme provider (replaces next-themes).
|
* App theme provider (replaces next-themes).
|
||||||
*
|
*
|
||||||
|
|
@ -23,73 +36,25 @@ import type { ReactNode } from "react";
|
||||||
*
|
*
|
||||||
* Semantics (kept compatible for all existing consumers):
|
* Semantics (kept compatible for all existing consumers):
|
||||||
* - `theme` the stored preference: one of THEMES, or "system"
|
* - `theme` the stored preference: one of THEMES, or "system"
|
||||||
* - `resolvedTheme` the theme actually applied (system resolved via
|
* - `resolvedTheme` the theme actually applied, always one of THEMES
|
||||||
* prefers-color-scheme), always one of THEMES
|
|
||||||
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
|
* - html classes .theme-default | .theme-sunset | ... (see globals.css)
|
||||||
|
*
|
||||||
|
* Theme scoping (per-project themes):
|
||||||
|
* A subtree can temporarily override the global preference with
|
||||||
|
* `useThemeScope().setScope(name)` -- the project pages use this so a
|
||||||
|
* project's assigned theme applies while its page is open and lifts on
|
||||||
|
* navigation (see components/theme/project-theme-scope.tsx). While a scope
|
||||||
|
* is active it wins over `theme` for `resolvedTheme` and the DOM; the
|
||||||
|
* stored global preference is never touched, so changing the global theme
|
||||||
|
* from the theme menu while inside a scoped project is fine (it applies
|
||||||
|
* everywhere *except* the scoped page, which keeps its own theme).
|
||||||
|
* The provider also picks up a `data-project-theme` marker on <html> at
|
||||||
|
* mount -- set by the project page's no-FOUC inline script -- so a scoped
|
||||||
|
* theme is in effect from the very first frame of a hard page load.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* Order is the picker order: all the light looks, then the dark looks. */
|
|
||||||
export const THEMES = [
|
|
||||||
"default",
|
|
||||||
"sunset",
|
|
||||||
"meadow",
|
|
||||||
"honey",
|
|
||||||
"rose",
|
|
||||||
"lavender",
|
|
||||||
"slate",
|
|
||||||
"blueprint",
|
|
||||||
"vaporwave",
|
|
||||||
"notebook",
|
|
||||||
"dark",
|
|
||||||
"ocean",
|
|
||||||
"pine",
|
|
||||||
"plum",
|
|
||||||
"midnight",
|
|
||||||
"ember",
|
|
||||||
"rosewood",
|
|
||||||
"cyberpunk",
|
|
||||||
"starfield",
|
|
||||||
] as const;
|
|
||||||
export type ThemeName = (typeof THEMES)[number];
|
|
||||||
export type RawTheme = ThemeName | "system";
|
|
||||||
|
|
||||||
const STORAGE_KEY = "theme";
|
const STORAGE_KEY = "theme";
|
||||||
const THEME_CLASSES: Record<ThemeName, string> = {
|
|
||||||
default: "theme-default",
|
|
||||||
sunset: "theme-sunset",
|
|
||||||
meadow: "theme-meadow",
|
|
||||||
honey: "theme-honey",
|
|
||||||
rose: "theme-rose",
|
|
||||||
lavender: "theme-lavender",
|
|
||||||
slate: "theme-slate",
|
|
||||||
blueprint: "theme-blueprint",
|
|
||||||
vaporwave: "theme-vaporwave",
|
|
||||||
notebook: "theme-notebook",
|
|
||||||
dark: "dark",
|
|
||||||
ocean: "ocean",
|
|
||||||
pine: "theme-pine",
|
|
||||||
plum: "theme-plum",
|
|
||||||
midnight: "theme-midnight",
|
|
||||||
ember: "theme-ember",
|
|
||||||
rosewood: "theme-rosewood",
|
|
||||||
cyberpunk: "theme-cyberpunk",
|
|
||||||
starfield: "theme-starfield",
|
|
||||||
};
|
|
||||||
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
const ALL_CLASSES = Object.values(THEME_CLASSES);
|
||||||
/* Themes whose *surfaces* are dark (see app/globals.css). The single source
|
|
||||||
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
|
|
||||||
* color-scheme, and the `dark:` custom-variant in globals.css. */
|
|
||||||
export const DARK_SURFACES: ThemeName[] = [
|
|
||||||
"dark",
|
|
||||||
"ocean",
|
|
||||||
"pine",
|
|
||||||
"plum",
|
|
||||||
"midnight",
|
|
||||||
"ember",
|
|
||||||
"rosewood",
|
|
||||||
"cyberpunk",
|
|
||||||
"starfield",
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
theme: RawTheme;
|
theme: RawTheme;
|
||||||
|
|
@ -98,12 +63,21 @@ interface ThemeContextValue {
|
||||||
themes: readonly ThemeName[];
|
themes: readonly ThemeName[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThemeScopeValue {
|
||||||
|
scope: ThemeName | null;
|
||||||
|
setScope: (scope: ThemeName | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue>({
|
const ThemeContext = createContext<ThemeContextValue>({
|
||||||
theme: "system",
|
theme: "system",
|
||||||
resolvedTheme: "default",
|
resolvedTheme: "default",
|
||||||
setTheme: () => {},
|
setTheme: () => {},
|
||||||
themes: THEMES,
|
themes: THEMES,
|
||||||
});
|
});
|
||||||
|
const ThemeScopeContext = createContext<ThemeScopeValue>({
|
||||||
|
scope: null,
|
||||||
|
setScope: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
function systemPrefersDark(): boolean {
|
function systemPrefersDark(): boolean {
|
||||||
return (
|
return (
|
||||||
|
|
@ -123,15 +97,25 @@ export function resolveTheme(theme: RawTheme): ThemeName {
|
||||||
return resolve(theme);
|
return resolve(theme);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyToDom(theme: RawTheme) {
|
function applyToDom(name: ThemeName) {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const name = resolve(theme);
|
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
// Also drop "light": a class next-themes applied to <html> in earlier
|
// Also drop "light": a class next-themes applied to <html> in earlier
|
||||||
// versions, harmless but stale.
|
// versions, harmless but stale.
|
||||||
root.classList.remove(...ALL_CLASSES, "light");
|
root.classList.remove(...ALL_CLASSES, "light");
|
||||||
root.classList.add(THEME_CLASSES[name]);
|
root.classList.add(THEME_CLASSES[name]);
|
||||||
root.style.colorScheme = DARK_SURFACES.includes(name) ? "dark" : "light";
|
root.style.colorScheme = themeColorScheme(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The project page's no-FOUC script leaves this marker behind (see
|
||||||
|
* themeSwitchScript in lib/themes.ts); read it once at mount so the first
|
||||||
|
* frame of a hard load is already scoped. */
|
||||||
|
function readInitialScope(): ThemeName | null {
|
||||||
|
if (typeof document === "undefined") return null;
|
||||||
|
const marker = document.documentElement.dataset.projectTheme;
|
||||||
|
return marker && (THEMES as readonly string[]).includes(marker)
|
||||||
|
? (marker as ThemeName)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ThemeProvider({
|
export function ThemeProvider({
|
||||||
|
|
@ -146,6 +130,11 @@ export function ThemeProvider({
|
||||||
// The inline script in app/layout.tsx already applied the right classes
|
// The inline script in app/layout.tsx already applied the right classes
|
||||||
// before first paint, so there is no visible jump either way.
|
// before first paint, so there is no visible jump either way.
|
||||||
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
const [theme, setThemeState] = useState<RawTheme>(defaultTheme);
|
||||||
|
// Starts null (matching the server render); the project-page marker is
|
||||||
|
// applied in a layout effect below, before paint.
|
||||||
|
const [scope, setScope] = useState<ThemeName | null>(null);
|
||||||
|
|
||||||
|
const resolvedTheme: ThemeName = scope ?? resolve(theme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -156,21 +145,33 @@ export function ThemeProvider({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Keep the DOM in sync whenever the preference changes -- synchronously
|
// Pick up a project's no-FOUC marker (hard page load). Runs in the same
|
||||||
|
// pre-paint window as the apply effect below, so the global-theme flash
|
||||||
|
// it may cause is never visible. (Mount-time sync from an external
|
||||||
|
// source, same pattern as the localStorage read above.)
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const initialScope = readInitialScope();
|
||||||
|
if (initialScope) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- mount-time sync from the <html> marker, see above
|
||||||
|
setScope(initialScope);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the DOM in sync whenever the resolved theme changes -- synchronously
|
||||||
// (layout effect) so the CSS classes land in the same frame as the
|
// (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.
|
// JS-driven colors that derive from this state: no flash of stale colors.
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
applyToDom(theme);
|
applyToDom(resolvedTheme);
|
||||||
}, [theme]);
|
}, [resolvedTheme]);
|
||||||
|
|
||||||
// Follow the OS while in "system" mode.
|
// Follow the OS while in "system" mode (and nothing is scoped).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (theme !== "system" || !window.matchMedia) return;
|
if (scope || theme !== "system" || !window.matchMedia) return;
|
||||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
const onChange = () => applyToDom("system");
|
const onChange = () => applyToDom(resolve("system"));
|
||||||
mq.addEventListener("change", onChange);
|
mq.addEventListener("change", onChange);
|
||||||
return () => mq.removeEventListener("change", onChange);
|
return () => mq.removeEventListener("change", onChange);
|
||||||
}, [theme]);
|
}, [scope, theme]);
|
||||||
|
|
||||||
// Keep multiple tabs in sync.
|
// Keep multiple tabs in sync.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -191,13 +192,25 @@ export function ThemeProvider({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value = useMemo<ThemeContextValue>(
|
const value = useMemo<ThemeContextValue>(
|
||||||
() => ({ theme, resolvedTheme: resolve(theme), setTheme, themes: THEMES }),
|
() => ({ theme, resolvedTheme, setTheme, themes: THEMES }),
|
||||||
[theme, setTheme],
|
[theme, resolvedTheme, setTheme],
|
||||||
|
);
|
||||||
|
const scopeValue = useMemo<ThemeScopeValue>(
|
||||||
|
() => ({ scope, setScope }),
|
||||||
|
[scope],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return (
|
||||||
|
<ThemeScopeContext.Provider value={scopeValue}>
|
||||||
|
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
|
||||||
|
</ThemeScopeContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
export function useTheme(): ThemeContextValue {
|
||||||
return useContext(ThemeContext);
|
return useContext(ThemeContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useThemeScope(): ThemeScopeValue {
|
||||||
|
return useContext(ThemeScopeContext);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,13 @@ import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
type ThemeOption = {
|
export type ThemeOption = {
|
||||||
value: ThemeName;
|
value: ThemeName;
|
||||||
label: string;
|
label: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
|
|
@ -45,8 +46,9 @@ type ThemeOption = {
|
||||||
|
|
||||||
// The looks, grouped by surface. Each value is one next-themes value (see
|
// The looks, grouped by surface. Each value is one next-themes value (see
|
||||||
// app/layout.tsx for the class mapping); the little dot is the theme's own
|
// app/layout.tsx for the class mapping); the little dot is the theme's own
|
||||||
// accent so the picker previews the vibe.
|
// accent so the picker previews the vibe. Exported for reuse by other
|
||||||
const LIGHT_OPTIONS: ThemeOption[] = [
|
// theme pickers (e.g. components/projects/project-theme-picker.tsx).
|
||||||
|
export const LIGHT_OPTIONS: ThemeOption[] = [
|
||||||
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
|
{ value: "default", label: "Default", icon: Sun, swatch: "#4f56e0" },
|
||||||
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
|
{ value: "sunset", label: "Sunset", icon: Sunset, swatch: "#c2410c" },
|
||||||
{ value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" },
|
{ value: "meadow", label: "Meadow", icon: Leaf, swatch: "#059669" },
|
||||||
|
|
@ -58,7 +60,7 @@ const LIGHT_OPTIONS: ThemeOption[] = [
|
||||||
{ value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" },
|
{ value: "vaporwave", label: "Vaporwave", icon: Disc3, swatch: "#e879f9" },
|
||||||
{ value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" },
|
{ value: "notebook", label: "Notebook", icon: NotebookText, swatch: "#3b5fc4" },
|
||||||
];
|
];
|
||||||
const DARK_OPTIONS: ThemeOption[] = [
|
export const DARK_OPTIONS: ThemeOption[] = [
|
||||||
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
|
{ value: "dark", label: "Dark", icon: Moon, swatch: "#7c83f2" },
|
||||||
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
|
{ value: "ocean", label: "Ocean", icon: Waves, swatch: "#5eead4" },
|
||||||
{ value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" },
|
{ value: "pine", label: "Pine", icon: TreePine, swatch: "#34d399" },
|
||||||
|
|
@ -69,9 +71,9 @@ const DARK_OPTIONS: ThemeOption[] = [
|
||||||
{ value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" },
|
{ value: "cyberpunk", label: "Cyberpunk", icon: CircuitBoard, swatch: "#ff2bd6" },
|
||||||
{ value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" },
|
{ value: "starfield", label: "Starfield", icon: Orbit, swatch: "#38bdf8" },
|
||||||
];
|
];
|
||||||
const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
|
export const ALL_OPTIONS = [...LIGHT_OPTIONS, ...DARK_OPTIONS];
|
||||||
|
|
||||||
function OptionRow({
|
export function OptionRow({
|
||||||
option,
|
option,
|
||||||
active,
|
active,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
|
@ -82,20 +84,19 @@ function OptionRow({
|
||||||
}) {
|
}) {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
return (
|
return (
|
||||||
<button
|
<DropdownMenuItem
|
||||||
type="button"
|
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
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"
|
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<Icon className="size-4 text-muted-foreground group-focus:text-current" />
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
<span
|
<span
|
||||||
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
className="ml-auto size-3 rounded-full ring-1 ring-foreground/15"
|
||||||
style={{ backgroundColor: option.swatch }}
|
style={{ backgroundColor: option.swatch }}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{active && <Check className="absolute right-2 -mr-4 size-4" />}
|
{active && <Check className="size-4 shrink-0" />}
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,10 +145,9 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<button
|
<DropdownMenuItem
|
||||||
type="button"
|
|
||||||
onClick={() => setTheme("system")}
|
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"
|
className="gap-2.5 px-2 py-1.5 text-sm cursor-pointer"
|
||||||
>
|
>
|
||||||
<Monitor className="size-4 text-muted-foreground" />
|
<Monitor className="size-4 text-muted-foreground" />
|
||||||
<span>Match system</span>
|
<span>Match system</span>
|
||||||
|
|
@ -158,8 +158,8 @@ export function ThemeToggle({ collapsed }: { collapsed?: boolean }) {
|
||||||
}}
|
}}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
{isSystem && <Check className="absolute right-2 -mr-4 size-4" />}
|
{isSystem && <Check className="size-4 shrink-0" />}
|
||||||
</button>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useTheme, resolveTheme, DARK_SURFACES } from "./theme-provider";
|
import { useTheme, DARK_SURFACES } from "./theme-provider";
|
||||||
import { getGroupColor, getThemedGroupColor, type GroupColor } from "@/lib/colors";
|
import { getGroupColor, getThemedGroupColor, type GroupColor } from "@/lib/colors";
|
||||||
|
|
||||||
// The themes whose *surfaces* are dark (see app/globals.css) come from the
|
// The themes whose *surfaces* are dark (see app/globals.css) come from the
|
||||||
|
|
@ -24,9 +24,11 @@ function useMounted(): boolean {
|
||||||
* Midnight, Ember, Rosewood, Cyberpunk).
|
* Midnight, Ember, Rosewood, Cyberpunk).
|
||||||
*
|
*
|
||||||
* Reactive to UI theme switches: the value is derived from the provider's
|
* Reactive to UI theme switches: the value is derived from the provider's
|
||||||
* theme state (the single source of truth), so the moment `setTheme` fires,
|
* `resolvedTheme` -- the single source of truth for what's actually
|
||||||
* every consumer re-renders with the new variant in the same commit -- no
|
* applied, including a project's scoped theme (see theme-provider) -- so
|
||||||
* stale card colors, no manual refresh needed.
|
* the moment `setTheme` fires (or a scope lifts), every consumer
|
||||||
|
* re-renders with the new variant in the same commit: no stale card
|
||||||
|
* colors, no manual refresh needed.
|
||||||
*
|
*
|
||||||
* Hydration-safe: until mounted we always report "light", matching the
|
* Hydration-safe: until mounted we always report "light", matching the
|
||||||
* server render (the no-FOUC script in app/layout.tsx covers the CSS side
|
* server render (the no-FOUC script in app/layout.tsx covers the CSS side
|
||||||
|
|
@ -34,9 +36,9 @@ function useMounted(): boolean {
|
||||||
*/
|
*/
|
||||||
export function isDarkTheme(): boolean {
|
export function isDarkTheme(): boolean {
|
||||||
const mounted = useMounted();
|
const mounted = useMounted();
|
||||||
const { theme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
return DARK_SURFACES.includes(resolveTheme(theme));
|
return DARK_SURFACES.includes(resolvedTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when the "cyberpunk" theme is active. Used to swap the neon
|
/** True when the "cyberpunk" theme is active. Used to swap the neon
|
||||||
|
|
@ -44,9 +46,9 @@ export function isDarkTheme(): boolean {
|
||||||
*/
|
*/
|
||||||
export function isCyberpunkTheme(): boolean {
|
export function isCyberpunkTheme(): boolean {
|
||||||
const mounted = useMounted();
|
const mounted = useMounted();
|
||||||
const { theme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
return resolveTheme(theme) === "cyberpunk";
|
return resolvedTheme === "cyberpunk";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -60,9 +62,9 @@ export function isCyberpunkTheme(): boolean {
|
||||||
*/
|
*/
|
||||||
export function useGroupColor(key: string): GroupColor {
|
export function useGroupColor(key: string): GroupColor {
|
||||||
const mounted = useMounted();
|
const mounted = useMounted();
|
||||||
const { theme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
if (!mounted) return getGroupColor(key);
|
if (!mounted) return getGroupColor(key);
|
||||||
return getThemedGroupColor(resolveTheme(theme), key);
|
return getThemedGroupColor(resolvedTheme, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUserId } from "@/lib/auth-helpers";
|
import { requireUserId } from "@/lib/auth-helpers";
|
||||||
import { projectAccessFilter } from "@/lib/access";
|
import { projectAccessFilter } from "@/lib/access";
|
||||||
import { ProjectTitleSchema } from "@/lib/validation/project";
|
import { ProjectTitleSchema, ProjectThemeSchema } from "@/lib/validation/project";
|
||||||
import type { ProjectDTO } from "@/types/project";
|
import type { ProjectDTO } from "@/types/project";
|
||||||
|
|
||||||
export async function createProject(title: string): Promise<ProjectDTO> {
|
export async function createProject(title: string): Promise<ProjectDTO> {
|
||||||
|
|
@ -17,7 +17,29 @@ export async function createProject(title: string): Promise<ProjectDTO> {
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/projects");
|
revalidatePath("/projects");
|
||||||
return { id: project.id, title: project.title };
|
return { id: project.id, title: project.title, theme: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns a theme to a project (or clears it back to "Default Theme" with
|
||||||
|
* null, which follows the user's global theme preference). Only the
|
||||||
|
* project's own page is themed -- see components/theme/project-theme-scope.tsx.
|
||||||
|
*/
|
||||||
|
export async function setProjectTheme(
|
||||||
|
projectId: string,
|
||||||
|
theme: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = await requireUserId();
|
||||||
|
const parsed = ProjectThemeSchema.parse(theme);
|
||||||
|
|
||||||
|
const { count } = await prisma.project.updateMany({
|
||||||
|
where: { id: projectId, ...projectAccessFilter(userId) },
|
||||||
|
data: { theme: parsed },
|
||||||
|
});
|
||||||
|
if (count === 0) throw new Error("Project not found");
|
||||||
|
|
||||||
|
revalidatePath("/projects");
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renameProject(projectId: string, title: string): Promise<void> {
|
export async function renameProject(projectId: string, title: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
/**
|
||||||
|
* Isomorphic theme metadata.
|
||||||
|
*
|
||||||
|
* Deliberately framework-free: it is imported from server code (project
|
||||||
|
* theme validation in lib/actions/projects.ts, the no-FOUC script rendered
|
||||||
|
* by the project page) *and* client code (theme provider, pickers), so it
|
||||||
|
* must stay free of React/DOM imports. What *applies* themes to the DOM
|
||||||
|
* lives in components/theme/theme-provider.tsx; the display options
|
||||||
|
* (labels, icons, swatches) live in components/theme/theme-toggle.tsx.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Order is the picker order: all the light looks, then the dark looks. */
|
||||||
|
export const THEMES = [
|
||||||
|
"default",
|
||||||
|
"sunset",
|
||||||
|
"meadow",
|
||||||
|
"honey",
|
||||||
|
"rose",
|
||||||
|
"lavender",
|
||||||
|
"slate",
|
||||||
|
"blueprint",
|
||||||
|
"vaporwave",
|
||||||
|
"notebook",
|
||||||
|
"dark",
|
||||||
|
"ocean",
|
||||||
|
"pine",
|
||||||
|
"plum",
|
||||||
|
"midnight",
|
||||||
|
"ember",
|
||||||
|
"rosewood",
|
||||||
|
"cyberpunk",
|
||||||
|
"starfield",
|
||||||
|
] as const;
|
||||||
|
export type ThemeName = (typeof THEMES)[number];
|
||||||
|
export type RawTheme = ThemeName | "system";
|
||||||
|
|
||||||
|
/** Theme name -> class applied to <html> (see app/globals.css). */
|
||||||
|
export const THEME_CLASSES: Record<ThemeName, string> = {
|
||||||
|
default: "theme-default",
|
||||||
|
sunset: "theme-sunset",
|
||||||
|
meadow: "theme-meadow",
|
||||||
|
honey: "theme-honey",
|
||||||
|
rose: "theme-rose",
|
||||||
|
lavender: "theme-lavender",
|
||||||
|
slate: "theme-slate",
|
||||||
|
blueprint: "theme-blueprint",
|
||||||
|
vaporwave: "theme-vaporwave",
|
||||||
|
notebook: "theme-notebook",
|
||||||
|
dark: "dark",
|
||||||
|
ocean: "ocean",
|
||||||
|
pine: "theme-pine",
|
||||||
|
plum: "theme-plum",
|
||||||
|
midnight: "theme-midnight",
|
||||||
|
ember: "theme-ember",
|
||||||
|
rosewood: "theme-rosewood",
|
||||||
|
cyberpunk: "theme-cyberpunk",
|
||||||
|
starfield: "theme-starfield",
|
||||||
|
};
|
||||||
|
/** Themes whose *surfaces* are dark (see app/globals.css). The single source
|
||||||
|
* of truth for every dark-surface check: isDarkTheme, the sonner toaster,
|
||||||
|
* color-scheme, and the `dark:` custom-variant in globals.css. */
|
||||||
|
export const DARK_SURFACES: ThemeName[] = [
|
||||||
|
"dark",
|
||||||
|
"ocean",
|
||||||
|
"pine",
|
||||||
|
"plum",
|
||||||
|
"midnight",
|
||||||
|
"ember",
|
||||||
|
"rosewood",
|
||||||
|
"cyberpunk",
|
||||||
|
"starfield",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function themeColorScheme(name: ThemeName): "light" | "dark" {
|
||||||
|
return DARK_SURFACES.includes(name) ? "dark" : "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline-script source that switches <html> over to `theme` immediately
|
||||||
|
* (before first paint), the same way the global no-FOUC script in
|
||||||
|
* app/layout.tsx works, and leaves a `data-project-theme` marker behind so
|
||||||
|
* the ThemeProvider can pick the theme up as its initial scope at
|
||||||
|
* hydration. Render it from a server component with
|
||||||
|
* `dangerouslySetInnerHTML`.
|
||||||
|
*/
|
||||||
|
export function themeSwitchScript(theme: ThemeName): string {
|
||||||
|
const classes = JSON.stringify([
|
||||||
|
...Object.values(THEME_CLASSES),
|
||||||
|
"light",
|
||||||
|
]);
|
||||||
|
return `try{var r=document.documentElement;var c=${classes};for(var i=0;i<c.length;i++){r.classList.remove(c[i]);}r.classList.add(${JSON.stringify(
|
||||||
|
THEME_CLASSES[theme]
|
||||||
|
)});r.style.colorScheme=${JSON.stringify(
|
||||||
|
themeColorScheme(theme)
|
||||||
|
)};r.dataset.projectTheme=${JSON.stringify(theme)};}catch(e){}`;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,10 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { THEMES } from "@/lib/themes";
|
||||||
|
|
||||||
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60);
|
export const ProjectTitleSchema = z.string().trim().min(1, "Title is required").max(60);
|
||||||
|
|
||||||
|
/** A project's assigned theme: one of the known themes, or null for
|
||||||
|
* "Default Theme" (follow the user's global theme preference). */
|
||||||
|
export const ProjectThemeSchema = z.union([z.enum(THEMES), z.null()]);
|
||||||
|
export type ProjectTheme = z.infer<typeof ProjectThemeSchema>;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Project" ADD COLUMN "theme" TEXT;
|
||||||
|
|
@ -61,6 +61,11 @@ model Project {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
ownerId String
|
ownerId String
|
||||||
|
// The theme assigned to this project (one of lib/themes' THEMES). Null =
|
||||||
|
// "Default Theme": follow whatever the user picked in the global theme
|
||||||
|
// menu. Applied only while the project's own page is open (see
|
||||||
|
// components/theme/project-theme-scope.tsx).
|
||||||
|
theme String?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
|
import type { ThemeName } from "@/lib/themes";
|
||||||
|
|
||||||
export interface ProjectDTO {
|
export interface ProjectDTO {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
// The theme assigned to this project (see components/theme/). Null =
|
||||||
|
// "Default Theme": follow whatever the user picked in the global theme
|
||||||
|
// menu, like every other page.
|
||||||
|
theme: ThemeName | null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue