diff --git a/README.md b/README.md index 2ca6c4d..1ea2bb7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful - **Drafts** -- save unfinished posts and come back to them later - **Channel management** -- add your social media accounts with platform detection and color coding - **Labels** -- categorize posts with colored labels for organization -- **Media attachments** -- attach several images to a post; every platform publishes the whole set (carousel, gallery or multi-photo, whichever that platform calls it) +- **Media attachments** -- attach several images to a post and every platform publishes the whole set (carousel, gallery or multi-photo, whichever that platform calls it), or attach one video and it goes out as a video post - **Analytics** -- bar charts showing posts per channel, per label, and daily activity - **Dashboard** -- at-a-glance stats, upcoming posts, and recent drafts - **Native previews** -- see the post as it will look on each selected platform before it goes out @@ -24,19 +24,22 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful ### Supported Platforms -| Platform | Character Limit | Images | Publishing | -|----------|----------------|--------|------------| -| X / Twitter | 280 | up to 4 | text, or a post with up to four images | -| LinkedIn | 3,000 | up to 20 | text, or a post with up to twenty images | -| Instagram | 2,200 | 1-10 | photo + caption; two or more images publish as a carousel (Business or Creator account, picked up from the connection) | -| Facebook | 63,206 | no stated cap | text, one photo, or one feed post with several photos, as a Page you manage | -| TikTok | 2,200 | 1-35 | photo post; the first image is the cover | -| Bluesky | 300 | up to 4 | text, with images and link cards | -| Mastodon | 500 | -- | coming soon | -| Threads | 500 | -- | coming soon | - -Attaching more images than a platform accepts fails **that channel only**, with the -reason on the post, rather than quietly publishing a subset -- the other channels in +| Platform | Character Limit | Images | Video | Publishing | +|----------|----------------|--------|-------|------------| +| X / Twitter | 280 | up to 4 | -- | text, or a post with up to four images | +| LinkedIn | 3,000 | up to 20 | -- | text, or a post with up to twenty images | +| Instagram | 2,200 | 1-10 | -- | photo + caption; two or more images publish as a carousel (Business or Creator account, picked up from the connection) | +| Facebook | 63,206 | no stated cap | yes | text, one photo, one feed post with several photos, or a video, as a Page you manage | +| TikTok | 2,200 | 1-35 | yes | photo post (the first image is the cover), or a video post | +| Bluesky | 300 | up to 4 | -- | text, with images and link cards | +| Mastodon | 500 | -- | -- | coming soon | +| Threads | 500 | -- | -- | coming soon | + +A post carries either images or one video, never both -- no platform here takes a mix. + +Attaching more images than a platform accepts, or a video to a channel with no video +column above, fails **that channel only**, with the reason on the post, rather than +quietly publishing a subset or publishing the text on its own -- the other channels in the fan-out still go out. The composer warns you before you get there. Connect each account once in Clawnify (Settings → Integrations), then add it as a channel here. Facebook channels are Pages: the channel form lists the Pages the connected account manages so you can pick one. @@ -100,7 +103,8 @@ database rather than left to the caller. ``` src/ shared/ - platforms.ts -- Platform limits, colours, labels; the media caps the server enforces and the composer warns on + platforms.ts -- Platform limits, colours, labels; which platforms take video, and the media rules the server enforces and the composer warns on + media.ts -- What an attachment is (image or video) and how its type is decided server/ index.ts -- Hono API with D1 + credentials middleware db.ts -- D1-native database adapter diff --git a/package.json b/package.json index 88f299e..7fbcab2 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "wrangler d1 execute open-post-db --local --file=src/server/schema.sql && concurrently -n ui,api -c cyan,green \"vite\" \"wrangler dev --port 8787\"", "build": "vite build", - "test": "esbuild src/server/index.ts --bundle --format=esm --platform=node --outfile=test/.server.mjs && node test/publish-idempotency.mjs" + "test": "esbuild src/server/index.ts --bundle --format=esm --platform=node --outfile=test/.server.mjs && node test/publish-idempotency.mjs && node test/video-publishing.mjs" }, "dependencies": { "@clawnify/app": "^0.1.0", diff --git a/src/client/components/post-card.tsx b/src/client/components/post-card.tsx index 371e611..0f73fa6 100644 --- a/src/client/components/post-card.tsx +++ b/src/client/components/post-card.tsx @@ -1,4 +1,4 @@ -import { Clock, Edit2, Trash2, Send, ExternalLink, AlertCircle } from "lucide-preact"; +import { Clock, Edit2, Trash2, Send, ExternalLink, AlertCircle, Hourglass } from "lucide-preact"; import type { Post, Channel } from "../types"; import { PLATFORM_LABELS } from "../types"; import { PostPreview, hasNativePreview } from "./previews"; @@ -33,6 +33,18 @@ function ChannelChip({ ch }: { ch: Channel }) { const label = PLATFORM_LABELS[ch.platform] || ch.platform; const base = "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium text-white"; + // Sent, and the platform hasn't ruled on it yet — TikTok only accepts a post + // synchronously, the verdict comes later. A pending row carrying a message is + // the one that has already gone out; a plain pending row hasn't. Showing them + // the same way would hide a post whose fate nobody knows. + if (ch.delivery_status === "pending" && ch.delivery_error) { + return ( + + {label} + + ); + } + if (ch.delivery_status === "failed") { return ( @@ -66,7 +78,7 @@ function ChannelChip({ ch }: { ch: Channel }) { export function PostCard({ post, onEdit, onDelete, onPublish, preview }: Props) { const excerpt = post.content.length > 140 ? post.content.slice(0, 140) + "..." : post.content; const previewChannel = preview ? post.channels.find((ch) => hasNativePreview(ch.platform)) : undefined; - const firstImage = post.media[0]?.url; + const firstMedia = post.media[0]; const timeLabel = post.scheduled_at ? new Date(post.scheduled_at + (post.scheduled_at.includes("T") ? "" : "T00:00:00")) .toLocaleDateString("en-US", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) @@ -75,7 +87,7 @@ export function PostCard({ post, onEdit, onDelete, onPublish, preview }: Props) return (
{previewChannel ? ( - + ) : (

{excerpt || "(empty)"} diff --git a/src/client/components/post-composer.tsx b/src/client/components/post-composer.tsx index 0d4dd0d..f0d09dc 100644 --- a/src/client/components/post-composer.tsx +++ b/src/client/components/post-composer.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useMemo } from "preact/hooks"; import { Send, Save, ArrowLeft, Image, X, Upload, Loader2 } from "lucide-preact"; import { useApp } from "../context"; -import { PLATFORM_LIMITS, PLATFORM_LABELS, mediaLimitError } from "../types"; +import { PLATFORM_LIMITS, PLATFORM_LABELS, mediaError, mediaShapeError, mediaTypeFromUrl } from "../types"; +import type { MediaItem } from "../types"; import type { Platform } from "../types"; import { PostPreview, ChannelTabs } from "./previews"; @@ -25,7 +26,7 @@ interface Props { } export function PostComposer({ editId, navigate }: Props) { - const { channels, labels, posts, createPost, updatePost, uploadImage } = useApp(); + const { channels, labels, posts, createPost, updatePost, uploadMedia } = useApp(); const existing = editId ? posts.find((p) => p.id === editId) : null; @@ -38,7 +39,7 @@ export function PostComposer({ editId, navigate }: Props) { const [selectedChannels, setSelectedChannels] = useState([]); const [selectedLabels, setSelectedLabels] = useState([]); const [scheduledAt, setScheduledAt] = useState(""); - const [mediaUrls, setMediaUrls] = useState([]); + const [media, setMedia] = useState([]); const [newMediaUrl, setNewMediaUrl] = useState(""); const [uploading, setUploading] = useState(false); const [saving, setSaving] = useState(false); @@ -56,7 +57,7 @@ export function PostComposer({ editId, navigate }: Props) { setSelectedChannels(existing.channels.map((c) => c.id)); setSelectedLabels(existing.labels.map((l) => l.id)); setScheduledAt(existing.scheduled_at ? utcToLocalInput(existing.scheduled_at) : ""); - setMediaUrls(existing.media.map((m) => m.url)); + setMedia(existing.media.map((m) => ({ url: m.url, type: m.type }))); } }, [existing?.id]); @@ -95,20 +96,26 @@ export function PostComposer({ editId, navigate }: Props) { ? 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 — - // while the images are still on screen and removable — instead of letting it - // surface as a delivery error after the fact. + // Why a selected channel can't carry these attachments — too many images, a + // video that platform has no path for, or a mix no platform takes. + // Publishing fails those channels rather than posting a truncated set, so say + // it here, while the attachments are still on screen and removable, instead + // of letting it surface as a delivery error after the fact. + // + // The shape errors don't depend on a channel, so they show even before one is + // picked: "images or a video, not both" is worth saying the moment it is true. const mediaWarnings = useMemo(() => { const messages = new Set(); + const shape = mediaShapeError(media); + if (shape) messages.add(shape); for (const c of channels) { if (!selectedChannels.includes(c.id)) continue; - const msg = mediaLimitError(c.platform, mediaUrls.length); + const msg = mediaError(c.platform, media); // Two channels on the same platform share one message. if (msg) messages.add(msg); } return [...messages]; - }, [selectedChannels, channels, mediaUrls.length]); + }, [selectedChannels, channels, media]); // Which tab is open: null = the shared draft ("All channels"), otherwise the // channel being written and previewed. Falls back to the shared draft when @@ -158,26 +165,27 @@ export function PostComposer({ editId, navigate }: Props) { ); }; + // A pasted link has only its path to go on, so the extension decides whether + // it is a video. An upload doesn't guess — the server reads the file's MIME. const addMedia = () => { - if (newMediaUrl.trim()) { - setMediaUrls((prev) => [...prev, newMediaUrl.trim()]); - setNewMediaUrl(""); - } + const url = newMediaUrl.trim(); + if (!url) return; + setMedia((prev) => [...prev, { url, type: mediaTypeFromUrl(url) }]); + setNewMediaUrl(""); }; const uploadFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; setUploading(true); for (const file of Array.from(files)) { - if (!file.type.startsWith("image/")) continue; - const url = await uploadImage(file); - if (url) setMediaUrls((prev) => [...prev, url]); + const item = await uploadMedia(file); + if (item) setMedia((prev) => [...prev, item]); } setUploading(false); }; const removeMedia = (index: number) => { - setMediaUrls((prev) => prev.filter((_, i) => i !== index)); + setMedia((prev) => prev.filter((_, i) => i !== index)); }; const handleSave = async (status: string) => { @@ -197,7 +205,7 @@ export function PostComposer({ editId, navigate }: Props) { channel_ids: selectedChannels, channel_content, label_ids: selectedLabels, - media_urls: mediaUrls, + media, }; if (editId) { await updatePost(editId, data); @@ -311,12 +319,12 @@ export function PostComposer({ editId, navigate }: Props) { ) : ( <> - Upload images or drag & drop + Upload images or a video or drag & drop )} { uploadFiles((e.target as HTMLInputElement).files); (e.target as HTMLInputElement).value = ""; }} @@ -327,7 +335,7 @@ export function PostComposer({ editId, navigate }: Props) {

setNewMediaUrl((e.target as HTMLInputElement).value)} onKeyDown={(e) => e.key === "Enter" && addMedia()} @@ -348,11 +356,17 @@ export function PostComposer({ editId, navigate }: Props) { ))}
)} - {mediaUrls.length > 0 && ( + {media.length > 0 && (
- {mediaUrls.map((url, i) => ( + {media.map((item, i) => (
- + {item.type === "video" ? ( + // muted + playsinline so the browser paints a first frame + // without the tile becoming something that plays audio. +
diff --git a/src/client/components/previews/facebook-preview.tsx b/src/client/components/previews/facebook-preview.tsx index 43c003a..3056822 100644 --- a/src/client/components/previews/facebook-preview.tsx +++ b/src/client/components/previews/facebook-preview.tsx @@ -1,11 +1,13 @@ import { useState } from "preact/hooks"; import { Globe, MoreHorizontal, X, ThumbsUp, MessageCircle, Forward } from "lucide-preact"; +import type { MediaItem } from "../../types"; +import { PreviewMedia } from "./preview-media"; interface Props { pageName: string; avatarUrl?: string; content: string; - imageUrl?: string; + media?: MediaItem; timeLabel?: string; } @@ -25,7 +27,7 @@ const ACTIONS = [ { Icon: Forward, label: "Share" }, ]; -export function FacebookPreview({ pageName, avatarUrl, content, imageUrl, timeLabel }: Props) { +export function FacebookPreview({ pageName, avatarUrl, content, media, timeLabel }: Props) { const [expanded, setExpanded] = useState(false); const isLong = content.length > COLLAPSE_AT || content.split("\n").length > 5; @@ -69,10 +71,8 @@ export function FacebookPreview({ pageName, avatarUrl, content, imageUrl, timeLa )}
- {/* Image */} - {imageUrl && ( - - )} + {/* Attachment */} + {/* Action bar */}
diff --git a/src/client/components/previews/generic-preview.tsx b/src/client/components/previews/generic-preview.tsx index 80e1a81..d7ef0d3 100644 --- a/src/client/components/previews/generic-preview.tsx +++ b/src/client/components/previews/generic-preview.tsx @@ -1,10 +1,11 @@ import { PLATFORM_LABELS } from "../../types"; -import type { Channel } from "../../types"; +import type { Channel, MediaItem } from "../../types"; +import { PreviewMedia } from "./preview-media"; interface Props { channel: Channel; content: string; - imageUrl?: string; + media?: MediaItem; timeLabel?: string; } @@ -17,7 +18,7 @@ function initials(name: string): string { // Plain text + attachments preview, used for platforms that don't have a // native-looking card yet. Mirrors the post's content as it will be sent. -export function GenericPreview({ channel, content, imageUrl, timeLabel }: Props) { +export function GenericPreview({ channel, content, media, timeLabel }: Props) { return (
@@ -40,9 +41,7 @@ export function GenericPreview({ channel, content, imageUrl, timeLabel }: Props) {content || What do you want to share?}
- {imageUrl && ( - - )} +
); } diff --git a/src/client/components/previews/index.tsx b/src/client/components/previews/index.tsx index d471aa2..21da3fb 100644 --- a/src/client/components/previews/index.tsx +++ b/src/client/components/previews/index.tsx @@ -1,4 +1,4 @@ -import type { Channel } from "../../types"; +import type { Channel, MediaItem } from "../../types"; import { PLATFORM_LABELS } from "../../types"; import { LinkedInPreview } from "./linkedin-preview"; import { XPreview } from "./x-preview"; @@ -18,13 +18,13 @@ export function hasNativePreview(platform: string): boolean { interface Props { channel: Channel; content: string; - imageUrl?: string; + media?: MediaItem; timeLabel?: string; } // Renders the post as it will appear on the channel's platform — a native card // when available, otherwise a generic text+attachments preview. -export function PostPreview({ channel, content, imageUrl, timeLabel }: Props) { +export function PostPreview({ channel, content, media, timeLabel }: Props) { switch (channel.platform) { case "linkedin": return ( @@ -33,7 +33,7 @@ export function PostPreview({ channel, content, imageUrl, timeLabel }: Props) { authorHeadline={channel.profile_headline || channel.handle || undefined} avatarUrl={channel.profile_avatar_url || undefined} content={content} - imageUrl={imageUrl} + media={media} timeLabel={timeLabel} /> ); @@ -44,7 +44,7 @@ export function PostPreview({ channel, content, imageUrl, timeLabel }: Props) { handle={channel.profile_handle || channel.handle || undefined} avatarUrl={channel.profile_avatar_url || undefined} content={content} - imageUrl={imageUrl} + media={media} timeLabel={timeLabel} /> ); @@ -54,7 +54,7 @@ export function PostPreview({ channel, content, imageUrl, timeLabel }: Props) { username={channel.profile_handle || channel.handle || channel.name} avatarUrl={channel.profile_avatar_url || undefined} content={content} - imageUrl={imageUrl} + media={media} timeLabel={timeLabel} /> ); @@ -64,7 +64,7 @@ export function PostPreview({ channel, content, imageUrl, timeLabel }: Props) { pageName={channel.profile_name || channel.name} avatarUrl={channel.profile_avatar_url || undefined} content={content} - imageUrl={imageUrl} + media={media} timeLabel={timeLabel} /> ); @@ -74,12 +74,12 @@ export function PostPreview({ channel, content, imageUrl, timeLabel }: Props) { username={channel.profile_handle || channel.handle || channel.name} avatarUrl={channel.profile_avatar_url || undefined} content={content} - imageUrl={imageUrl} + media={media} timeLabel={timeLabel} /> ); default: - return ; + return ; } } diff --git a/src/client/components/previews/instagram-preview.tsx b/src/client/components/previews/instagram-preview.tsx index 350bbd5..d17c652 100644 --- a/src/client/components/previews/instagram-preview.tsx +++ b/src/client/components/previews/instagram-preview.tsx @@ -1,11 +1,13 @@ import { useState } from "preact/hooks"; import { MoreHorizontal, Heart, MessageCircle, Send, Bookmark, Image as ImageIcon } from "lucide-preact"; +import type { MediaItem } from "../../types"; +import { PreviewMedia } from "./preview-media"; interface Props { username: string; avatarUrl?: string; content: string; - imageUrl?: string; + media?: MediaItem; timeLabel?: string; } @@ -23,7 +25,7 @@ function toUsername(name: string): string { // Instagram truncates captions after ~125 chars behind a "… more". const COLLAPSE_AT = 125; -export function InstagramPreview({ username, avatarUrl, content, imageUrl, timeLabel }: Props) { +export function InstagramPreview({ username, avatarUrl, content, media, timeLabel }: Props) { const [expanded, setExpanded] = useState(false); const handle = toUsername(username); @@ -45,9 +47,10 @@ export function InstagramPreview({ username, avatarUrl, content, imageUrl, timeL
- {/* Image (photo-first; IG requires one) */} - {imageUrl ? ( - + {/* Instagram requires an attachment — this app publishes photo posts, so + a video here is a channel that will fail rather than a Reel. */} + {media ? ( + ) : (
diff --git a/src/client/components/previews/linkedin-preview.tsx b/src/client/components/previews/linkedin-preview.tsx index 627474f..4805d4d 100644 --- a/src/client/components/previews/linkedin-preview.tsx +++ b/src/client/components/previews/linkedin-preview.tsx @@ -1,12 +1,14 @@ import { useState } from "preact/hooks"; import { Globe, MoreHorizontal, X, ThumbsUp, MessageSquare, Repeat2, Send } from "lucide-preact"; +import type { MediaItem } from "../../types"; +import { PreviewMedia } from "./preview-media"; interface Props { authorName: string; authorHeadline?: string; avatarUrl?: string; content: string; - imageUrl?: string; + media?: MediaItem; timeLabel?: string; } @@ -27,7 +29,7 @@ const ACTIONS = [ { Icon: Send, label: "Send" }, ]; -export function LinkedInPreview({ authorName, authorHeadline, avatarUrl, content, imageUrl, timeLabel }: Props) { +export function LinkedInPreview({ authorName, authorHeadline, avatarUrl, content, media, timeLabel }: Props) { const [expanded, setExpanded] = useState(false); const isLong = content.length > COLLAPSE_AT || content.split("\n").length > 4; @@ -74,10 +76,8 @@ export function LinkedInPreview({ authorName, authorHeadline, avatarUrl, content )}
- {/* Image */} - {imageUrl && ( - - )} + {/* Attachment */} + {/* Action bar */}
diff --git a/src/client/components/previews/preview-media.tsx b/src/client/components/previews/preview-media.tsx new file mode 100644 index 0000000..c612999 --- /dev/null +++ b/src/client/components/previews/preview-media.tsx @@ -0,0 +1,17 @@ +import type { MediaItem } from "../../types"; + +// The post's first attachment, rendered as the platform would show it: an +// image, or a video the reviewer can actually play. +// +// One component rather than an `` in each preview card, because the +// attachment is the one part of a preview that isn't platform-specific — only +// its frame is, which is what `class` carries. A preview that quietly rendered +// a video URL into an would show a broken tile and tell the author their +// post is broken when it isn't. +export function PreviewMedia({ media, class: className }: { media?: MediaItem; class: string }) { + if (!media) return null; + if (media.type === "video") { + return