Release / build-and-push (push) Has been cancelled
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>
122 lines
3.2 KiB
TypeScript
122 lines
3.2 KiB
TypeScript
"use server";
|
|
|
|
import { createHash, randomBytes } from "crypto";
|
|
import { and, eq, isNull } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { getCurrentSession } from "@/lib/session";
|
|
import { getEntityType } from "./registry";
|
|
import { shareLinks } from "./schema";
|
|
|
|
export type ShareLinkCapabilities = { read: boolean; write: boolean };
|
|
|
|
export type CreateShareLinkResult = {
|
|
url: string;
|
|
token: string;
|
|
expiresAt: Date | null;
|
|
};
|
|
|
|
function hashToken(raw: string): string {
|
|
return createHash("sha256").update(raw).digest("hex");
|
|
}
|
|
|
|
function buildUrl(token: string): string {
|
|
const base = process.env["NEXTAUTH_URL"] ?? "http://localhost:3000";
|
|
return `${base}/s/${token}`;
|
|
}
|
|
|
|
export async function createShareLink(
|
|
entityType: string,
|
|
entityId: string,
|
|
opts: { expiresAt?: Date; capabilities?: Partial<ShareLinkCapabilities> } = {},
|
|
): Promise<CreateShareLinkResult> {
|
|
const registration = getEntityType(entityType);
|
|
if (!registration?.share?.canShare) {
|
|
throw new Error(`Entity type "${entityType}" is not shareable`);
|
|
}
|
|
|
|
const { user, household } = await getCurrentSession();
|
|
|
|
const rawToken = randomBytes(32).toString("base64url");
|
|
const tokenHash = hashToken(rawToken);
|
|
|
|
const capabilities: ShareLinkCapabilities = {
|
|
read: opts.capabilities?.read ?? true,
|
|
write: opts.capabilities?.write ?? false,
|
|
};
|
|
|
|
const expiresAt = opts.expiresAt ?? null;
|
|
|
|
await db.insert(shareLinks).values({
|
|
householdId: household.id,
|
|
entityType,
|
|
entityId,
|
|
token: tokenHash,
|
|
capabilities,
|
|
createdBy: user.id,
|
|
expiresAt,
|
|
});
|
|
|
|
return { url: buildUrl(rawToken), token: rawToken, expiresAt };
|
|
}
|
|
|
|
export async function resolveShareToken(rawToken: string): Promise<{
|
|
entityType: string;
|
|
entityId: string;
|
|
capabilities: ShareLinkCapabilities;
|
|
householdId: string;
|
|
expiresAt: Date | null;
|
|
createdBy: string;
|
|
} | null> {
|
|
const tokenHash = hashToken(rawToken);
|
|
|
|
const [link] = await db
|
|
.select()
|
|
.from(shareLinks)
|
|
.where(and(eq(shareLinks.token, tokenHash), isNull(shareLinks.revokedAt)))
|
|
.limit(1);
|
|
|
|
if (!link) return null;
|
|
|
|
if (link.expiresAt && link.expiresAt < new Date()) return null;
|
|
|
|
return {
|
|
entityType: link.entityType,
|
|
entityId: link.entityId,
|
|
capabilities: link.capabilities,
|
|
householdId: link.householdId,
|
|
expiresAt: link.expiresAt,
|
|
createdBy: link.createdBy,
|
|
};
|
|
}
|
|
|
|
export async function revokeShareLink(id: string): Promise<void> {
|
|
const { household } = await getCurrentSession();
|
|
|
|
await db
|
|
.update(shareLinks)
|
|
.set({ revokedAt: new Date() })
|
|
.where(and(eq(shareLinks.id, id), eq(shareLinks.householdId, household.id)));
|
|
}
|
|
|
|
export type ActiveShareLink = typeof shareLinks.$inferSelect;
|
|
|
|
export async function getActiveShareLinks(): Promise<ActiveShareLink[]> {
|
|
const { household } = await getCurrentSession();
|
|
const now = new Date();
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(shareLinks)
|
|
.where(
|
|
and(
|
|
eq(shareLinks.householdId, household.id),
|
|
isNull(shareLinks.revokedAt),
|
|
// exclude already-expired links
|
|
),
|
|
)
|
|
.orderBy(shareLinks.createdAt);
|
|
|
|
// Filter expired in JS since OR NULL handling is cleaner here
|
|
return rows.filter((r) => !r.expiresAt || r.expiresAt > now);
|
|
}
|