Files
famapp/src/app/d/actions.ts
T
ginnoirandClaude Opus 4.7 9612a54e52
Release / build-and-push (push) Has been cancelled
Apply paper-and-ink design system across all surfaces
Replaces the generic shadcn/ui gray theme + horizontal top-bar shell with
the paper-and-ink language from the Claude Design handoff bundle: warm
off-white paper, near-black ink, Source Serif 4 + Inter, hairline borders,
muted ink accents (clay/indigo/sage/plum/ochre) used functionally for
calendars and share scopes.

Theme switcher expanded from 2 dimensions (theme × mode) to 7: palette ×
mode × fontPair × density × dashLayout × calView × navStyle. All exposed
in Settings → Appearance and persisted on the users row. Pre-paint script
applies all four data-* attributes from localStorage so reload doesn't
flash.

App shell restructured to a CSS-grid driven by data-nav on <html>: sidebar
on desktop, bottom-nav + FAB under 760px. Four desktop nav modes wired
(sidebar/rail/top/fab-only). Topbar gets a search-→-CommandPalette button,
notification bell, "+ New" quick-add, avatar.

Dashboard, calendar, lists, notes, settings, login, public share viewer,
and quick-add sheet all reskinned. Dashboard editor gains a Preset menu
(classic/split/glance) that fills the layout from the registered widgets.
FullCalendar wrapped in .fc-skin and inherits all paper-and-ink tokens via
CSS variable overrides. Public share viewer (/s/<token>) rebuilt around
ShareFrame: expiration banner, brand strip, eyebrow chip, 38px serif
title, mini-day + mini-map cards, share-rows.

Schema: drops users.theme; adds theme_palette, theme_font_pair,
theme_density, theme_dash_layout, theme_cal_view, theme_nav_style with
defaults that match the design (clay / serif-sans / regular / classic /
month / rail-desktop). Migration 0014_paper_ink_theme.

Middleware sets x-pathname so the AppShell server component can render
bare for /s/* and /login without a route-group refactor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 01:03:21 -05:00

207 lines
6.7 KiB
TypeScript

"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 { type DashboardLayout } from "@/lib/dashboard";
import { computeDefaultLayout } from "@/lib/dashboard.server";
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 — insert one directly without revalidatePath (we're in a
// render path; the redirect that follows is a fresh request anyway).
const slug = await uniqueSlug(user.id, "home");
await db
.insert(dashboards)
.values({ userId: user.id, name: "Home", slug, isDefault: false, position: 0 });
return slug;
}
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 });
}