Merge branch 'dev'
This commit is contained in:
commit
932b60998b
|
|
@ -26,6 +26,7 @@ interface ThreadData {
|
||||||
title: string;
|
title: string;
|
||||||
tweets: string[];
|
tweets: string[];
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
tweetImages?: Record<string, string>;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
}
|
}
|
||||||
|
|
@ -200,11 +201,12 @@ routes.put("/api/threads/:id", async (c) => {
|
||||||
const existing = await loadThread(id);
|
const existing = await loadThread(id);
|
||||||
if (!existing) return c.json({ error: "Thread not found" }, 404);
|
if (!existing) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
const { name, handle, title, tweets } = await c.req.json();
|
const { name, handle, title, tweets, tweetImages } = await c.req.json();
|
||||||
if (name !== undefined) existing.name = name;
|
if (name !== undefined) existing.name = name;
|
||||||
if (handle !== undefined) existing.handle = handle;
|
if (handle !== undefined) existing.handle = handle;
|
||||||
if (title !== undefined) existing.title = title;
|
if (title !== undefined) existing.title = title;
|
||||||
if (tweets?.length) existing.tweets = tweets;
|
if (tweets?.length) existing.tweets = tweets;
|
||||||
|
if (tweetImages !== undefined) existing.tweetImages = tweetImages;
|
||||||
existing.updatedAt = Date.now();
|
existing.updatedAt = Date.now();
|
||||||
|
|
||||||
await saveThread(existing);
|
await saveThread(existing);
|
||||||
|
|
@ -215,15 +217,24 @@ routes.delete("/api/threads/:id", async (c) => {
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
|
||||||
// Try to delete associated preview image
|
// Try to delete associated images
|
||||||
const thread = await loadThread(id);
|
const thread = await loadThread(id);
|
||||||
|
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
if (thread?.imageUrl) {
|
if (thread?.imageUrl) {
|
||||||
const filename = thread.imageUrl.split("/").pop();
|
const filename = thread.imageUrl.split("/").pop();
|
||||||
if (filename) {
|
if (filename) {
|
||||||
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
|
||||||
try { await unlink(resolve(genDir, filename)); } catch {}
|
try { await unlink(resolve(genDir, filename)); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Delete per-tweet images
|
||||||
|
if (thread?.tweetImages) {
|
||||||
|
for (const url of Object.values(thread.tweetImages)) {
|
||||||
|
const fname = url.split("/").pop();
|
||||||
|
if (fname) {
|
||||||
|
try { await unlink(resolve(genDir, fname)); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const ok = await deleteThreadFile(id);
|
const ok = await deleteThreadFile(id);
|
||||||
if (!ok) return c.json({ error: "Thread not found" }, 404);
|
if (!ok) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
@ -338,6 +349,162 @@ routes.post("/api/threads/:id/upload-image", async (c) => {
|
||||||
return c.json({ imageUrl });
|
return c.json({ imageUrl });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Per-tweet image endpoints ──
|
||||||
|
|
||||||
|
routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const index = c.req.param("index");
|
||||||
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);
|
||||||
|
|
||||||
|
const thread = await loadThread(id);
|
||||||
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
|
let formData: FormData;
|
||||||
|
try {
|
||||||
|
formData = await c.req.formData();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: "Invalid form data" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = formData.get("file");
|
||||||
|
if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400);
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
||||||
|
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||||
|
return c.json({ error: "Invalid file type. Allowed: png, jpg, webp, gif" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_SIZE = 5 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
return c.json({ error: "File too large. Maximum 5MB" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() || "png";
|
||||||
|
const safeExt = ["png", "jpg", "jpeg", "webp", "gif"].includes(ext) ? ext : "png";
|
||||||
|
const filename = `thread-${id}-tweet-${index}.${safeExt}`;
|
||||||
|
|
||||||
|
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
|
await mkdir(genDir, { recursive: true });
|
||||||
|
|
||||||
|
// Delete old image at this index if replacing
|
||||||
|
if (!thread.tweetImages) thread.tweetImages = {};
|
||||||
|
const oldUrl = thread.tweetImages[index];
|
||||||
|
if (oldUrl) {
|
||||||
|
const oldFilename = oldUrl.split("/").pop();
|
||||||
|
if (oldFilename && oldFilename !== filename) {
|
||||||
|
try { await unlink(resolve(genDir, oldFilename)); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
await writeFile(resolve(genDir, filename), buffer);
|
||||||
|
|
||||||
|
const imageUrl = `/data/files/generated/${filename}`;
|
||||||
|
thread.tweetImages[index] = imageUrl;
|
||||||
|
thread.updatedAt = Date.now();
|
||||||
|
await saveThread(thread);
|
||||||
|
|
||||||
|
return c.json({ imageUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const index = c.req.param("index");
|
||||||
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);
|
||||||
|
|
||||||
|
const thread = await loadThread(id);
|
||||||
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
|
const tweetIndex = parseInt(index, 10);
|
||||||
|
if (tweetIndex < 0 || tweetIndex >= thread.tweets.length) {
|
||||||
|
return c.json({ error: "Tweet index out of range" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAL_KEY = process.env.FAL_KEY || "";
|
||||||
|
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
||||||
|
|
||||||
|
const tweetText = thread.tweets[tweetIndex].substring(0, 200);
|
||||||
|
const prompt = `Social media post image about: ${tweetText}. Dark themed, modern, minimal style with abstract shapes.`;
|
||||||
|
|
||||||
|
const falRes = await fetch("https://fal.run/fal-ai/flux-pro/v1.1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Key ${FAL_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
image_size: "landscape_4_3",
|
||||||
|
num_images: 1,
|
||||||
|
safety_tolerance: "2",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!falRes.ok) {
|
||||||
|
console.error("[tweet-image] fal.ai error:", await falRes.text());
|
||||||
|
return c.json({ error: "Image generation failed" }, 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
const falData = await falRes.json() as { images?: { url: string }[]; output?: { url: string } };
|
||||||
|
const cdnUrl = falData.images?.[0]?.url || falData.output?.url;
|
||||||
|
if (!cdnUrl) return c.json({ error: "No image returned" }, 502);
|
||||||
|
|
||||||
|
const imgRes = await fetch(cdnUrl);
|
||||||
|
if (!imgRes.ok) return c.json({ error: "Failed to download image" }, 502);
|
||||||
|
|
||||||
|
const imgBuffer = await imgRes.arrayBuffer();
|
||||||
|
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
|
await mkdir(genDir, { recursive: true });
|
||||||
|
const filename = `thread-${id}-tweet-${index}.png`;
|
||||||
|
|
||||||
|
// Delete old image at this index if replacing
|
||||||
|
if (!thread.tweetImages) thread.tweetImages = {};
|
||||||
|
const oldUrl = thread.tweetImages[index];
|
||||||
|
if (oldUrl) {
|
||||||
|
const oldFilename = oldUrl.split("/").pop();
|
||||||
|
if (oldFilename && oldFilename !== filename) {
|
||||||
|
try { await unlink(resolve(genDir, oldFilename)); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(resolve(genDir, filename), Buffer.from(imgBuffer));
|
||||||
|
|
||||||
|
const imageUrl = `/data/files/generated/${filename}`;
|
||||||
|
thread.tweetImages[index] = imageUrl;
|
||||||
|
thread.updatedAt = Date.now();
|
||||||
|
await saveThread(thread);
|
||||||
|
|
||||||
|
return c.json({ imageUrl });
|
||||||
|
});
|
||||||
|
|
||||||
|
routes.delete("/api/threads/:id/tweet/:index/image", async (c) => {
|
||||||
|
const id = c.req.param("id");
|
||||||
|
const index = c.req.param("index");
|
||||||
|
if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
|
||||||
|
if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);
|
||||||
|
|
||||||
|
const thread = await loadThread(id);
|
||||||
|
if (!thread) return c.json({ error: "Thread not found" }, 404);
|
||||||
|
|
||||||
|
if (!thread.tweetImages?.[index]) return c.json({ ok: true });
|
||||||
|
|
||||||
|
const url = thread.tweetImages[index];
|
||||||
|
const fname = url.split("/").pop();
|
||||||
|
if (fname) {
|
||||||
|
const genDir = resolve(process.env.FILES_DIR || "./data/files", "generated");
|
||||||
|
try { await unlink(resolve(genDir, fname)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete thread.tweetImages[index];
|
||||||
|
if (Object.keys(thread.tweetImages).length === 0) delete thread.tweetImages;
|
||||||
|
thread.updatedAt = Date.now();
|
||||||
|
await saveThread(thread);
|
||||||
|
|
||||||
|
return c.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// ── Demo feed data (server-rendered, no API calls) ──
|
// ── Demo feed data (server-rendered, no API calls) ──
|
||||||
const DEMO_FEED = [
|
const DEMO_FEED = [
|
||||||
{
|
{
|
||||||
|
|
@ -763,6 +930,25 @@ const THREAD_CSS = `
|
||||||
}
|
}
|
||||||
.thread-export-menu button:hover { background: rgba(99,102,241,0.15); }
|
.thread-export-menu button:hover { background: rgba(99,102,241,0.15); }
|
||||||
.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); }
|
.thread-export-menu button + button { border-top: 1px solid var(--rs-bg-hover); }
|
||||||
|
.tweet-card__image-bar { display: flex; gap: 0.4rem; margin-top: 0.5rem; }
|
||||||
|
.tweet-card__image-btn {
|
||||||
|
padding: 0.25rem 0.5rem; border-radius: 6px; font-size: 0.7rem; font-weight: 600;
|
||||||
|
cursor: pointer; transition: all 0.15s; background: transparent;
|
||||||
|
color: var(--rs-text-muted); border: 1px solid var(--rs-input-border);
|
||||||
|
display: inline-flex; align-items: center; gap: 0.25rem;
|
||||||
|
}
|
||||||
|
.tweet-card__image-btn:hover { border-color: #6366f1; color: #c4b5fd; }
|
||||||
|
.tweet-card__image-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.tweet-card__image-btn svg { width: 12px; height: 12px; }
|
||||||
|
.tweet-card__attached-image { position: relative; margin-top: 0.5rem; border-radius: 8px; overflow: hidden; border: 1px solid var(--rs-input-border); }
|
||||||
|
.tweet-card__attached-image img { display: block; width: 100%; height: auto; }
|
||||||
|
.tweet-card__image-remove {
|
||||||
|
position: absolute; top: 6px; right: 6px; width: 22px; height: 22px;
|
||||||
|
border-radius: 50%; background: rgba(0,0,0,0.7); color: white; border: none;
|
||||||
|
font-size: 0.8rem; cursor: pointer; display: flex; align-items: center;
|
||||||
|
justify-content: center; line-height: 1; transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.tweet-card__image-remove:hover { background: #ef4444; }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string {
|
function renderThreadBuilderPage(space: string, threadData?: ThreadData | null): string {
|
||||||
|
|
@ -817,6 +1003,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
<div class="thread-preview" id="thread-preview">
|
<div class="thread-preview" id="thread-preview">
|
||||||
<div class="thread-preview__empty">Your tweet thread preview will appear here</div>
|
<div class="thread-preview__empty">Your tweet thread preview will appear here</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" id="tweet-image-input" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
|
||||||
</div>
|
</div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
// Derive base from actual URL — bare-domain rewrites inject /demo/ which isn't in the browser path
|
// Derive base from actual URL — bare-domain rewrites inject /demo/ which isn't in the browser path
|
||||||
|
|
@ -824,6 +1011,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
const base = window.location.pathname.startsWith(declared) ? declared : declared.replace(/^\\/[^\\/]+/, '');
|
const base = window.location.pathname.startsWith(declared) ? declared : declared.replace(/^\\/[^\\/]+/, '');
|
||||||
let currentThreadId = null;
|
let currentThreadId = null;
|
||||||
let autoSaveTimer = null;
|
let autoSaveTimer = null;
|
||||||
|
let tweetImages = {};
|
||||||
|
let tweetImageUploadIdx = null;
|
||||||
|
|
||||||
const textarea = document.getElementById('thread-input');
|
const textarea = document.getElementById('thread-input');
|
||||||
const preview = document.getElementById('thread-preview');
|
const preview = document.getElementById('thread-preview');
|
||||||
|
|
@ -849,6 +1038,9 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
|
|
||||||
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
|
const svgUpload = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
|
||||||
|
const svgSparkle = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z"/></svg>';
|
||||||
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
const raw = textarea.value;
|
const raw = textarea.value;
|
||||||
const tweets = raw.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
|
const tweets = raw.split(/\\n---\\n/).map(t => t.trim()).filter(Boolean);
|
||||||
|
|
@ -866,6 +1058,17 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
const len = text.length;
|
const len = text.length;
|
||||||
const overClass = len > 280 ? ' tweet-card__chars--over' : '';
|
const overClass = len > 280 ? ' tweet-card__chars--over' : '';
|
||||||
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : '';
|
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : '';
|
||||||
|
const imgUrl = tweetImages[String(i)];
|
||||||
|
const imgHtml = imgUrl
|
||||||
|
? '<div class="tweet-card__attached-image">' +
|
||||||
|
'<img src="' + esc(imgUrl) + '" alt="Tweet image">' +
|
||||||
|
'<button class="tweet-card__image-remove" data-remove-idx="' + i + '" title="Remove image">×</button>' +
|
||||||
|
'</div>'
|
||||||
|
: '';
|
||||||
|
const imageBar = '<div class="tweet-card__image-bar">' +
|
||||||
|
'<button class="tweet-card__image-btn" data-upload-idx="' + i + '">' + svgUpload + ' Upload</button>' +
|
||||||
|
'<button class="tweet-card__image-btn" data-generate-idx="' + i + '">' + svgSparkle + ' AI</button>' +
|
||||||
|
'</div>';
|
||||||
return '<div class="tweet-card">' +
|
return '<div class="tweet-card">' +
|
||||||
connector +
|
connector +
|
||||||
'<div class="tweet-card__header">' +
|
'<div class="tweet-card__header">' +
|
||||||
|
|
@ -876,6 +1079,8 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
'<span class="tweet-card__time">now</span>' +
|
'<span class="tweet-card__time">now</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<p class="tweet-card__content">' + esc(text) + '</p>' +
|
'<p class="tweet-card__content">' + esc(text) + '</p>' +
|
||||||
|
imgHtml +
|
||||||
|
imageBar +
|
||||||
'<div class="tweet-card__footer">' +
|
'<div class="tweet-card__footer">' +
|
||||||
'<div class="tweet-card__actions">' +
|
'<div class="tweet-card__actions">' +
|
||||||
'<span class="tweet-card__action">' + svgReply + '</span>' +
|
'<span class="tweet-card__action">' + svgReply + '</span>' +
|
||||||
|
|
@ -903,6 +1108,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
handle: handleInput.value || '@yourhandle',
|
handle: handleInput.value || '@yourhandle',
|
||||||
title: titleInput.value || tweets[0].substring(0, 60),
|
title: titleInput.value || tweets[0].substring(0, 60),
|
||||||
tweets,
|
tweets,
|
||||||
|
tweetImages: Object.keys(tweetImages).length ? tweetImages : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -948,6 +1154,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
handleInput.value = data.handle || '';
|
handleInput.value = data.handle || '';
|
||||||
titleInput.value = data.title || '';
|
titleInput.value = data.title || '';
|
||||||
textarea.value = data.tweets.join('\\n---\\n');
|
textarea.value = data.tweets.join('\\n---\\n');
|
||||||
|
tweetImages = data.tweetImages || {};
|
||||||
|
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
imageThumb.src = data.imageUrl;
|
imageThumb.src = data.imageUrl;
|
||||||
|
|
@ -974,6 +1181,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
await fetch(base + 'api/threads/' + id, { method: 'DELETE' });
|
await fetch(base + 'api/threads/' + id, { method: 'DELETE' });
|
||||||
if (currentThreadId === id) {
|
if (currentThreadId === id) {
|
||||||
currentThreadId = null;
|
currentThreadId = null;
|
||||||
|
tweetImages = {};
|
||||||
history.replaceState(null, '', base + 'thread');
|
history.replaceState(null, '', base + 'thread');
|
||||||
imagePreview.hidden = true;
|
imagePreview.hidden = true;
|
||||||
genImageBtn.textContent = 'Generate with AI';
|
genImageBtn.textContent = 'Generate with AI';
|
||||||
|
|
@ -1134,6 +1342,63 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Per-tweet image helpers ──
|
||||||
|
const tweetImageInput = document.getElementById('tweet-image-input');
|
||||||
|
|
||||||
|
async function uploadTweetImage(index, file) {
|
||||||
|
if (!currentThreadId) { await saveDraft(); if (!currentThreadId) return; }
|
||||||
|
const btn = preview.querySelector('[data-upload-idx="' + index + '"]');
|
||||||
|
if (btn) { btn.textContent = 'Uploading...'; btn.disabled = true; }
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await fetch(base + 'api/threads/' + currentThreadId + '/tweet/' + index + '/upload-image', { method: 'POST', body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.imageUrl) { tweetImages[String(index)] = data.imageUrl; renderPreview(); }
|
||||||
|
} catch (e) { console.error('Tweet image upload failed:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateTweetImage(index) {
|
||||||
|
if (!currentThreadId) { await saveDraft(); if (!currentThreadId) return; }
|
||||||
|
const btn = preview.querySelector('[data-generate-idx="' + index + '"]');
|
||||||
|
if (btn) { btn.textContent = 'Generating...'; btn.disabled = true; }
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + 'api/threads/' + currentThreadId + '/tweet/' + index + '/image', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.imageUrl) { tweetImages[String(index)] = data.imageUrl; renderPreview(); }
|
||||||
|
else if (btn) { btn.textContent = 'Failed'; setTimeout(() => renderPreview(), 2000); }
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Tweet image generation failed:', e);
|
||||||
|
if (btn) { btn.textContent = 'Failed'; setTimeout(() => renderPreview(), 2000); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTweetImage(index) {
|
||||||
|
if (!currentThreadId) return;
|
||||||
|
try {
|
||||||
|
await fetch(base + 'api/threads/' + currentThreadId + '/tweet/' + index + '/image', { method: 'DELETE' });
|
||||||
|
delete tweetImages[String(index)];
|
||||||
|
renderPreview();
|
||||||
|
} catch (e) { console.error('Tweet image removal failed:', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event delegation on preview container
|
||||||
|
preview.addEventListener('click', (e) => {
|
||||||
|
const uploadBtn = e.target.closest('[data-upload-idx]');
|
||||||
|
if (uploadBtn) { tweetImageUploadIdx = uploadBtn.dataset.uploadIdx; tweetImageInput.click(); return; }
|
||||||
|
const genBtn = e.target.closest('[data-generate-idx]');
|
||||||
|
if (genBtn) { generateTweetImage(genBtn.dataset.generateIdx); return; }
|
||||||
|
const removeBtn = e.target.closest('[data-remove-idx]');
|
||||||
|
if (removeBtn) { removeTweetImage(removeBtn.dataset.removeIdx); return; }
|
||||||
|
});
|
||||||
|
|
||||||
|
tweetImageInput.addEventListener('change', () => {
|
||||||
|
const file = tweetImageInput.files?.[0];
|
||||||
|
if (file && tweetImageUploadIdx !== null) uploadTweetImage(tweetImageUploadIdx, file);
|
||||||
|
tweetImageInput.value = '';
|
||||||
|
tweetImageUploadIdx = null;
|
||||||
|
});
|
||||||
|
|
||||||
saveBtn.addEventListener('click', saveDraft);
|
saveBtn.addEventListener('click', saveDraft);
|
||||||
shareBtn.addEventListener('click', shareThread);
|
shareBtn.addEventListener('click', shareThread);
|
||||||
genImageBtn.addEventListener('click', generateImage);
|
genImageBtn.addEventListener('click', generateImage);
|
||||||
|
|
@ -1227,6 +1492,7 @@ function renderThreadBuilderPage(space: string, threadData?: ThreadData | null):
|
||||||
handleInput.value = data.handle || '';
|
handleInput.value = data.handle || '';
|
||||||
titleInput.value = data.title || '';
|
titleInput.value = data.title || '';
|
||||||
textarea.value = data.tweets.join('\\n---\\n');
|
textarea.value = data.tweets.join('\\n---\\n');
|
||||||
|
tweetImages = data.tweetImages || {};
|
||||||
if (data.imageUrl) {
|
if (data.imageUrl) {
|
||||||
imageThumb.src = data.imageUrl;
|
imageThumb.src = data.imageUrl;
|
||||||
imagePreview.hidden = false;
|
imagePreview.hidden = false;
|
||||||
|
|
@ -1252,6 +1518,10 @@ function renderThreadReadOnly(space: string, thread: ThreadData): string {
|
||||||
const tweetCards = thread.tweets.map((text, i) => {
|
const tweetCards = thread.tweets.map((text, i) => {
|
||||||
const len = text.length;
|
const len = text.length;
|
||||||
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : "";
|
const connector = i > 0 ? '<div class="tweet-card__connector"></div>' : "";
|
||||||
|
const tweetImgUrl = thread.tweetImages?.[String(i)];
|
||||||
|
const tweetImgHtml = tweetImgUrl
|
||||||
|
? `<div class="tweet-card__attached-image"><img src="${escapeHtml(tweetImgUrl)}" alt="Tweet image"></div>`
|
||||||
|
: "";
|
||||||
return `<div class="tweet-card">
|
return `<div class="tweet-card">
|
||||||
${connector}
|
${connector}
|
||||||
<div class="tweet-card__header">
|
<div class="tweet-card__header">
|
||||||
|
|
@ -1262,6 +1532,7 @@ function renderThreadReadOnly(space: string, thread: ThreadData): string {
|
||||||
<span class="tweet-card__time">${escapeHtml(dateStr)}</span>
|
<span class="tweet-card__time">${escapeHtml(dateStr)}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="tweet-card__content">${escapeHtml(text)}</p>
|
<p class="tweet-card__content">${escapeHtml(text)}</p>
|
||||||
|
${tweetImgHtml}
|
||||||
<div class="tweet-card__footer">
|
<div class="tweet-card__footer">
|
||||||
<div class="tweet-card__actions">
|
<div class="tweet-card__actions">
|
||||||
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg></span>
|
<span class="tweet-card__action"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg></span>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue