Implement tasks 25, 26 + completion visibility setting
Task 25: Multiple dashboards — dashboards table + migration, /d/[slug] route, root redirect, dashboard tabs in nav with create/rename/delete/ set-default. Task 26: Editable dashboard — react-grid-layout drag/resize editor, widget picker modal with per-widget configurator auto-generated from default config, save/reset server actions with Zod validation. Completion visibility: server-side per-user setting (hours) replaces localStorage timer; queries filter completed items by updatedAt cutoff; Settings page dropdown persists preference via server action. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2ad9521ef9
commit
d5a8bf9d95
@@ -0,0 +1,91 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { parseDashboardLayout, computeDefaultLayout } from "@/lib/dashboard";
|
||||
import { getWidget, getWidgetMetas } from "@/modules/_core";
|
||||
import { getDashboardBySlug } from "@/app/d/actions";
|
||||
import { DashboardEditor } from "@/components/dashboard-editor";
|
||||
import { QuickAddFab } from "@/components/quick-add-fab";
|
||||
import { EditDashboardButton } from "@/components/edit-dashboard-button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
const smColSpan: Record<number, string> = {
|
||||
1: "sm:col-span-1", 2: "sm:col-span-2", 3: "sm:col-span-3",
|
||||
4: "sm:col-span-4", 5: "sm:col-span-5", 6: "sm:col-span-6",
|
||||
7: "sm:col-span-7", 8: "sm:col-span-8", 9: "sm:col-span-9",
|
||||
10: "sm:col-span-10", 11: "sm:col-span-11", 12: "sm:col-span-12",
|
||||
};
|
||||
|
||||
export default async function DashboardPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ edit?: string }>;
|
||||
}) {
|
||||
const { slug } = await params;
|
||||
const { edit } = await searchParams;
|
||||
const isEditing = edit === "1";
|
||||
|
||||
const { user, household } = await getCurrentSession();
|
||||
const dashboard = await getDashboardBySlug(slug);
|
||||
if (!dashboard) notFound();
|
||||
|
||||
const layout = parseDashboardLayout(dashboard.layout) ?? computeDefaultLayout();
|
||||
const widgetMetas = getWidgetMetas();
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<DashboardEditor
|
||||
dashboard={{ id: dashboard.id, name: dashboard.name, slug: dashboard.slug }}
|
||||
layout={layout}
|
||||
widgetMetas={widgetMetas}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const ctx = { userId: user.id, householdId: household.id };
|
||||
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">{dashboard.name}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<QuickAddFab />
|
||||
<EditDashboardButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
||||
{placements.map((placement, i) => {
|
||||
const widget = getWidget(placement.widgetId);
|
||||
if (!widget) return null;
|
||||
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
|
||||
return (
|
||||
<div key={i} className={`col-span-1 ${colClass}`}>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{widget.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-2/3 rounded bg-muted" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{widget.render({ config: placement.config, ctx })}
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
"use server";
|
||||
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { getWidget } from "@/modules/_core";
|
||||
import { dashboards } from "@/modules/_core/schema";
|
||||
import { computeDefaultLayout, type DashboardLayout } from "@/lib/dashboard";
|
||||
|
||||
export type DashboardMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
isDefault: boolean;
|
||||
position: number;
|
||||
};
|
||||
|
||||
export async function listDashboards(): Promise<DashboardMeta[]> {
|
||||
const { user } = await getCurrentSession();
|
||||
const rows = await db
|
||||
.select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position })
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getDefaultDashboardSlug(): Promise<string> {
|
||||
const { user } = await getCurrentSession();
|
||||
const rows = await db
|
||||
.select({ slug: dashboards.slug })
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.userId, user.id), eq(dashboards.isDefault, true)))
|
||||
.limit(1);
|
||||
if (rows[0]) return rows[0].slug;
|
||||
// Fallback: first dashboard by position
|
||||
const [first] = await db
|
||||
.select({ slug: dashboards.slug })
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt))
|
||||
.limit(1);
|
||||
if (first) return first.slug;
|
||||
// No dashboards yet — create Home
|
||||
await createDashboard("Home");
|
||||
return "home";
|
||||
}
|
||||
|
||||
export async function getDashboardBySlug(slug: string) {
|
||||
const { user } = await getCurrentSession();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.userId, user.id), eq(dashboards.slug, slug)))
|
||||
.limit(1);
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
function toSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
|| "dashboard";
|
||||
}
|
||||
|
||||
async function uniqueSlug(userId: string, base: string): Promise<string> {
|
||||
let slug = base;
|
||||
let attempt = 1;
|
||||
for (;;) {
|
||||
const [existing] = await db
|
||||
.select({ id: dashboards.id })
|
||||
.from(dashboards)
|
||||
.where(and(eq(dashboards.userId, userId), eq(dashboards.slug, slug)))
|
||||
.limit(1);
|
||||
if (!existing) return slug;
|
||||
attempt++;
|
||||
slug = `${base}-${attempt}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDashboard(name: string): Promise<DashboardMeta> {
|
||||
const parsed = z.string().trim().min(1).max(80).parse(name);
|
||||
const { user } = await getCurrentSession();
|
||||
const slug = await uniqueSlug(user.id, toSlug(parsed));
|
||||
const rows = await db
|
||||
.insert(dashboards)
|
||||
.values({ userId: user.id, name: parsed, slug, isDefault: false, position: 9999 })
|
||||
.returning();
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("Insert failed");
|
||||
revalidatePath("/");
|
||||
return { id: row.id, name: row.name, slug: row.slug, isDefault: row.isDefault, position: row.position };
|
||||
}
|
||||
|
||||
export async function renameDashboard(id: string, name: string): Promise<void> {
|
||||
const parsedName = z.string().trim().min(1).max(80).parse(name);
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ name: parsedName, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function deleteDashboard(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const all = await db
|
||||
.select({ id: dashboards.id, isDefault: dashboards.isDefault, slug: dashboards.slug })
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, user.id));
|
||||
if (all.length <= 1) throw new Error("Cannot delete your only dashboard");
|
||||
const target = all.find((d) => d.id === id);
|
||||
if (!target) throw new Error("Dashboard not found");
|
||||
|
||||
await db.delete(dashboards).where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
|
||||
// If we deleted the default, promote another
|
||||
if (target.isDefault) {
|
||||
const next = all.find((d) => d.id !== id);
|
||||
if (next) {
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ isDefault: true })
|
||||
.where(and(eq(dashboards.id, next.id), eq(dashboards.userId, user.id)));
|
||||
}
|
||||
}
|
||||
revalidatePath("/");
|
||||
redirect("/");
|
||||
}
|
||||
|
||||
export async function setDefaultDashboard(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ isDefault: false })
|
||||
.where(eq(dashboards.userId, user.id));
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ isDefault: true })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function reorderDashboards(orderedIds: string[]): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
await Promise.all(
|
||||
orderedIds.map((id, i) =>
|
||||
db
|
||||
.update(dashboards)
|
||||
.set({ position: i })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id))),
|
||||
),
|
||||
);
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function saveDashboardLayout(id: string, layout: DashboardLayout): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
// Validate each widget's config against its registered schema
|
||||
for (const placement of layout.widgets) {
|
||||
const widget = getWidget(placement.widgetId);
|
||||
if (!widget) continue;
|
||||
widget.configSchema.parse(placement.config);
|
||||
}
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function resetDashboardLayout(id: string): Promise<void> {
|
||||
const { user } = await getCurrentSession();
|
||||
const layout = computeDefaultLayout();
|
||||
await db
|
||||
.update(dashboards)
|
||||
.set({ layout: layout as unknown as Record<string, unknown>, updatedAt: new Date() })
|
||||
.where(and(eq(dashboards.id, id), eq(dashboards.userId, user.id)));
|
||||
revalidatePath("/");
|
||||
}
|
||||
|
||||
export async function resolveWidgetConfigOptions(widgetId: string): Promise<unknown> {
|
||||
const { user, household } = await getCurrentSession();
|
||||
const widget = getWidget(widgetId);
|
||||
if (!widget?.resolveConfigOptions) return null;
|
||||
return widget.resolveConfigOptions({ userId: user.id, householdId: household.id });
|
||||
}
|
||||
+10
-3
@@ -6,8 +6,9 @@ import { AppNav } from "@/components/app-nav";
|
||||
import "@/modules"; // registers all module manifests
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { dashboards, users } from "@/modules/_core/schema";
|
||||
import type { DashboardMeta } from "@/app/d/actions";
|
||||
import { getQuickAdds } from "@/modules/_core";
|
||||
import { QuickAddProvider } from "@/components/quick-add-provider";
|
||||
import { QuickAddSheet } from "@/components/quick-add-sheet";
|
||||
@@ -40,6 +41,7 @@ export default async function RootLayout({
|
||||
}) {
|
||||
let theme = "default";
|
||||
let themeMode = "system";
|
||||
let userDashboards: DashboardMeta[] = [];
|
||||
|
||||
const session = await auth();
|
||||
if (session?.user?.id) {
|
||||
@@ -52,6 +54,11 @@ export default async function RootLayout({
|
||||
theme = row.theme;
|
||||
themeMode = row.themeMode;
|
||||
}
|
||||
userDashboards = await db
|
||||
.select({ id: dashboards.id, name: dashboards.name, slug: dashboards.slug, isDefault: dashboards.isDefault, position: dashboards.position })
|
||||
.from(dashboards)
|
||||
.where(eq(dashboards.userId, session.user.id))
|
||||
.orderBy(asc(dashboards.position), asc(dashboards.createdAt));
|
||||
}
|
||||
|
||||
// For system mode we can't know the preference on the server — the inline
|
||||
@@ -71,7 +78,7 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
<QuickAddProvider actions={quickAdds}>
|
||||
<AppNav />
|
||||
<AppNav dashboards={userDashboards} />
|
||||
<main>{children}</main>
|
||||
<QuickAddSheet />
|
||||
<CommandPalette />
|
||||
|
||||
+5
-81
@@ -1,83 +1,7 @@
|
||||
import { Suspense } from "react";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { computeDefaultLayout, parseDashboardLayout } from "@/lib/dashboard";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { getWidget } from "@/modules/_core";
|
||||
import { users } from "@/modules/_core/schema";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { QuickAddFab } from "@/components/quick-add-fab";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getDefaultDashboardSlug } from "@/app/d/actions";
|
||||
|
||||
// Static lookup ensures Tailwind sees all sm:col-span-* classes at build time.
|
||||
const smColSpan: Record<number, string> = {
|
||||
1: "sm:col-span-1",
|
||||
2: "sm:col-span-2",
|
||||
3: "sm:col-span-3",
|
||||
4: "sm:col-span-4",
|
||||
5: "sm:col-span-5",
|
||||
6: "sm:col-span-6",
|
||||
7: "sm:col-span-7",
|
||||
8: "sm:col-span-8",
|
||||
9: "sm:col-span-9",
|
||||
10: "sm:col-span-10",
|
||||
11: "sm:col-span-11",
|
||||
12: "sm:col-span-12",
|
||||
};
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const { user, household } = await getCurrentSession();
|
||||
|
||||
const [row] = await db
|
||||
.select({ layout: users.defaultDashboardLayout })
|
||||
.from(users)
|
||||
.where(eq(users.id, user.id))
|
||||
.limit(1);
|
||||
|
||||
const layout = parseDashboardLayout(row?.layout) ?? computeDefaultLayout();
|
||||
|
||||
const ctx = { userId: user.id, householdId: household.id };
|
||||
|
||||
// Sort by y then x so document order matches visual order on mobile (single col).
|
||||
const placements = [...layout.widgets].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||
<QuickAddFab />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-12">
|
||||
{placements.map((placement, i) => {
|
||||
const widget = getWidget(placement.widgetId);
|
||||
if (!widget) return null;
|
||||
|
||||
const colClass = smColSpan[placement.w] ?? "sm:col-span-12";
|
||||
|
||||
return (
|
||||
<div key={i} className={`col-span-1 ${colClass}`}>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base">{widget.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-3 w-3/4 rounded bg-muted" />
|
||||
<div className="h-3 w-1/2 rounded bg-muted" />
|
||||
<div className="h-3 w-2/3 rounded bg-muted" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{widget.render({ config: placement.config, ctx })}
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default async function RootPage() {
|
||||
const slug = await getDefaultDashboardSlug();
|
||||
redirect(`/d/${slug}`);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,18 @@ export async function setUserTheme({
|
||||
.where(eq(users.id, user.id));
|
||||
}
|
||||
|
||||
export async function setCompletionVisibilityHours(hours: number): Promise<void> {
|
||||
const parsed = Number(hours);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 8760)
|
||||
throw new Error("Invalid hours value");
|
||||
const { user } = await getCurrentSession();
|
||||
await db
|
||||
.update(users)
|
||||
.set({ completionVisibilityHours: parsed })
|
||||
.where(eq(users.id, user.id));
|
||||
revalidatePath("/settings");
|
||||
}
|
||||
|
||||
export async function revokeShareLinkAction(formData: FormData): Promise<void> {
|
||||
const id = formData.get("id");
|
||||
if (typeof id !== "string") throw new Error("Missing id");
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function SettingsPage() {
|
||||
<CardTitle>Lists</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CompletionDelaySetting />
|
||||
<CompletionDelaySetting initialHours={user.completionVisibilityHours} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user