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
34 changes: 19 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 15 additions & 3 deletions src/client/components/post-card.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<span class={`${base} opacity-75`} style={{ background: ch.color }} title={ch.delivery_error}>
{label} <Hourglass size={11} />
</span>
);
}

if (ch.delivery_status === "failed") {
return (
<span class={`${base} opacity-60`} style={{ background: ch.color }} title={ch.delivery_error || "Failed to publish"}>
Expand Down Expand Up @@ -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" })
Expand All @@ -75,7 +87,7 @@ export function PostCard({ post, onEdit, onDelete, onPublish, preview }: Props)
return (
<div class="bg-card border border-border rounded-lg p-4 hover:shadow-md transition-shadow">
{previewChannel ? (
<PostPreview channel={previewChannel} content={post.content} imageUrl={firstImage} timeLabel={timeLabel} />
<PostPreview channel={previewChannel} content={post.content} media={firstMedia} timeLabel={timeLabel} />
) : (
<p class="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{excerpt || "(empty)"}
Expand Down
66 changes: 40 additions & 26 deletions src/client/components/post-composer.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;

Expand All @@ -38,7 +39,7 @@ export function PostComposer({ editId, navigate }: Props) {
const [selectedChannels, setSelectedChannels] = useState<number[]>([]);
const [selectedLabels, setSelectedLabels] = useState<number[]>([]);
const [scheduledAt, setScheduledAt] = useState("");
const [mediaUrls, setMediaUrls] = useState<string[]>([]);
const [media, setMedia] = useState<MediaItem[]>([]);
const [newMediaUrl, setNewMediaUrl] = useState("");
const [uploading, setUploading] = useState(false);
const [saving, setSaving] = useState(false);
Expand All @@ -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]);

Expand Down Expand Up @@ -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<string>();
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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down Expand Up @@ -311,12 +319,12 @@ export function PostComposer({ editId, navigate }: Props) {
) : (
<>
<Upload size={18} />
<span><span class="text-foreground font-medium">Upload images</span> or drag &amp; drop</span>
<span><span class="text-foreground font-medium">Upload images or a video</span> or drag &amp; drop</span>
</>
)}
<input
type="file"
accept="image/*"
accept="image/*,video/*"
multiple
class="hidden"
onChange={(e) => { uploadFiles((e.target as HTMLInputElement).files); (e.target as HTMLInputElement).value = ""; }}
Expand All @@ -327,7 +335,7 @@ export function PostComposer({ editId, navigate }: Props) {
<div class="flex gap-2 mt-2">
<input
type="url"
placeholder="…or paste an image URL"
placeholder="…or paste an image or video URL"
value={newMediaUrl}
onInput={(e) => setNewMediaUrl((e.target as HTMLInputElement).value)}
onKeyDown={(e) => e.key === "Enter" && addMedia()}
Expand All @@ -348,11 +356,17 @@ export function PostComposer({ editId, navigate }: Props) {
))}
</div>
)}
{mediaUrls.length > 0 && (
{media.length > 0 && (
<div class="flex gap-2 mt-3 flex-wrap">
{mediaUrls.map((url, i) => (
{media.map((item, i) => (
<div key={i} class="relative group w-20 h-20 rounded-md overflow-hidden border border-border">
<img src={url} alt="" class="w-full h-full object-cover" />
{item.type === "video" ? (
// muted + playsinline so the browser paints a first frame
// without the tile becoming something that plays audio.
<video src={item.url} class="w-full h-full object-cover bg-black" muted playsInline preload="metadata" />
) : (
<img src={item.url} alt="" class="w-full h-full object-cover" />
)}
<button
class="absolute top-0.5 right-0.5 p-0.5 bg-black/60 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeMedia(i)}
Expand All @@ -374,7 +388,7 @@ export function PostComposer({ editId, navigate }: Props) {
<PostPreview
channel={activePreviewChannel}
content={overrides[activePreviewChannel.id] ?? content}
imageUrl={mediaUrls[0]}
media={media[0]}
timeLabel="Now"
/>
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/client/components/previews/facebook-preview.tsx
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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;
Expand Down Expand Up @@ -69,10 +71,8 @@ export function FacebookPreview({ pageName, avatarUrl, content, imageUrl, timeLa
)}
</div>

{/* Image */}
{imageUrl && (
<img src={imageUrl} alt="" class="w-full max-h-[500px] object-cover bg-[#f0f2f5]" />
)}
{/* Attachment */}
<PreviewMedia media={media} class="w-full max-h-[500px] object-cover bg-[#f0f2f5]" />

{/* Action bar */}
<div class="mx-4 mt-1 border-t border-[#ced0d4] flex items-center justify-around py-1">
Expand Down
11 changes: 5 additions & 6 deletions src/client/components/previews/generic-preview.tsx
Original file line number Diff line number Diff line change
@@ -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;
}

Expand All @@ -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 (
<div class="bg-card rounded-lg border border-border max-w-[552px] overflow-hidden">
<div class="flex items-center gap-2 px-4 pt-3">
Expand All @@ -40,9 +41,7 @@ export function GenericPreview({ channel, content, imageUrl, timeLabel }: Props)
{content || <span class="text-muted-foreground">What do you want to share?</span>}
</div>

{imageUrl && (
<img src={imageUrl} alt="" class="w-full max-h-[480px] object-cover bg-muted" />
)}
<PreviewMedia media={media} class="w-full max-h-[480px] object-cover bg-muted" />
</div>
);
}
Loading