Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ instead of uploading a copy. Images that cannot be downloaded degrade to links,

AT Protocol accounts are linked through discovery, PAR, PKCE, DPoP-bound token exchange, encrypted token/key persistence, DPoP nonce retry, and refresh-token rotation. Existing accounts created before these fields and scopes were added must reconnect. Production startup requires `TOKEN_ENCRYPTION_KEY` to be valid base64 containing at least 32 bytes.

## Research

The Research tab shows the linked account’s Semble collections and Margin notes. Choose **Use in post**
on an item to select its quote, source link, or comment and preview the content. Add it to the end of
an existing draft, or start a new draft in a chosen publication; either action opens the post editor.
Existing draft writing and metadata are preserved. Published, scheduled, and publishing posts are
excluded from insertion. Research source records are unchanged, and publication remains a separate action.

## Development

```bash
Expand Down
56 changes: 50 additions & 6 deletions apps/web/components/cms/cms-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {
import { RightPanel } from "@/components/cms/right-panel";
import { PublicationsDashboard } from "@/components/cms/publications-dashboard";
import { ResearchSection } from "@/components/cms/research-section";
import { ResearchPostDialog, type ResearchPostSubmission } from "@/components/cms/research-post-dialog";
import { appendResearchToDraft, type ResearchPostMaterial } from "@/lib/research-composer";
import { WorkspaceHeader } from "@/components/cms/workspace-header";
import { FeedbackSection } from "@/components/cms/feedback-section";
import { MobileWorkspaceFooter } from "@/components/cms/mobile-workspace-footer";
Expand Down Expand Up @@ -84,6 +86,8 @@ export function CmsWorkspace() {
const [revertDraftToConfirm, setRevertDraftToConfirm] = React.useState<Draft | null>(null);
const [unpublishDraftToConfirm, setUnpublishDraftToConfirm] = React.useState<Draft | null>(null);
const [isMutatingDraft, setIsMutatingDraft] = React.useState(false);
const [researchMaterial, setResearchMaterial] = React.useState<ResearchPostMaterial | null>(null);
const researchSubmission = React.useRef(false);
const [search, setSearch] = React.useState("");
const [draftListTab, setDraftListTab] = React.useState<DraftListTab>("drafts");
const [draftListGrouping, setDraftListGrouping] = React.useState<DraftListGrouping>("all");
Expand Down Expand Up @@ -346,7 +350,10 @@ export function CmsWorkspace() {
}

function trackDraftSave(draft: Draft, version: number, notify: boolean) {
const save = persistDraftSnapshot(draft, version, notify);
// Keep writes for one draft in order so an older autosave cannot overwrite a later insertion.
const previousSave = inFlightSaves.current.get(draft.id);
setDraftSaveState(draft.id, "saving");
const save = Promise.resolve(previousSave).then(() => persistDraftSnapshot(draft, version, notify));
inFlightSaves.current.set(draft.id, save);
void save.finally(() => {
if (inFlightSaves.current.get(draft.id) === save) {
Expand Down Expand Up @@ -391,19 +398,20 @@ export function CmsWorkspace() {
scheduleAutosave(next, version);
}

async function createDraft(publicationURI: string) {
async function createDraft(publicationURI: string, seed?: { title: string; markdown: string }) {
const publication = accountPublications.find((candidate) => candidate.uri === publicationURI);
if (!publication) {
return false;
}
const id = crypto.randomUUID();
const next: Draft = {
const title = seed?.title.trim() || "Untitled article";
const empty: Draft = {
id,
accountDID: activeAccountDID,
publicationURI: publication.uri,
publicationURL: publication.url,
title: "Untitled article",
path: slugPathFromTitle("Untitled article", slugDiscriminatorFromDraftID(id)),
title,
path: slugPathFromTitle(title, slugDiscriminatorFromDraftID(id)),
excerpt: "",
tags: [],
markdown: "",
Expand All @@ -413,6 +421,7 @@ export function CmsWorkspace() {
updatedAt: new Date().toISOString(),
};
try {
const next = seed ? appendResearchToDraft(empty, seed.markdown) : empty;
const persisted = await draftAPI.createDraft(next);
setDrafts((current) => [persisted, ...current]);
editVersions.current.set(persisted.id, 0);
Expand All @@ -427,6 +436,32 @@ export function CmsWorkspace() {
}
}

async function useResearchInPost(submission: ResearchPostSubmission) {
if (researchSubmission.current || !activeAccountDID || draftsLoadedForAccount !== activeAccountDID || !submission.markdown.trim()) return false;
researchSubmission.current = true;
try {
if (submission.destination.type === "new") {
return await createDraft(submission.destination.publicationURI, submission);
}
const targetID = submission.destination.draftID;
const target = drafts.find((draft) => draft.id === targetID && draft.accountDID === activeAccountDID);
if (!target || (target.status !== "draft" && target.status !== "failed")) return false;
clearAutosave(target.id);
const next = appendResearchToDraft(target, submission.markdown);
const version = (editVersions.current.get(target.id) ?? 0) + 1;
editVersions.current.set(target.id, version);
// Persist the combined snapshot before changing the UI. Failed attempts can retry without appending twice.
if (!await trackDraftSave(next, version, false)) return false;
setSearch("");
setDraftListTab("drafts");
selectDraft(target.id);
toast.success("Research added to draft");
return true;
} finally {
researchSubmission.current = false;
}
}

async function saveDraft() {
if (!activeDraft || draftSaveStates[activeDraft.id] === "saving") {
return;
Expand Down Expand Up @@ -684,7 +719,7 @@ export function CmsWorkspace() {
onSync={syncPublications}
/>
) : activeView === "research" ? (
<ResearchSection />
<ResearchSection onUseInPost={setResearchMaterial} canUseInPost={draftsLoadedForAccount === activeAccountDID} />
) : activeView === "feedback" ? (
<FeedbackSection account={activeAccount} onReconnect={logOut} />
) : (
Expand Down Expand Up @@ -776,6 +811,15 @@ export function CmsWorkspace() {
onPublish={publishDraft}
/>
</main>
{researchMaterial ? (
<ResearchPostDialog
material={researchMaterial}
drafts={sortDraftsReverseChronological(drafts.filter((draft) => draft.accountDID === activeAccountDID && (draft.status === "draft" || draft.status === "failed")))}
publications={accountPublications}
onOpenChange={(open) => { if (!open) setResearchMaterial(null); }}
onSubmit={useResearchInPost}
/>
) : null}
<ChangePublicationDialog
draft={publicationDraft}
publications={accountPublications}
Expand Down
128 changes: 128 additions & 0 deletions apps/web/components/cms/research-post-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
"use client";

import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Field, FieldLabel } from "@/components/ui/field";
import { researchMarkdown, type ResearchPostMaterial, type ResearchPostParts } from "@/lib/research-composer";
import type { Draft, Publication } from "@/lib/types";

export type ResearchPostSubmission = {
title: string;
markdown: string;
destination: { type: "existing"; draftID: string } | { type: "new"; publicationURI: string };
};

export function ResearchPostDialog({ material, drafts, publications, onOpenChange, onSubmit }: {
material: ResearchPostMaterial;
drafts: Draft[];
publications: Publication[];
onOpenChange: (open: boolean) => void;
onSubmit: (submission: ResearchPostSubmission) => Promise<boolean>;
}) {
const [destination, setDestination] = React.useState<"existing" | "new">(drafts.length ? "existing" : "new");
const [draftID, setDraftID] = React.useState(drafts[0]?.id ?? "");
const [publicationURI, setPublicationURI] = React.useState(publications[0]?.uri ?? "");
const [parts, setParts] = React.useState<ResearchPostParts>({
quote: Boolean(material.quote), link: Boolean(material.sourceURL), comment: Boolean(material.comment),
});
const [busy, setBusy] = React.useState(false);
const submitting = React.useRef(false);
const [error, setError] = React.useState("");
const markdown = researchMarkdown(material, parts);
const selectedDraftID = drafts.some((draft) => draft.id === draftID) ? draftID : drafts[0]?.id ?? "";
const selectedPublicationURI = publications.some((publication) => publication.uri === publicationURI)
? publicationURI : publications[0]?.uri ?? "";
const hasDestination = destination === "existing"
? Boolean(selectedDraftID)
: Boolean(selectedPublicationURI);

async function submit(event: React.FormEvent) {
event.preventDefault();
if (submitting.current || !hasDestination || !markdown) return;
submitting.current = true;
setBusy(true);
setError("");
try {
const saved = await onSubmit({
title: material.title,
markdown,
destination: destination === "existing" ? { type: "existing", draftID: selectedDraftID } : { type: "new", publicationURI: selectedPublicationURI },
});
if (saved) onOpenChange(false);
else setError("Could not save this research to your draft. Please try again.");
} catch {
setError("Could not save this research to your draft. Please try again.");
} finally {
submitting.current = false;
setBusy(false);
}
}

return (
<Dialog open onOpenChange={(open) => { if (!submitting.current) onOpenChange(open); }}>
<DialogContent mobileSheet className="sm:max-w-xl">
<DialogHeader className="min-w-0 pr-8 text-left">
<DialogTitle>Use in post</DialogTitle>
<DialogDescription className="break-words">Choose what to include from “{material.title}”, then continue writing.</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid min-w-0 gap-5">
<fieldset disabled={busy} className="flex flex-wrap gap-x-5 gap-y-2">
<legend className="mb-2 text-sm font-medium">Include</legend>
{([
["quote", "Quote", material.quote],
["link", "Source link", material.sourceURL],
["comment", "Comment", material.comment],
] as const).map(([part, label, available]) => available ? (
<label key={part} className="flex min-h-9 items-center gap-2 text-sm">
<input type="checkbox" checked={parts[part]} onChange={(event) => setParts((current) => ({ ...current, [part]: event.target.checked }))} className="size-4 accent-primary" />
{label}
</label>
) : null)}
</fieldset>
<section aria-label="Post preview" className="bg-muted/30 max-h-56 space-y-3 overflow-y-auto rounded-md border p-4 text-sm leading-6 [overflow-wrap:anywhere]">
{parts.quote && material.quote ? <blockquote className="whitespace-pre-wrap border-l-2 border-primary/40 pl-3">{material.quote}</blockquote> : null}
{parts.link && material.sourceURL ? (
<p><a href={material.sourceURL} target="_blank" rel="noreferrer" className="underline underline-offset-4">{material.title}</a>{material.author ? ` — ${material.author}` : ""}</p>
) : null}
{parts.comment && material.comment ? <p className="whitespace-pre-wrap">{material.comment}</p> : null}
{!markdown ? <p className="text-muted-foreground">Select something to include in your post.</p> : null}
</section>
<fieldset disabled={busy} className="grid min-w-0 gap-4">
<legend className="mb-2 text-sm font-medium">Add to</legend>
<div className="flex flex-wrap gap-x-5 gap-y-2 text-sm">
<label className="flex min-h-9 items-center gap-2">
<input type="radio" name="research-destination" checked={destination === "existing"} onChange={() => setDestination("existing")} className="size-4 accent-primary" /> Existing draft
</label>
<label className="flex min-h-9 items-center gap-2">
<input type="radio" name="research-destination" checked={destination === "new"} onChange={() => setDestination("new")} className="size-4 accent-primary" /> New draft
</label>
</div>
{destination === "existing" ? drafts.length ? (
<Field>
<FieldLabel htmlFor="research-draft">Draft</FieldLabel>
<select id="research-draft" value={selectedDraftID} onChange={(event) => setDraftID(event.target.value)} className="bg-background h-11 w-full min-w-0 rounded-md border px-3 text-sm">
{drafts.map((draft) => <option key={draft.id} value={draft.id}>{draft.title || "Untitled article"} · {publications.find((publication) => publication.uri === draft.publicationURI)?.name ?? draft.publicationURL}</option>)}
</select>
<p className="text-muted-foreground text-xs">Appends to the end of your draft. Your existing writing stays in place.</p>
</Field>
) : <p className="text-muted-foreground text-sm">No editable drafts yet. Choose New draft to start a post.</p> : publications.length ? (
<Field>
<FieldLabel htmlFor="research-publication">Publication</FieldLabel>
<select id="research-publication" value={selectedPublicationURI} onChange={(event) => setPublicationURI(event.target.value)} className="bg-background h-11 w-full min-w-0 rounded-md border px-3 text-sm">
{publications.map((publication) => <option key={publication.uri} value={publication.uri}>{publication.name}</option>)}
</select>
<p className="text-muted-foreground text-xs">Starts a draft with this source’s title and your selected material.</p>
</Field>
) : <p className="text-muted-foreground text-sm">No publications found. Sync your publications before creating a draft.</p>}
</fieldset>
{error ? <p role="alert" className="text-destructive text-sm">{error}</p> : null}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => onOpenChange(false)}>Cancel</Button>
<Button type="submit" disabled={busy || !hasDestination || !markdown}>{busy ? "Saving…" : destination === "existing" ? "Add to draft" : "Create draft"}</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
Loading