diff --git a/actions/deletePublicationPage.ts b/actions/deletePublicationPage.ts new file mode 100644 index 00000000..4338f329 --- /dev/null +++ b/actions/deletePublicationPage.ts @@ -0,0 +1,34 @@ +"use server"; +import { getIdentityData } from "actions/getIdentityData"; +import { supabaseServerClient } from "supabase/serverClient"; + +export async function deletePublicationPage(args: { + publication_uri: string; + page_id: number; +}): Promise<{ success: boolean }> { + let identity = await getIdentityData(); + if (!identity || !identity.atp_did) return { success: false }; + + let { data: publication } = await supabaseServerClient + .from("publications") + .select("uri, identity_did") + .eq("uri", args.publication_uri) + .single(); + if (!publication || publication.identity_did !== identity.atp_did) + return { success: false }; + + let { count } = await supabaseServerClient + .from("publication_pages") + .select("id", { count: "exact", head: true }) + .eq("publication", args.publication_uri); + if ((count ?? 0) <= 1) return { success: false }; + + let { error } = await supabaseServerClient + .from("publication_pages") + .delete() + .eq("id", args.page_id) + .eq("publication", args.publication_uri); + if (error) return { success: false }; + + return { success: true }; +} diff --git a/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx b/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx index 5bf31da3..8c2c6730 100644 --- a/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx +++ b/app/(app)/lish/[did]/[publication]/PublicationPostsList.tsx @@ -129,9 +129,8 @@ export function PublicationPostsList({ if (Variant === "large") { return ( - <> +
- +
); } if (Variant === "small") { return ( - <> +
- +
); } return ( - <> +
- +
); })} diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/LeafletDirtyReporter.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/LeafletDirtyReporter.tsx new file mode 100644 index 00000000..76ede443 --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/LeafletDirtyReporter.tsx @@ -0,0 +1,100 @@ +"use client"; +import { useEffect, useMemo, useRef } from "react"; +import { Fact, useReplicache } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { useSubscribe } from "src/replicache/useSubscribe"; +import { leafletToPublicationPageRecord } from "src/utils/leafletToPublicationPageRecord"; +import { + dirtyCheckHooks, + deepEqual, + normalizePageRecordForDiff, +} from "src/utils/publicationPageDiff"; +import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; +import { useSetPublicationEditDirtyState } from "./dirtyContext"; + +const DEBOUNCE_MS = 500; + +export function LeafletDirtyReporter(props: { + leaflet_id: string; + publication_uri: string; + path: string; + title: string; +}) { + let { rep, initialFacts } = useReplicache(); + let setDirty = useSetPublicationEditDirtyState(); + let { data } = usePublicationData(); + let publishedRecord = + data?.publication?.publication_pages?.find((p) => p.path === props.path) + ?.record ?? null; + + let facts = useSubscribe( + rep, + async (tx) => { + let initialized = await tx.get("initialized"); + if (!initialized) return null; + return await tx + .scan>({ indexName: "eav" }) + .toArray(); + }, + { default: null as Fact[] | null, dependencies: [] }, + ); + + let effectiveFacts = facts ?? initialFacts; + + let normalizedPublished = useMemo( + () => normalizePageRecordForDiff(publishedRecord as any), + [publishedRecord], + ); + + let runIdRef = useRef(0); + useEffect(() => { + // No published record yet → there's something to publish. + if (!publishedRecord) { + setDirty("dirty"); + return; + } + + let cancelled = false; + let runId = ++runIdRef.current; + let timer = window.setTimeout(async () => { + try { + let record = await leafletToPublicationPageRecord({ + facts: effectiveFacts, + root_entity: props.leaflet_id, + publication_uri: props.publication_uri, + path: props.path, + title: props.title, + hooks: dirtyCheckHooks, + }); + if (cancelled || runId !== runIdRef.current) return; + let normalizedCurrent = normalizePageRecordForDiff( + record as unknown as Record, + ); + setDirty(deepEqual(normalizedCurrent, normalizedPublished) ? "clean" : "dirty"); + } catch { + if (cancelled || runId !== runIdRef.current) return; + setDirty("dirty"); + } + }, DEBOUNCE_MS); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [ + effectiveFacts, + props.leaflet_id, + props.publication_uri, + props.path, + props.title, + publishedRecord, + normalizedPublished, + setDirty, + ]); + + useEffect(() => { + return () => setDirty("unknown"); + }, [setDirty]); + + return null; +} diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx index 99d20904..7c860a0a 100644 --- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/PublicationEditHeader.tsx @@ -4,7 +4,9 @@ import { SpeedyLink } from "components/SpeedyLink"; import { GoToArrowLined } from "components/Icons/GoToArrowLined"; import { publishPublicationPages } from "actions/publishPublicationPages"; import { useToaster } from "components/Toast"; +import { OAuthErrorMessage, isOAuthSessionError } from "components/OAuthError"; import { usePublicationData } from "../../dashboard/PublicationSWRProvider"; +import { usePublicationEditDirtyState } from "./dirtyContext"; type Status = "idle" | "publishing" | "success"; @@ -12,10 +14,11 @@ export function PublicationEditHeader(props: { did: string; publicationName: string; }) { - let { data } = usePublicationData(); + let { data, mutate } = usePublicationData(); let publicationUri = data?.publication?.uri; let [status, setStatus] = useState("idle"); let toaster = useToaster(); + let dirtyState = usePublicationEditDirtyState(); let dashboardHref = `/lish/${props.did}/${props.publicationName}/dashboard`; @@ -28,15 +31,17 @@ export function PublicationEditHeader(props: { }); if (result.success) { setStatus("success"); + mutate(); setTimeout(() => setStatus("idle"), 2000); } else { setStatus("idle"); toaster({ type: "error", - content: - result.error.type === "oauth_session_expired" - ? "Sign in again to publish" - : result.error.message, + content: isOAuthSessionError(result.error) ? ( + + ) : ( + result.error.message + ), }); } } catch (e) { @@ -68,7 +73,11 @@ export function PublicationEditHeader(props: { + } + title="Delete page?" + > +
+ This will permanently delete{" "} + + {props.page.title || props.page.path || "/"} + + . +
+
+ setConfirmOpen(false)}> + Nevermind + + + {deleting ? "Deleting..." : "Delete"} + +
+ + )} ); } diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/dirtyContext.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/dirtyContext.tsx new file mode 100644 index 00000000..6b3010d2 --- /dev/null +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/dirtyContext.tsx @@ -0,0 +1,34 @@ +"use client"; +import { createContext, useContext, useMemo, useState } from "react"; + +type DirtyState = "unknown" | "clean" | "dirty"; + +type DirtyContextValue = { + state: DirtyState; + setState: (state: DirtyState) => void; +}; + +const PublicationEditDirtyContext = createContext({ + state: "unknown", + setState: () => {}, +}); + +export function PublicationEditDirtyProvider(props: { + children: React.ReactNode; +}) { + let [state, setState] = useState("unknown"); + let value = useMemo(() => ({ state, setState }), [state]); + return ( + + {props.children} + + ); +} + +export function usePublicationEditDirtyState() { + return useContext(PublicationEditDirtyContext).state; +} + +export function useSetPublicationEditDirtyState() { + return useContext(PublicationEditDirtyContext).setState; +} diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/layout.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/layout.tsx index 0aa90e49..97d3788d 100644 --- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/layout.tsx +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/layout.tsx @@ -13,6 +13,7 @@ import { PublicationPagesNav } from "./PublicationPagesNav"; import { PublicationEditHeader } from "./PublicationEditHeader"; import { PublicationHeader } from "../../PublicationHeader"; import { PublicationStickyHeader } from "../../PublicationStickyHeader"; +import { PublicationEditDirtyProvider } from "./dirtyContext"; export async function generateMetadata(props: { params: Promise<{ publication: string; did: string }>; @@ -90,33 +91,35 @@ export default async function PublicationEditLayout(props: { publication_rkey={uri.rkey} publication_data={publication_data} > - -
- -
- + +
+ +
+ + } + > + - } - > - - -
{props.children}
+ +
{props.children}
+
-
- + + ); } diff --git a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx index d95c482c..05fc9988 100644 --- a/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx +++ b/app/(app)/lish/[did]/[publication]/edit/[[...route]]/page.tsx @@ -71,6 +71,9 @@ export default async function PublicationEditPage(props: Props) { token={res.data} publicationRecord={publication.record} publicationCreator={publication.identity_did} + publicationUri={publication.uri} + pagePath={path} + pageTitle={page.title ?? ""} /> diff --git a/src/utils/publicationPageDiff.ts b/src/utils/publicationPageDiff.ts new file mode 100644 index 00000000..09c5c679 --- /dev/null +++ b/src/utils/publicationPageDiff.ts @@ -0,0 +1,85 @@ +import { BlobRef } from "@atproto/lexicon"; +import type { PubLeafletPublicationPage } from "lexicons/api"; +import type { ProcessBlocksToPagesHooks } from "src/utils/factsToPagesRecord"; + +// The published record stores BlobRef CIDs for images and at-uri/cid for polls, +// neither of which we can recompute client-side without uploading. For the +// dirty check we generate the would-be-record using the placeholder hooks +// below, then strip both records of those opaque fields before comparing. + +export const dirtyCheckHooks: ProcessBlocksToPagesHooks = { + uploadImage: async (src) => + ({ + ref: { $link: src }, + mimeType: "image/*", + size: 0, + }) as unknown as BlobRef, + uploadPoll: async (entityId) => ({ + uri: `at://dirty-check/${entityId}`, + cid: "dirty-check", + }), +}; + +function stripVolatile(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripVolatile); + if (value && typeof value === "object") { + const obj = value as Record; + const type = obj["$type"]; + if (type === "pub.leaflet.blocks.image" || type === "pub.leaflet.blocks.website") { + const { image: _image, previewImage: _previewImage, ...rest } = obj as { + image?: unknown; + previewImage?: unknown; + } & Record; + return Object.fromEntries( + Object.entries(rest).map(([k, v]) => [k, stripVolatile(v)]), + ); + } + if (type === "pub.leaflet.blocks.poll") { + const { pollRef: _pollRef, ...rest } = obj as { + pollRef?: unknown; + } & Record; + return Object.fromEntries( + Object.entries(rest).map(([k, v]) => [k, stripVolatile(v)]), + ); + } + return Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, stripVolatile(v)]), + ); + } + return value; +} + +// `publishedAt` is set to "now" on every publish, so a freshly generated +// record will always differ from the stored one on that field alone. +export function normalizePageRecordForDiff( + record: PubLeafletPublicationPage.Record | Record | null | undefined, +): unknown { + if (!record) return null; + const { publishedAt: _publishedAt, ...rest } = record as Record; + return stripVolatile(rest); +} + +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return false; + if (typeof a !== "object") return false; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b)) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + const ao = a as Record; + const bo = b as Record; + const ak = Object.keys(ao); + const bk = Object.keys(bo); + if (ak.length !== bk.length) return false; + for (const k of ak) { + if (!Object.prototype.hasOwnProperty.call(bo, k)) return false; + if (!deepEqual(ao[k], bo[k])) return false; + } + return true; +}