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>
90 lines
2.8 KiB
TypeScript
90 lines
2.8 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { headers } from "next/headers";
|
|
import { eq } from "drizzle-orm";
|
|
import { resolveShareToken } from "@/modules/_core/share";
|
|
import { getEntityType } from "@/modules/_core/registry";
|
|
import { isRateLimited, recordFailure } from "@/lib/rate-limit";
|
|
import { db } from "@/lib/db";
|
|
import { users } from "@/modules/_core/schema";
|
|
import { ShareFrame } from "@/components/share/share-frame";
|
|
|
|
export const metadata: Metadata = {
|
|
robots: { index: false, follow: false },
|
|
};
|
|
|
|
const RL_PREFIX_LEN = 8;
|
|
|
|
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
|
const { token } = await params;
|
|
const headersList = await headers();
|
|
const ip =
|
|
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
headersList.get("x-real-ip") ??
|
|
"0.0.0.0";
|
|
const rlKey = `${ip}:${token.slice(0, RL_PREFIX_LEN)}`;
|
|
|
|
if (isRateLimited(rlKey)) {
|
|
return <ShareRateLimitError />;
|
|
}
|
|
|
|
const resolved = await resolveShareToken(token);
|
|
if (!resolved) {
|
|
recordFailure(rlKey);
|
|
return <ShareError />;
|
|
}
|
|
|
|
const entityReg = getEntityType(resolved.entityType);
|
|
if (!entityReg?.loadForShare || !entityReg.renderSharedView) {
|
|
return <ShareError message="This content type cannot be shared." />;
|
|
}
|
|
|
|
const data = await entityReg.loadForShare(resolved.entityId);
|
|
if (!data) {
|
|
recordFailure(rlKey);
|
|
return <ShareError />;
|
|
}
|
|
|
|
// Best-effort lookup of the creator's display name. Doesn't reveal email.
|
|
const [creator] = resolved.createdBy
|
|
? await db
|
|
.select({ name: users.name })
|
|
.from(users)
|
|
.where(eq(users.id, resolved.createdBy))
|
|
.limit(1)
|
|
: [];
|
|
|
|
return (
|
|
<ShareFrame
|
|
expiresAt={resolved.expiresAt}
|
|
capabilities={resolved.capabilities}
|
|
token={token}
|
|
sharedByName={creator?.name ?? null}
|
|
>
|
|
{entityReg.renderSharedView({ data, capabilities: resolved.capabilities, token })}
|
|
</ShareFrame>
|
|
);
|
|
}
|
|
|
|
function ShareRateLimitError() {
|
|
return (
|
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
|
<h1 className="serif text-[28px] font-medium tracking-tight">Too many requests</h1>
|
|
<p className="max-w-sm text-[13.5px] muted">
|
|
You have made too many requests in a short period. Please wait a minute and try again.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ShareError({ message }: { message?: string }) {
|
|
return (
|
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 p-8 text-center">
|
|
<h1 className="serif text-[28px] font-medium tracking-tight">Link not found</h1>
|
|
<p className="max-w-sm text-[13.5px] muted">
|
|
{message ??
|
|
"This share link may have expired or been revoked. Ask the sender for a new link."}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|