diff --git a/actions/createPublicationPage.ts b/actions/createPublicationPage.ts index f03c6c52..044aed11 100644 --- a/actions/createPublicationPage.ts +++ b/actions/createPublicationPage.ts @@ -3,6 +3,10 @@ import { generateKeyBetween } from "fractional-indexing"; import { getIdentityData } from "actions/getIdentityData"; import { createNewLeaflet, type DefaultBlockSpec } from "./createNewLeaflet"; import { supabaseServerClient } from "supabase/serverClient"; +import { + isExternalLink, + normalizeExternalLink, +} from "src/utils/externalPublicationLink"; export async function createPublicationPage(args: { publication_uri: string; @@ -23,6 +27,13 @@ export async function createPublicationPage(args: { if (!publication || publication.identity_did !== identity.atp_did) return null; + // An external link tab stores a full URL in `path` and has no backing + // leaflet document — bail early if the url isn't usable so we never insert a + // half-formed link. + let external = isExternalLink(args.path); + let externalUrl = external ? normalizeExternalLink(args.path) : null; + if (external && !externalUrl) return null; + let description = (publication.record as { description?: string } | null)?.description ?? ""; @@ -38,24 +49,27 @@ export async function createPublicationPage(args: { sort_order = generateKeyBetween(last?.sort_order ?? null, null); } - let firstBlocks: DefaultBlockSpec[] = [ - description ? { type: "text" as const, content: description } : "text", - ...(args.includePostsList ? (["posts-list"] as const) : []), - ...(args.includeSignup ? (["signup"] as const) : []), - ]; - let leaflet_src = await createNewLeaflet({ - pageType: "doc", - redirectUser: false, - firstBlocks, - addToHomepage: false, - }); + let leaflet_src: string | null = null; + if (!external) { + let firstBlocks: DefaultBlockSpec[] = [ + description ? { type: "text" as const, content: description } : "text", + ...(args.includePostsList ? (["posts-list"] as const) : []), + ...(args.includeSignup ? (["signup"] as const) : []), + ]; + leaflet_src = await createNewLeaflet({ + pageType: "doc", + redirectUser: false, + firstBlocks, + addToHomepage: false, + }); + } let { data: page } = await supabaseServerClient .from("publication_pages") .insert({ publication: args.publication_uri, leaflet_src, - path: args.path, + path: externalUrl ?? args.path, title: args.title ?? "", sort_order, }) diff --git a/actions/publishPublicationPages.ts b/actions/publishPublicationPages.ts index bf641bec..e1b3b2b7 100644 --- a/actions/publishPublicationPages.ts +++ b/actions/publishPublicationPages.ts @@ -1,6 +1,8 @@ "use server"; import { revalidatePath } from "next/cache"; +import { TID } from "@atproto/common"; +import { AtUri } from "@atproto/syntax"; import { AtpBaseClient } from "lexicons/api"; import { Json } from "supabase/database.types"; import type { Fact } from "src/replicache"; @@ -9,11 +11,9 @@ import { Lock } from "src/utils/lock"; import { OAuthSessionError, restoreOAuthSession } from "src/atproto-oauth"; import { get_leaflet_data } from "app/api/rpc/[command]/get_leaflet_data"; import { getIdentityData } from "actions/getIdentityData"; -import { - leafletToPublicationPageRecord, - pathToRkey, -} from "src/utils/leafletToPublicationPageRecord"; +import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord"; import { supabaseServerClient } from "supabase/serverClient"; +import { isExternalLink } from "src/utils/externalPublicationLink"; type PublishPagesResult = | { success: true; published: { id: number; uri: string }[] } @@ -62,7 +62,7 @@ export async function publishPublicationPages({ const { data: pageRows } = await supabaseServerClient .from("publication_pages") - .select("id, title, path, leaflet_src") + .select("id, title, path, leaflet_src, sort_order, record_uri") .eq("publication", publication_uri); if (!pageRows || pageRows.length === 0) { return { success: true, published: [] }; @@ -72,6 +72,23 @@ export async function publishPublicationPages({ const published: { id: number; uri: string }[] = []; for (const page of pageRows) { + const title = page.title ?? ""; + const sort_order = page.sort_order; + + // External link tabs have no backing leaflet document — there's nothing to + // publish to the network. Just snapshot their nav metadata (the url lives in + // `path`) so the tab goes live / picks up draft edits. + if (isExternalLink(page.path)) { + await supabaseServerClient + .from("publication_pages") + .update({ + published_metadata: { path: page.path, title, sort_order }, + }) + .eq("id", page.id) + .eq("publication", publication_uri); + continue; + } + if (!page.leaflet_src) continue; const path = page.path ?? "/"; const { result: leafletRes } = await get_leaflet_data.handler( @@ -122,7 +139,12 @@ export async function publishPublicationPages({ }, }); - const rkey = pathToRkey(path); + // Reuse the rkey of an already-published page so republishing updates the + // record in place. New pages get a tid — a path-derived rkey would collide + // across publications in the same repo (e.g. every pub's "home"). + const rkey = page.record_uri + ? new AtUri(page.record_uri).rkey + : TID.nextStr(); const { data: putResult } = await agent.com.atproto.repo.putRecord({ rkey, repo: credentialSession.did!, @@ -136,6 +158,9 @@ export async function publishPublicationPages({ .update({ record: record as unknown as Json, record_uri: putResult.uri, + // Snapshot the live nav metadata alongside the content so the public + // nav reflects this publish (and not any later draft edits). + published_metadata: { path, title, sort_order }, }) .eq("id", page.id) .eq("publication", publication_uri); diff --git a/actions/updatePublicationPage.ts b/actions/updatePublicationPage.ts index 72f62b2d..cb31f7ac 100644 --- a/actions/updatePublicationPage.ts +++ b/actions/updatePublicationPage.ts @@ -1,6 +1,10 @@ "use server"; import { getIdentityData } from "actions/getIdentityData"; import { supabaseServerClient } from "supabase/serverClient"; +import { + isExternalLink, + normalizeExternalLink, +} from "src/utils/externalPublicationLink"; export async function updatePublicationPage(args: { publication_uri: string; @@ -19,9 +23,32 @@ export async function updatePublicationPage(args: { if (!publication || publication.identity_did !== identity.atp_did) return { success: false }; + let { data: page } = await supabaseServerClient + .from("publication_pages") + .select("id, leaflet_src") + .eq("id", args.page_id) + .eq("publication", args.publication_uri) + .single(); + if (!page) return { success: false }; + + // A tab's type is fixed at creation and discriminated by leaflet_src: + // external link tabs have no backing leaflet and must keep a valid full url; + // hosted pages must keep a relative path. Rejecting type flips here keeps a + // hosted page from orphaning its leaflet (and its published record) and an + // external tab from becoming an un-editable, un-publishable hosted page. + let external = !page.leaflet_src; + let path = args.path; + if (external) { + let normalized = normalizeExternalLink(path); + if (!normalized) return { success: false }; + path = normalized; + } else if (isExternalLink(path)) { + return { success: false }; + } + let { error } = await supabaseServerClient .from("publication_pages") - .update({ title: args.title, path: args.path }) + .update({ title: args.title, path }) .eq("id", args.page_id) .eq("publication", args.publication_uri); if (error) return { success: false }; diff --git a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx b/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx index ce0f90f7..4ceb892f 100644 --- a/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx +++ b/app/(app)/lish/[did]/[publication]/DefaultPublicationHomepage.tsx @@ -9,6 +9,7 @@ import { FontLoader } from "components/FontLoader"; import { SpeedyLink } from "components/SpeedyLink"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { publishedNavPages } from "src/utils/publishedPageMetadata"; import { PublicationPostsList, type PublicationPostsListPost, @@ -39,6 +40,7 @@ export const DefaultPublicationHomepage = ({ title: string | null; record_uri: string | null; sort_order: string; + published_metadata: unknown; }[]; documents_in_publications: { documents: { @@ -56,9 +58,8 @@ export const DefaultPublicationHomepage = ({ fakePosts?: FakePost[]; }) => { const newsletterMode = !!publication.publication_newsletter_settings?.enabled; - const navPages = (publication.publication_pages ?? []).filter( - (p) => p.record_uri, - ); + // Public nav renders only published tabs, from their published snapshot. + const navPages = publishedNavPages(publication.publication_pages); const posts: PublicationPostsListPost[] = fakePosts ? [] : publication.documents_in_publications diff --git a/app/(app)/lish/[did]/[publication]/PublicationNav.tsx b/app/(app)/lish/[did]/[publication]/PublicationNav.tsx index 2d06c9f8..9b68a392 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationNav.tsx +++ b/app/(app)/lish/[did]/[publication]/PublicationNav.tsx @@ -1,17 +1,20 @@ "use client"; -import { SpeedyLink } from "components/SpeedyLink"; +import { isExternalLink } from "src/utils/externalPublicationLink"; import { sortPublicationPages } from "./sortPublicationPages"; import { PublicationNavSubscribe } from "./PublicationNavSubscribe"; +import { PublicationNavTabLink } from "./PublicationNavTabLink"; import { SubscribeButton, SubscribeInput, } from "components/Subscribe/SubscribeButton"; import { useNavBackgroundFade } from "./useNavBackgroundFade"; +// Pages here are published snapshots (publishedNavPages output), where path +// and title are always present. export type PublicationNavPage = { id: number; - path: string | null; - title: string | null; + path: string; + title: string; sort_order: string; }; @@ -64,9 +67,13 @@ export function PublicationNav(props: { >
{tabs.map((page) => { + let external = isExternalLink(page.path); let segment = page.path === "/" ? "" : page.path; - let href = `${props.publicationUrl}${segment}`; - let active = props.activePath === page.path; + let href = external + ? page.path + : `${props.publicationUrl}${segment}`; + // External links point off-site, so they're never the active tab. + let active = !external && props.activePath === page.path; return (
- {page.title || page.path} - +
); })} diff --git a/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx b/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx new file mode 100644 index 00000000..1a437b5d --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/PublicationNavTabLink.tsx @@ -0,0 +1,32 @@ +import { SpeedyLink } from "components/SpeedyLink"; + +// A single tab link in a publication nav, shared between the public nav +// (PublicationNav) and the editor nav (PublicationPagesEditNav) so the two +// stay visually identical. External link tabs open off-site in a new window; +// hosted pages navigate client-side. +export function PublicationNavTabLink(props: { + href: string; + external: boolean; + active: boolean; + children: React.ReactNode; +}) { + let className = `block px-1 pt-1 pb-0.5 text-sm font-bold text-inherit no-underline! select-none border-b-3 ${ + props.active ? "border-accent-contrast" : "border-transparent" + }`; + if (props.external) + return ( + + {props.children} + + ); + return ( + + {props.children} + + ); +} diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx index c57e747d..41d7e4c8 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/PublicationPageRenderer.tsx @@ -24,6 +24,7 @@ import { type PublicationPostsListPost } from "../PublicationPostsList"; import { PublicationHomeLayout } from "../PublicationHomeLayout"; import { getPublicationURL } from "app/(app)/lish/createPub/getPublicationURL"; import { blobRefToSrc } from "src/utils/blobRefToSrc"; +import { publishedNavPages } from "src/utils/publishedPageMetadata"; import { collectAndFetchBlockResources } from "./collectAndFetchBlockResources"; import { PostContent } from "./PostContent"; @@ -42,6 +43,7 @@ type PublicationRow = { title: string; record_uri: string | null; sort_order: string; + published_metadata: unknown; }[]; documents_in_publications?: { documents: { @@ -161,9 +163,9 @@ export async function PublicationPageRenderer({ recommendsCount: 0, }; - const navPages = (publication.publication_pages ?? []).filter( - (p) => p.record_uri, - ); + // The public nav renders only published tabs, from their published snapshot — + // never the live draft columns. + const navPages = publishedNavPages(publication.publication_pages); return ( diff --git a/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx b/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx index bb72ee44..7a1e7b50 100644 --- a/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx +++ b/app/(app)/lish/[did]/[publication]/[rkey]/page.tsx @@ -11,6 +11,7 @@ import { publicationNameOrUriFilter, } from "src/utils/uriHelpers"; import { getDocumentURL } from "app/(app)/lish/createPub/getPublicationURL"; +import { findPublishedPage } from "src/utils/publishedPageMetadata"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string; rkey: string }>; @@ -23,17 +24,18 @@ export async function generateMetadata(props: { let { data: pubs } = await supabaseServerClient .from("publications") - .select("name, publication_pages(path, title, record_uri)") + .select("name, publication_pages(record_uri, published_metadata)") .eq("identity_did", did) .or(publicationNameOrUriFilter(did, publication_name)) .order("uri", { ascending: false }) .limit(1); - let matchingPage = pubs?.[0]?.publication_pages?.find( - (p) => p.path === "/" + rkey && p.record_uri, - ); - if (matchingPage) { + // Match on the published snapshot, the same way the page body does + // (tryRenderPublicationPage), so metadata and body never disagree about + // which page a URL serves. + let match = findPublishedPage(pubs?.[0]?.publication_pages, "/" + rkey); + if (match && match.page.record_uri) { return { - title: `${matchingPage.title || matchingPage.path} - ${pubs?.[0]?.name}`, + title: `${match.metadata.title || match.metadata.path} - ${pubs?.[0]?.name}`, }; } @@ -120,7 +122,7 @@ export default async function Post(props: { .from("publications") .select( `uri, name, identity_did, record, - publication_pages(id, path, title, record, record_uri, sort_order), + publication_pages(id, path, title, record, record_uri, sort_order, published_metadata), documents_in_publications(documents(uri, data, comments_on_documents(count), document_mentions_in_bsky(count), diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx index 1b4981f3..2ae00fc1 100644 --- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationPagesEditNav.tsx @@ -24,7 +24,6 @@ import { } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { generateKeyBetween } from "fractional-indexing"; -import { SpeedyLink } from "components/SpeedyLink"; import { ButtonPrimary, ButtonSecondary, @@ -45,10 +44,15 @@ import { } from "../../dashboard/PublicationSWRProvider"; import { sortPublicationPages } from "../../sortPublicationPages"; import { PublicationNavSubscribe } from "../../PublicationNavSubscribe"; +import { PublicationNavTabLink } from "../../PublicationNavTabLink"; import { useNavBackgroundFade } from "../../useNavBackgroundFade"; import { DotLoader } from "components/utils/DotLoader"; import { useToaster } from "components/Toast"; import { Checkbox } from "components/Checkbox"; +import { + isExternalLink, + normalizeExternalLink, +} from "src/utils/externalPublicationLink"; // Turn arbitrary user input into a slug that is safe to use as the path // segment of a URL: lowercase, ascii letters/numbers/dashes only, no spaces @@ -90,6 +94,34 @@ function useIsPathInUse() { }; } +// Validate the user's path/url input for a tab and return the value to store +// in `path`, or null (after showing an error toast) if it's unusable. External +// link tabs store a full url; hosted pages store a cleaned relative path that +// must be unique within the publication. +function useResolveTabPath() { + let toaster = useToaster(); + let isPathInUse = useIsPathInUse(); + return ( + input: string, + external: boolean, + excludePageId?: number, + ): string | null => { + if (external) { + let url = normalizeExternalLink(input); + if (!url) { + toaster({ type: "error", content: "enter a valid url (https://…)" }); + return null; + } + return url; + } + if (isPathInUse(input, excludePageId)) { + toaster({ type: "error", content: "path already in use!" }); + return null; + } + return cleanPath(input) || ""; + }; +} + export function PublicationPagesEditNav(props: { did: string; publicationName: string; @@ -231,7 +263,10 @@ export function PublicationPagesEditNav(props: { publicationUrl={publicationRecord?.url} onCreated={async (created) => { await mutate(); - router.push(hrefForPath(created.path)); + // External links have no editable page to navigate to — they just + // appear in the nav once created. + if (!isExternalLink(created.path)) + router.push(hrefForPath(created.path)); }} />
@@ -260,8 +295,7 @@ function AddPageButton(props: { publicationUrl: string | undefined; onCreated: (created: { path: string | null }) => void | Promise; }) { - let toaster = useToaster(); - let isPathInUse = useIsPathInUse(); + let resolveTabPath = useResolveTabPath(); let [open, setOpen] = useState(false); let [creating, setCreating] = useState(false); let [name, setName] = useState(""); @@ -298,14 +332,17 @@ function AddPageButton(props: { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (creating || !props.publicationUri) return; - if (isPathInUse(path)) { - toaster({ type: "error", content: "path already in use!" }); - return; - } + + let path_to_create = resolveTabPath( + isExternal ? externalLink : path, + isExternal, + ); + if (path_to_create === null) return; + setCreating(true); let created = await createPublicationPage({ publication_uri: props.publicationUri, - path: cleanPath(path) || "", + path: path_to_create, title: name.trim() || undefined, }); setCreating(false); @@ -396,10 +433,8 @@ function AddPageButton(props: { type="submit" disabled={ creating || - !path || - path.trim() === "" || - !name || - name.trim() === "" + !name.trim() || + (isExternal ? !externalLink.trim() : !path.trim()) } fullWidth compact @@ -432,8 +467,8 @@ function SortableTab(props: { transition, isDragging, } = useSortable({ id: props.page.id }); - let toaster = useToaster(); - let isPathInUse = useIsPathInUse(); + let resolveTabPath = useResolveTabPath(); + let external = isExternalLink(props.page.path); let [popoverOpen, setPopoverOpen] = useState(false); let [mode, setMode] = useState<"edit" | "confirm">("edit"); let [name, setName] = useState(props.page.title); @@ -458,10 +493,9 @@ function SortableTab(props: { async function handleSave(e: React.FormEvent) { e.preventDefault(); if (!props.publicationUri || saving) return; - if (isPathInUse(path, props.page.id)) { - toaster({ type: "error", content: "path already in use!" }); - return; - } + + let nextPath = resolveTabPath(path, external, props.page.id); + if (nextPath === null) return; let trimmedTitle = name.trim(); setSaving(true); @@ -469,12 +503,12 @@ function SortableTab(props: { publication_uri: props.publicationUri, page_id: props.page.id, title: trimmedTitle, - path: cleanPath(path) || "", + path: nextPath, }); setSaving(false); if (!result.success) return; setPopoverOpen(false); - props.onUpdated({ title: trimmedTitle, path: cleanPath(path) || "" }); + props.onUpdated({ title: trimmedTitle, path: nextPath }); } async function handleDelete() { @@ -502,14 +536,13 @@ function SortableTab(props: { : "text-tertiary hover:text-secondary" }`} > - {props.page.title || props.page.path || "/"} - +
setName(e.currentTarget.value)} autoFocus /> - setPath(e.currentTarget.value)} - placeholder="/about" - /> -
- Full page link
- {props.publicationUrl?.replace(/^https?:\/\//, "")} - {cleanPath(path)} -
+ {external ? ( + setPath(e.currentTarget.value)} + placeholder="https://example.com" + /> + ) : ( + <> + setPath(e.currentTarget.value)} + placeholder="/about" + /> +
+ Full page link
+ {props.publicationUrl?.replace(/^https?:\/\//, "")} + {cleanPath(path)} +
+ + )} {saving ? "Saving..." : "Save"} diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx index 8f073795..489eb40f 100644 --- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx @@ -33,7 +33,8 @@ export default async function PublicationEditPage(props: Props) { if (!publication) return ; let page = publication.publication_pages?.find((p) => p.path === path); - if (!page) return ; + // External link tabs have no backing leaflet document to edit. + if (!page || !page.leaflet_src) return ; let { result: res } = await get_leaflet_data.handler( { token_id: page.leaflet_src }, diff --git a/app/(app)/lish/[did]/[publication]/page.tsx b/app/(app)/lish/[did]/[publication]/page.tsx index ea25955d..ff569dbd 100644 --- a/app/(app)/lish/[did]/[publication]/page.tsx +++ b/app/(app)/lish/[did]/[publication]/page.tsx @@ -30,7 +30,7 @@ export default async function Publication(props: { `*, publication_subscriptions(*), publication_newsletter_settings(enabled), - publication_pages(id, path, title, record, record_uri, sort_order), + publication_pages(id, path, title, record, record_uri, sort_order, published_metadata), documents_in_publications(documents( *, comments_on_documents(count), diff --git a/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx b/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx index 90f761dc..b6d587fe 100644 --- a/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx +++ b/app/(app)/lish/[did]/[publication]/tryRenderPublicationPage.tsx @@ -2,6 +2,7 @@ import { PublicationPageRenderer, type PublicationPageRecord, } from "./[rkey]/PublicationPageRenderer"; +import { findPublishedPage } from "src/utils/publishedPageMetadata"; type RendererProps = Parameters[0]; @@ -16,6 +17,7 @@ type PublicationForRenderer = Omit< record: unknown; record_uri: string | null; sort_order: string; + published_metadata: unknown; }[]; }; @@ -28,19 +30,16 @@ export function tryRenderPublicationPage({ publication: PublicationForRenderer; path: string; }) { - const matchingPage = publication.publication_pages?.find( - (p) => p.path === path && p.record_uri && p.record, - ); - if (!matchingPage || !matchingPage.record) return null; - const pageRecord = - matchingPage.record as unknown as PublicationPageRecord; + const match = findPublishedPage(publication.publication_pages, path); + if (!match || !match.page.record_uri || !match.page.record) return null; + const pageRecord = match.page.record as unknown as PublicationPageRecord; return ( ({ identity: one(identities, { diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 129a87d8..0ec53ca9 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -77,6 +77,7 @@ export const facts = pgTable("facts", { updated_at: timestamp("updated_at", { mode: 'string' }), // You can use { mode: "bigint" } if numbers are exceeding js number limitations version: bigint("version", { mode: "number" }).default(0).notNull(), + author_did: text("author_did"), }, (table) => { return { @@ -599,7 +600,7 @@ export const publication_pages = pgTable("publication_pages", { // You can use { mode: "bigint" } if numbers are exceeding js number limitations id: bigint("id", { mode: "number" }).notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - leaflet_src: uuid("leaflet_src").notNull().references(() => permission_tokens.id), + leaflet_src: uuid("leaflet_src").references(() => permission_tokens.id), document: text("document").references(() => documents.uri), path: text("path"), publication: text("publication").notNull().references(() => publications.uri, { onDelete: "cascade", onUpdate: "cascade" } ), @@ -608,6 +609,7 @@ export const publication_pages = pgTable("publication_pages", { record: jsonb("record"), record_uri: text("record_uri"), sort_order: text("sort_order").notNull(), + published_metadata: jsonb("published_metadata"), }, (table) => { return { diff --git a/src/utils/externalPublicationLink.ts b/src/utils/externalPublicationLink.ts new file mode 100644 index 00000000..f75694e5 --- /dev/null +++ b/src/utils/externalPublicationLink.ts @@ -0,0 +1,26 @@ +// Publication nav tabs are either a hosted publication page (stored as a +// relative path like "/about") or an external link (stored as a full URL like +// "https://example.com"). We tell them apart by whether the stored `path` is an +// absolute http(s) URL — there's no separate column, the shape of the path is +// the discriminator. + +export function isExternalLink(path: string | null | undefined): boolean { + if (!path) return false; + return /^https?:\/\//i.test(path.trim()); +} + +// Validate + normalize user input for an external link tab. Returns the +// canonical URL string, or null if it isn't a usable absolute http(s) URL. Used +// to enforce that external link tabs always store a full url. +export function normalizeExternalLink(input: string): string | null { + let trimmed = input.trim(); + if (!trimmed) return null; + let url: URL; + try { + url = new URL(trimmed); + } catch { + return null; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url.toString(); +} diff --git a/src/utils/leafletToPublicationPageRecord.ts b/src/utils/leafletToPublicationPageRecord.ts index e854fcf5..73b21451 100644 --- a/src/utils/leafletToPublicationPageRecord.ts +++ b/src/utils/leafletToPublicationPageRecord.ts @@ -54,13 +54,3 @@ export async function leafletToPublicationPageRecord(opts: { }, }; } - -export function pathToRkey(path: string): string { - let trimmed = path.replace(/^\/+/, "").replace(/\/+$/, ""); - if (trimmed === "") return "home"; - let slug = trimmed - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - return slug || "home"; -} diff --git a/src/utils/publishedPageMetadata.ts b/src/utils/publishedPageMetadata.ts new file mode 100644 index 00000000..0e119c07 --- /dev/null +++ b/src/utils/publishedPageMetadata.ts @@ -0,0 +1,51 @@ +// A publication page's *published* nav state. Editing happens against the live +// `path`/`title`/`sort_order` columns on the row (the draft); publishing +// snapshots those into `published_metadata`, and the public site reads only +// from this snapshot. That's what keeps draft edits to a nav tab — renaming it, +// changing its path/url, reordering — out of production until the next publish. +// +// For external link tabs `path` holds the full URL (see isExternalLink). +export type PublishedPageMetadata = { + path: string; + title: string; + sort_order: string; +}; + +export function getPublishedPageMetadata( + published_metadata: unknown, +): PublishedPageMetadata | null { + let pm = published_metadata as Partial | null; + if (!pm || typeof pm.path !== "string" || typeof pm.sort_order !== "string") + return null; + return { path: pm.path, title: pm.title ?? "", sort_order: pm.sort_order }; +} + +// Find the page row whose *published* path matches the requested path. Matching +// on the snapshot (not the live draft `path` column) is what keeps an +// unpublished path edit from changing which URL serves the page — every public +// reader (page body, metadata) must match this way or they'll disagree. +export function findPublishedPage( + pages: T[] | null | undefined, + path: string, +): { page: T; metadata: PublishedPageMetadata } | null { + for (let page of pages ?? []) { + let metadata = getPublishedPageMetadata(page.published_metadata); + if (metadata?.path === path) return { page, metadata }; + } + return null; +} + +// Map raw publication_pages rows to the published nav tabs the public site +// renders. Rows without a published snapshot (never-published drafts) are +// dropped. The returned fields are the *published* values, not the live +// draft columns. +export function publishedNavPages( + pages: { id: number; published_metadata: unknown }[] | null | undefined, +): { id: number; path: string; title: string; sort_order: string }[] { + return (pages ?? []).flatMap((p) => { + let pm = getPublishedPageMetadata(p.published_metadata); + return pm + ? [{ id: p.id, path: pm.path, title: pm.title, sort_order: pm.sort_order }] + : []; + }); +} diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 5c2053b4..b2621b7d 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -1276,10 +1276,11 @@ export type Database = { created_at: string document: string | null id: number - leaflet_src: string + leaflet_src: string | null metadata: Json path: string | null publication: string + published_metadata: Json | null record: Json | null record_uri: string | null sort_order: string @@ -1289,10 +1290,11 @@ export type Database = { created_at?: string document?: string | null id?: number - leaflet_src: string + leaflet_src?: string | null metadata?: Json path?: string | null publication: string + published_metadata?: Json | null record?: Json | null record_uri?: string | null sort_order: string @@ -1302,10 +1304,11 @@ export type Database = { created_at?: string document?: string | null id?: number - leaflet_src?: string + leaflet_src?: string | null metadata?: Json path?: string | null publication?: string + published_metadata?: Json | null record?: Json | null record_uri?: string | null sort_order?: string