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
28 changes: 17 additions & 11 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** -- upload images to posts
- **Media attachments** -- attach several images to a post; every platform publishes the whole set (carousel, gallery or multi-photo, whichever that platform calls it)
- **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 @@ -23,16 +23,20 @@ Think of it as an open-source alternative to **Buffer**, **Hypefury**, **Typeful

### Supported Platforms

| Platform | Character Limit | Publishing |
|----------|----------------|------------|
| X / Twitter | 280 | text or photo |
| LinkedIn | 3,000 | text or photo |
| Instagram | 2,200 | photo + caption (Business or Creator account, picked up from the connection) |
| Facebook | 63,206 | text or photo, as a Page you manage |
| TikTok | 2,200 | photo post |
| Bluesky | 300 | text, with an image and link cards |
| Mastodon | 500 | coming soon |
| Threads | 500 | coming soon |
| 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
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 @@ -75,6 +79,8 @@ Publishing runs through the accounts connected in Clawnify -- no API keys in the

```
src/
shared/
platforms.ts -- Platform limits, colours, labels; the media caps the server enforces and the composer warns on
server/
index.ts -- Hono API with D1 + credentials middleware
db.ts -- D1-native database adapter
Expand Down
24 changes: 23 additions & 1 deletion src/client/components/post-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 } from "../types";
import { PLATFORM_LIMITS, PLATFORM_LABELS, mediaLimitError } from "../types";
import type { Platform } from "../types";
import { PostPreview, PreviewChannelTabs } from "./previews";

Expand Down Expand Up @@ -57,6 +57,21 @@ export function PostComposer({ editId, navigate }: Props) {

const overLimit = charLimit !== null && content.length > charLimit;

// 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.
const mediaWarnings = useMemo(() => {
const messages = new Set<string>();
for (const c of channels) {
if (!selectedChannels.includes(c.id)) continue;
const msg = mediaLimitError(c.platform, mediaUrls.length);
// Two channels on the same platform share one message.
if (msg) messages.add(msg);
}
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,
Expand Down Expand Up @@ -207,6 +222,13 @@ export function PostComposer({ editId, navigate }: Props) {
<Image size={14} /> Add
</button>
</div>
{mediaWarnings.length > 0 && (
<div class="mt-2 space-y-1">
{mediaWarnings.map((msg) => (
<p key={msg} class="text-xs text-destructive">{msg}</p>
))}
</div>
)}
{mediaUrls.length > 0 && (
<div class="flex gap-2 mt-3 flex-wrap">
{mediaUrls.map((url, i) => (
Expand Down
62 changes: 13 additions & 49 deletions src/client/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,19 @@ export type PostStatus = "draft" | "scheduled" | "published" | "partial" | "fail
// Per-channel delivery state on a post's channel (Postiz-style). Present on the
// Channel objects returned inside a Post; absent in the standalone channel list.
export type DeliveryStatus = "pending" | "published" | "failed";
export type Platform = "twitter" | "linkedin" | "instagram" | "facebook" | "bluesky" | "mastodon" | "threads" | "tiktok";

// The platforms the server can actually publish to — the single source the
// channel picker offers. It MUST track the cases in publishToChannel()
// (src/server/index.ts): a platform here with no server case would let a user
// schedule posts that only fail at send time (the mastodon/threads trap this
// list closes); a server case missing here is simply not offered.
// mastodon/threads stay out until they have a publish path.
export const PUBLISHABLE_PLATFORMS: Platform[] = [
"twitter",
"linkedin",
"instagram",
"facebook",
"tiktok",
"bluesky",
];
// Platform metadata (limits, colours, labels, the publishable list) lives in
// src/shared/platforms.ts because the server enforces the same tables. Re-exported
// here so client code keeps importing platform facts from one place.
export {
PUBLISHABLE_PLATFORMS,
PLATFORM_LIMITS,
PLATFORM_MEDIA_LIMITS,
PLATFORM_COLORS,
PLATFORM_LABELS,
mediaLimitError,
} from "../shared/platforms";
export type { Platform } from "../shared/platforms";
import type { Platform } from "../shared/platforms";

// A Facebook Page the connected account manages (GET /api/platforms/facebook/pages).
export interface FacebookPage {
Expand All @@ -27,39 +24,6 @@ export interface FacebookPage {
username: string | null;
}

export const PLATFORM_LIMITS: Record<Platform, number> = {
twitter: 280,
linkedin: 3000,
instagram: 2200,
facebook: 63206,
bluesky: 300,
mastodon: 500,
threads: 500,
tiktok: 2200,
};

export const PLATFORM_COLORS: Record<Platform, string> = {
twitter: "#1da1f2",
linkedin: "#0a66c2",
instagram: "#e4405f",
facebook: "#1877f2",
bluesky: "#0085ff",
mastodon: "#6364ff",
threads: "#000000",
tiktok: "#00f2ea",
};

export const PLATFORM_LABELS: Record<Platform, string> = {
twitter: "X / Twitter",
linkedin: "LinkedIn",
instagram: "Instagram",
facebook: "Facebook",
bluesky: "Bluesky",
mastodon: "Mastodon",
threads: "Threads",
tiktok: "TikTok",
};

export interface Channel {
id: number;
name: string;
Expand Down
22 changes: 13 additions & 9 deletions src/server/bluesky.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ async function uploadBlob(
return { blob: data.blob };
}

// Publish one post: createSession → (optional) uploadBlob → createRecord.
// Publish one post: createSession → (optional) uploadBlob per image → createRecord.
export async function publishToBluesky(
creds: BlueskyCreds,
content: string,
imageUrl?: string,
imageUrls: string[] = [],
): Promise<BlueskyResult> {
const base = trimBase(creds.service);
const session = await createSession(creds);
Expand All @@ -146,13 +146,17 @@ export async function publishToBluesky(
const facets = detectFacets(content);
if (facets.length) record.facets = facets;

if (imageUrl) {
const up = await uploadBlob(base, session.accessJwt, imageUrl);
if ("error" in up) return { success: false, error: up.error };
record.embed = {
$type: "app.bsky.embed.images",
images: [{ image: up.blob, alt: "" }],
};
if (imageUrls.length) {
// One blob upload per image, in order — the lexicon caps the embed at 4,
// which the caller has already checked. A single failed blob fails the
// whole post rather than shipping the rest without it.
const images: Array<{ image: unknown; alt: string }> = [];
for (const url of imageUrls) {
const up = await uploadBlob(base, session.accessJwt, url);
if ("error" in up) return { success: false, error: up.error };
images.push({ image: up.blob, alt: "" });
}
record.embed = { $type: "app.bsky.embed.images", images };
}

const res = await fetch(`${base}/xrpc/com.atproto.repo.createRecord`, {
Expand Down
Loading