From 5cc39788ee609f79453c8ce632b4515843aaa43a Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 15:16:02 +0200 Subject: [PATCH 1/2] Answer pnpm's allowBuilds prompt so the repo installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 11 writes this key as a stub ("set this to true or false") on first install and then refuses to install until someone decides, so `pnpm build` failed on a fresh clone before reaching vite. esbuild and workerd need their install scripts to produce a usable binary; sharp is vite's optional image dependency. The equivalent block in package.json ("pnpm": { "onlyBuiltDependencies" }) is dead — pnpm 11 reads this file instead. --- pnpm-workspace.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 26edc2b..bdaa596 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,10 @@ +# esbuild and workerd need their install scripts to produce a usable binary, +# and sharp is vite's optional image dependency. pnpm 11 leaves this key as an +# unanswered stub on first install and refuses to install until it's decided. allowBuilds: - esbuild: set this to true or false - sharp: set this to true or false - workerd: set this to true or false + esbuild: true + sharp: true + workerd: true minimumReleaseAgeExclude: # First-party Clawnify packages — exempt from the new-release age gate so app # builds pick up fresh versions promptly. From 77a99d78797de6139b4b07dd14dd5eab98dbe554 Mon Sep 17 00:00:00 2001 From: pallaoro Date: Thu, 3 Sep 2026 15:16:17 +0200 Subject: [PATCH 2/2] Give each channel its own version of the post text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One body of text sent verbatim to every platform is the compromise a multi-channel scheduler exists to avoid, and the composer made it worse: the character counter took the minimum limit across every selected channel, so adding X to a LinkedIn post silently capped that LinkedIn post at 280 characters. There was no way to say "short hook on X, the long version on LinkedIn" short of writing two posts. post_channels gains a `content` column. NULL means "inherit the shared draft" — so a post nobody customised stores exactly one body of text, and existing posts keep behaving identically. A blank override normalises to NULL rather than being stored as "", so emptying the box means "go back to the shared draft", never "publish nothing". publishToChannel now receives the resolved text from one helper, channelContent(), rather than posts.content directly, so publishing, the previews and the API can't disagree about which text wins. Two consequences worth naming: - The shared draft is now only measured against the channels still inheriting it. Give X its own version and LinkedIn stops being capped at 280. - Customising every channel and clearing the shared draft is a real post, not an empty one, so publishPost's precondition moves from "posts.content is non-empty" to "some channel has text to send". A channel that ends up with no text at all fails on its own and the others still go out. The editor and preview share one tab strip on purpose: two strips would let you type X's version while looking at LinkedIn's preview. PreviewChannelTabs is renamed ChannelTabs to match what it now does, and grows an "All channels" tab plus a dot marking the channels that carry their own version. Text length stays unenforced server-side, deliberately: bluesky.ts already states the house rule that the platform's own rejection is the source of truth. X counts weighted characters and Bluesky counts graphemes, so a JS .length check would reject valid posts and pass invalid ones. The per-platform counters remain composer guidance, as they were. Verified end to end against a local D1 and the running app: overrides publish to the right channel, a legacy request with no channel_content is unchanged, blank normalises to NULL, an all-customised post with no shared draft publishes, a genuinely empty one still 400s, and the save/reopen round-trip restores the overrides. --- src/client/components/post-composer.tsx | 201 ++++++++++++++++++----- src/client/components/previews/index.tsx | 37 ++++- src/client/hooks/use-app.ts | 4 + src/client/types.ts | 4 + src/server/index.ts | 67 ++++++-- src/server/schema.sql | 5 + 6 files changed, 257 insertions(+), 61 deletions(-) diff --git a/src/client/components/post-composer.tsx b/src/client/components/post-composer.tsx index 4a6c02b..0d4dd0d 100644 --- a/src/client/components/post-composer.tsx +++ b/src/client/components/post-composer.tsx @@ -3,7 +3,7 @@ import { Send, Save, ArrowLeft, Image, X, Upload, Loader2 } from "lucide-preact" import { useApp } from "../context"; import { PLATFORM_LIMITS, PLATFORM_LABELS, mediaLimitError } from "../types"; import type { Platform } from "../types"; -import { PostPreview, PreviewChannelTabs } from "./previews"; +import { PostPreview, ChannelTabs } from "./previews"; // Timezone boundary: storage + the queue are always UTC; the browser is the // only timezone-aware layer. Convert local <-> UTC only here, at the edges. @@ -30,6 +30,11 @@ export function PostComposer({ editId, navigate }: Props) { const existing = editId ? posts.find((p) => p.id === editId) : null; const [content, setContent] = useState(""); + // Channels the author gave their own version of the text. A channel absent + // here inherits `content` — the shared draft. Kept keyed by channel id (not + // per-tab local state) so toggling a channel off and back on doesn't discard + // what was typed for it. + const [overrides, setOverrides] = useState>({}); const [selectedChannels, setSelectedChannels] = useState([]); const [selectedLabels, setSelectedLabels] = useState([]); const [scheduledAt, setScheduledAt] = useState(""); @@ -41,6 +46,13 @@ export function PostComposer({ editId, navigate }: Props) { useEffect(() => { if (existing) { setContent(existing.content); + setOverrides( + Object.fromEntries( + existing.channels + .filter((c) => c.content_override != null) + .map((c) => [c.id, c.content_override as string]), + ), + ); setSelectedChannels(existing.channels.map((c) => c.id)); setSelectedLabels(existing.labels.map((l) => l.id)); setScheduledAt(existing.scheduled_at ? utcToLocalInput(existing.scheduled_at) : ""); @@ -48,14 +60,40 @@ export function PostComposer({ editId, navigate }: Props) { } }, [existing?.id]); - const charLimit = useMemo(() => { - if (selectedChannels.length === 0) return null; - const selected = channels.filter((c) => selectedChannels.includes(c.id)); - const limits = selected.map((c) => PLATFORM_LIMITS[c.platform as Platform] || 10000); - return Math.min(...limits); - }, [selectedChannels, channels]); + // Selected channels, in selection order — the tab strip, the preview, and the + // limits below all read this one list. + const previewChannels = useMemo( + () => selectedChannels.map((id) => channels.find((c) => c.id === id)).filter(Boolean) as typeof channels, + [selectedChannels, channels], + ); - const overLimit = charLimit !== null && content.length > charLimit; + // The shared draft only has to fit the channels that still inherit it: give + // X its own version and LinkedIn stops being capped at 280 characters. + const sharedLimit = useMemo(() => { + const inheriting = previewChannels.filter((c) => overrides[c.id] === undefined); + if (inheriting.length === 0) return null; + return Math.min(...inheriting.map((c) => PLATFORM_LIMITS[c.platform as Platform] || 10000)); + }, [previewChannels, overrides]); + + // Save is blocked while any text on its way out is too long for where it's + // going — the shared draft for the channels inheriting it, and each channel's + // own version against its own platform's limit. + const overLimit = useMemo(() => { + if (sharedLimit !== null && content.length > sharedLimit) return true; + return previewChannels.some((c) => { + const own = overrides[c.id]; + if (own === undefined) return false; + const limit = PLATFORM_LIMITS[c.platform as Platform]; + return limit !== undefined && own.length > limit; + }); + }, [content, sharedLimit, previewChannels, overrides]); + + // Every channel publishes something. Customizing all of them and clearing the + // shared draft is a real post; leaving all of them empty is not. Mirrors the + // server's own rule in publishPost(). + const hasContent = previewChannels.length + ? previewChannels.some((c) => (overrides[c.id] ?? content).trim()) + : !!content.trim(); // Selected channels whose platform can't carry this many images. Publishing // fails those channels rather than posting a truncated set, so say it here — @@ -72,24 +110,41 @@ export function PostComposer({ editId, navigate }: Props) { return [...messages]; }, [selectedChannels, channels, mediaUrls.length]); - // Selected channels, in selection order, for the preview switcher. - const previewChannels = useMemo( - () => selectedChannels.map((id) => channels.find((c) => c.id === id)).filter(Boolean) as typeof channels, - [selectedChannels, channels], - ); - - // Which selected channel's preview is shown. Defaults to the first selected; - // falls back to the first whenever the active one is deselected. - const [previewChannelId, setPreviewChannelId] = useState(null); + // Which tab is open: null = the shared draft ("All channels"), otherwise the + // channel being written and previewed. Falls back to the shared draft when + // the open channel is deselected. + const [activeTab, setActiveTab] = useState(null); useEffect(() => { - if (previewChannels.length === 0) { - setPreviewChannelId(null); - } else if (!previewChannels.some((c) => c.id === previewChannelId)) { - setPreviewChannelId(previewChannels[0].id); + if (activeTab !== null && !previewChannels.some((c) => c.id === activeTab)) { + setActiveTab(null); } - }, [previewChannels, previewChannelId]); + }, [previewChannels, activeTab]); - const activePreviewChannel = previewChannels.find((c) => c.id === previewChannelId) ?? previewChannels[0]; + // The channel the editor is writing for, if any. On the shared tab the + // preview still has to pick someone — the first selected channel. + const activeChannel = activeTab === null ? undefined : previewChannels.find((c) => c.id === activeTab); + const activePreviewChannel = activeChannel ?? previewChannels[0]; + + const isCustomized = activeChannel ? overrides[activeChannel.id] !== undefined : false; + // What the textarea shows: this channel's own version, else the shared draft + // it inherits (read-only until customized, so editing it can't silently + // rewrite every other channel). + const editorValue = activeChannel ? overrides[activeChannel.id] ?? content : content; + const editorLimit = activeChannel + ? PLATFORM_LIMITS[activeChannel.platform as Platform] ?? null + : sharedLimit; + const editorOverLimit = editorLimit !== null && editorValue.length > editorLimit; + + const customize = () => { + if (activeChannel) setOverrides((prev) => ({ ...prev, [activeChannel.id]: content })); + }; + const resetToShared = () => { + if (!activeChannel) return; + setOverrides((prev) => { + const { [activeChannel.id]: _dropped, ...rest } = prev; + return rest; + }); + }; const toggleChannel = (id: number) => { setSelectedChannels((prev) => @@ -128,11 +183,19 @@ export function PostComposer({ editId, navigate }: Props) { const handleSave = async (status: string) => { if (overLimit) return; setSaving(true); + // Only the selected channels' overrides go out — a version typed for a + // channel that is no longer selected isn't part of this post. A channel with + // no entry inherits the shared draft server-side. + const channel_content: Record = {}; + for (const id of selectedChannels) { + if (overrides[id] !== undefined) channel_content[String(id)] = overrides[id]; + } const data = { content, status, scheduled_at: scheduledAt ? localInputToUtc(scheduledAt) : undefined, channel_ids: selectedChannels, + channel_content, label_ids: selectedLabels, media_urls: mediaUrls, }; @@ -160,19 +223,75 @@ export function PostComposer({ editId, navigate }: Props) {
{/* Main editor */}
-
+
+ {/* One strip for the whole composer: it picks which text you're + editing AND which platform you're previewing. */} +