From c428ec69e923a5543c20355d40bfb64f0d5f4e9a Mon Sep 17 00:00:00 2001 From: Fardin Hakimi Date: Sat, 14 Feb 2026 00:58:12 +0100 Subject: [PATCH 01/16] Create CNAME --- docs/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..f758282 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +docs.funrang.com \ No newline at end of file From 9e8f35698069732d7274de887959ed34db880829 Mon Sep 17 00:00:00 2001 From: Fardin Hakimi Date: Sat, 14 Feb 2026 01:32:51 +0100 Subject: [PATCH 02/16] Delete CNAME --- docs/CNAME | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index f758282..0000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -docs.funrang.com \ No newline at end of file From 48f72c4f76bd7ccad9073fb58f3ddb3115d72ad1 Mon Sep 17 00:00:00 2001 From: Fardin Hakimi Date: Sat, 14 Feb 2026 01:58:22 +0100 Subject: [PATCH 03/16] Create CNAME --- docs/CNAME | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/CNAME diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..f758282 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +docs.funrang.com \ No newline at end of file From c8e0768d211afd14e24ff2b8f9545e33983ce702 Mon Sep 17 00:00:00 2001 From: fardin Date: Sat, 14 Feb 2026 02:17:50 +0100 Subject: [PATCH 04/16] wip --- docs/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index c33b7f4..5ad0525 100644 --- a/docs/index.html +++ b/docs/index.html @@ -24,7 +24,7 @@

Funrang

Contact

Support: fardin4work@gmail.com
- Website: https://funrang.app + Website: https://funrang.com

Last updated: February 13, 2026

From 11e26f8ea18eaa743c0aa2cea02da64f7187eac0 Mon Sep 17 00:00:00 2001 From: fardin Date: Sun, 15 Feb 2026 12:40:06 +0100 Subject: [PATCH 05/16] WIP --- .env.example | 2 + migrations/0048_posts_job_id/down.sql | 5 + migrations/0048_posts_job_id/up.sql | 19 ++ scripts/migrate.ts | 2 +- src/api/posts.ts | 261 +++++++++++++----- src/db/postsRepo.ts | 13 +- src/jobs/worker.ts | 2 +- src/routes/pages.ts | 10 +- src/routes/render.ts | 32 ++- src/runner/aiRunner.ts | 1 + src/runner/realRunner.ts | 1 + src/runner/types.ts | 1 + src/ui/index.html | 9 +- src/ui/styles/input.css | 7 + src/ui/styles/tailwind.css | 9 + .../jobs/components/JobDefinitionDrawer.vue | 20 ++ .../jobs/components/JobDefinitionsPanel.vue | 1 + .../vue/jobs/components/RecentRunsPanel.vue | 9 + src/ui/vue/jobs/stores/jobDrawerStore.ts | 61 ++-- src/ui/vue/jobs/stores/jobRunsStore.ts | 22 +- src/ui/vue/jobs/stores/runSummaryStore.ts | 2 +- src/ui/vue/post/components/ReelsPanel.vue | 35 ++- src/ui/vue/post/components/ShortsPanel.vue | 35 ++- src/ui/vue/post/lib/publishConfirm.ts | 83 +++++- src/ui/vue/shared/components/DrawerShell.vue | 96 ++----- 25 files changed, 524 insertions(+), 214 deletions(-) create mode 100644 migrations/0048_posts_job_id/down.sql create mode 100644 migrations/0048_posts_job_id/up.sql diff --git a/.env.example b/.env.example index 0a07402..38b0357 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ OPENAI_API_KEY=your_openai_api_key NODE_ENV=production # Use .env.dev for development and .env.test for tests (see package.json scripts) +# Scheduler cron expression (default every 4 hours) +CRON=0 */4 * * * # Postgres connection string DATABASE_URL=postgres://ai_farm_brain:ai_farm_brain_password@localhost:5432/funrang_dev diff --git a/migrations/0048_posts_job_id/down.sql b/migrations/0048_posts_job_id/down.sql new file mode 100644 index 0000000..f964b92 --- /dev/null +++ b/migrations/0048_posts_job_id/down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_posts_org_job_id_created_at; + +ALTER TABLE posts + DROP CONSTRAINT IF EXISTS posts_job_id_fkey, + DROP COLUMN IF EXISTS job_id; diff --git a/migrations/0048_posts_job_id/up.sql b/migrations/0048_posts_job_id/up.sql new file mode 100644 index 0000000..8f46b5d --- /dev/null +++ b/migrations/0048_posts_job_id/up.sql @@ -0,0 +1,19 @@ +ALTER TABLE posts + ADD COLUMN IF NOT EXISTS job_id UUID; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'posts_job_id_fkey' + ) THEN + ALTER TABLE posts + ADD CONSTRAINT posts_job_id_fkey + FOREIGN KEY (job_id) REFERENCES jobs(id) + ON DELETE SET NULL; + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS idx_posts_org_job_id_created_at + ON posts (organization_id, job_id, created_at DESC); diff --git a/scripts/migrate.ts b/scripts/migrate.ts index 7ef511c..e2f59ac 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -7,7 +7,7 @@ import { Pool } from "pg"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const migrationsDir = path.resolve(__dirname, "../migrations"); -const TARGET_VERSION = "0047_post_media_settings"; +const TARGET_VERSION = "0048_posts_job_id"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) { diff --git a/src/api/posts.ts b/src/api/posts.ts index 2ae9071..de51a38 100644 --- a/src/api/posts.ts +++ b/src/api/posts.ts @@ -3,14 +3,13 @@ import { query, queryOne } from "../db/index.js"; import { insertRejection } from "../db/rejections.js"; import { listSocialAccounts } from "../db/socialAccounts.js"; import { insertPostSchedule, listPostSchedules } from "../db/postSchedules.js"; -import { listPostImages } from "../db/postImages.js"; +import { insertPostImage, listPostImages } from "../db/postImages.js"; import { deletePostReelById, getPostReelById, insertPostReel } from "../db/postReels.js"; import { insertPostReelPublish } from "../db/postReelPublishes.js"; import { deletePostShortById, getPostShortById, insertPostShort } from "../db/postShorts.js"; import { insertPostShortPublish } from "../db/postShortPublishes.js"; import { buildKenBurnsReel, buildKenBurnsSlideshow, buildVideoTemplateMontage } from "../reels/reelBuilder.js"; import { generateCoverImage } from "../ai/imageGen.js"; -import { saveFacebookDraft } from "../runner/helpers.js"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; @@ -25,6 +24,12 @@ import { spawn } from "node:child_process"; type TemplateImageInput = { url: string; durationSeconds?: number }; type TemplateVideoInput = { url: string; durationSeconds: number }; type PostMediaAsset = { url?: string; preview?: string; filePath?: string; label?: string; source?: string }; +type PostVideoSettingsRow = { + image_prompt: string | null; + post_media_mode: string | null; + post_media_source: string | null; + post_media_count: number | null; +}; function stripHashtags(input: string) { return input @@ -229,6 +234,123 @@ async function getAudioDurationSeconds(audioPath: string): Promise { }); } +async function getPostVideoSettings(orgId: string, postId: string): Promise<{ + imagePrompt: string; + mediaMode: "text" | "single" | "carousel"; + mediaSource: "ai" | "uploads" | "pexels"; + mediaCount: number; +}> { + const row = await queryOne( + ` + SELECT image_prompt, post_media_mode, post_media_source, post_media_count + FROM posts + WHERE id = $1 AND organization_id = $2 + `, + [postId, orgId] + ); + if (!row) throw new Error("Post not found"); + return { + imagePrompt: (row.image_prompt ?? "").trim(), + mediaMode: normalizeMediaMode(row.post_media_mode), + mediaSource: normalizeMediaSource(row.post_media_source), + mediaCount: normalizeMediaCount(row.post_media_count) + }; +} + +function buildImagePromptVariant(basePrompt: string, index: number, total: number): string { + const hints = [ + "wide establishing composition", + "medium composition with clear subject focus", + "close-up detail shot", + "alternate angle of the same subject", + "contextual scene with foreground depth", + "cinematic texture detail shot" + ]; + const shot = hints[index % hints.length] ?? "alternate composition"; + return `${basePrompt}\n\nFrame ${index + 1}/${Math.max(total, 1)}: ${shot}. Keep consistent subject, style, and lighting with prior frames.`; +} + +async function persistGeneratedImage(args: { + orgId: string; + postId: string; + image: Buffer; + prompt: string; +}): Promise { + const baseDir = process.env.FB_ARCHIVE_DIR ?? "facebook_posts"; + const dateDir = new Date().toISOString().slice(0, 10); + const outDir = path.resolve(process.cwd(), baseDir, dateDir); + await fs.mkdir(outDir, { recursive: true }); + const fileName = `${args.postId}-${Date.now()}-${Math.random().toString(36).slice(2)}.png`; + const absPath = path.join(outDir, fileName); + await fs.writeFile(absPath, args.image); + const relPath = path.join(baseDir, dateDir, fileName).split(path.sep).join("/"); + await insertPostImage(args.orgId, { + post_id: args.postId, + file_path: relPath, + prompt: args.prompt + }); +} + +async function ensurePostImagesForVideo(args: { + orgId: string; + postId: string; + desiredCount: number; + imagePrompt: string; + allowAiGeneration: boolean; +}): Promise> { + let images = await listPostImages(args.orgId, args.postId); + if (images.length >= args.desiredCount) return images.slice(-args.desiredCount); + if (!args.allowAiGeneration) return images.slice(-args.desiredCount); + if (!args.imagePrompt.trim()) throw new Error("Missing image prompt for this post."); + + const missing = args.desiredCount - images.length; + const startingIndex = images.length; + for (let i = 0; i < missing; i += 1) { + const prompt = buildImagePromptVariant(args.imagePrompt, startingIndex + i, args.desiredCount); + const image = await generateCoverImage(prompt); + await persistGeneratedImage({ + orgId: args.orgId, + postId: args.postId, + image, + prompt + }); + } + images = await listPostImages(args.orgId, args.postId); + return images.slice(-args.desiredCount); +} + +function estimateVoiceDurationSeconds(caption: string): number { + const cleaned = stripHashtags(caption || ""); + const words = cleaned ? cleaned.split(/\s+/).filter(Boolean).length : 0; + const sentencePauses = (cleaned.match(/[.!?]+/g) ?? []).length; + const shortPauses = (cleaned.match(/[,;:]/g) ?? []).length; + const base = words > 0 ? words / 2.7 : 6; + return Math.max(6, Math.min(180, base + sentencePauses * 0.35 + shortPauses * 0.12 + 0.6)); +} + +function buildPingPongIndices(length: number, count: number): number[] { + if (length <= 0 || count <= 0) return []; + if (length === 1) return Array.from({ length: count }, () => 0); + const forward = Array.from({ length }, (_, i) => i); + const backward = Array.from({ length: Math.max(0, length - 2) }, (_, i) => length - 2 - i); + const cycle = [...forward, ...backward]; + return Array.from({ length: count }, (_, i) => cycle[i % cycle.length] ?? 0); +} + +function buildCarouselTimeline(imagePaths: string[], caption: string): Array<{ path: string; durationSeconds: number }> { + if (!imagePaths.length) return []; + const estimatedDuration = estimateVoiceDurationSeconds(caption); + const targetSceneSeconds = 3.8; + const desiredScenes = Math.round(estimatedDuration / targetSceneSeconds); + const maxScenes = Math.max(imagePaths.length, Math.min(16, imagePaths.length * 3)); + const sceneCount = Math.max(imagePaths.length, Math.min(maxScenes, desiredScenes)); + const order = buildPingPongIndices(imagePaths.length, sceneCount); + const steadyDuration = Math.max(3.6, Math.min(4.2, estimatedDuration / Math.max(1, sceneCount))); + return order.map((idx) => { + return { path: imagePaths[idx] ?? imagePaths[0], durationSeconds: steadyDuration }; + }); +} + async function copyVideoToDir(sourcePath: string, dirName: string, prefix: string): Promise<{ relPath: string; absPath: string }> { const dateDir = new Date().toISOString().slice(0, 10); const outDir = path.resolve(process.cwd(), dirName, dateDir); @@ -530,35 +652,29 @@ export function postsApiRouter() { try { const templateList = normalizeTemplateImages(templateImages); const videoList = normalizeTemplateVideos(templateVideos ?? templateVideo); - let images: Array<{ file_path: string }> = []; + let imagePath = ""; + let autoCarouselTimeline: Array<{ path: string; durationSeconds: number }> = []; if (videoList.length === 0 && templateList.length === 0) { - images = await listPostImages(orgId, postId); - if (!images.length) { - const row = await queryOne<{ image_prompt?: string | null; story_key?: string | null }>( - "SELECT image_prompt, story_key FROM posts WHERE id = $1 AND organization_id = $2", - [postId, orgId] - ); - const imagePrompt = row?.image_prompt ?? ""; - if (!imagePrompt.trim()) { - return res.status(400).json({ ok: false, error: "Missing image prompt for this post." }); - } - const img = await generateCoverImage(imagePrompt); - await saveFacebookDraft({ - orgId, - postId, - storyKey: row?.story_key ?? postId, - posts: { [lang]: text }, - image: img, - imagePrompt - }); - images = await listPostImages(orgId, postId); - } - if (!images.length) { + const settings = await getPostVideoSettings(orgId, postId); + const desiredCount = settings.mediaMode === "carousel" ? settings.mediaCount : 1; + const images = await ensurePostImagesForVideo({ + orgId, + postId, + desiredCount, + imagePrompt: settings.imagePrompt, + allowAiGeneration: settings.mediaSource === "ai" + }); + if (images.length === 0) { return res.status(400).json({ ok: false, error: "No image saved for this post yet." }); } + if (settings.mediaMode === "carousel" && images.length > 1) { + const imagePaths = images.map((img) => path.resolve(process.cwd(), img.file_path)); + autoCarouselTimeline = buildCarouselTimeline(imagePaths, text); + } else { + const latest = images[images.length - 1]; + imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; + } } - const latest = images[images.length - 1]; - const imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; const reelsDir = process.env.REELS_DIR ?? "reels"; const dateDir = new Date().toISOString().slice(0, 10); const outDir = path.resolve(process.cwd(), reelsDir, dateDir); @@ -608,6 +724,18 @@ export function postsApiRouter() { audioLanguage: typeof audioLang === "string" && audioLang.trim() ? audioLang.trim() : undefined, ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined }); + } else if (autoCarouselTimeline.length > 0) { + await buildKenBurnsSlideshow({ + images: autoCarouselTimeline, + caption: text, + voiceText: typeof audioText === "string" && audioText.trim() ? audioText : undefined, + outputPath: outPath, + audioPath, + voice: typeof voice === "string" && voice.trim() ? voice.trim() : process.env.REEL_VOICE ?? undefined, + language: lang, + audioLanguage: typeof audioLang === "string" && audioLang.trim() ? audioLang.trim() : undefined, + ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined + }); } else { await buildKenBurnsReel({ imagePath, @@ -634,7 +762,9 @@ export function postsApiRouter() { reel: { id, url: `/${relPath}`, lang, createdAt: new Date().toISOString() } }); } catch (err: any) { - return res.status(500).json({ ok: false, error: err?.message ?? String(err) }); + const message = err?.message ?? String(err); + const status = message === "Post not found" ? 404 : 500; + return res.status(status).json({ ok: false, error: message }); } }); @@ -648,35 +778,29 @@ export function postsApiRouter() { try { const templateList = normalizeTemplateImages(templateImages); const videoList = normalizeTemplateVideos(templateVideos ?? templateVideo); - let images: Array<{ file_path: string }> = []; + let imagePath = ""; + let autoCarouselTimeline: Array<{ path: string; durationSeconds: number }> = []; if (videoList.length === 0 && templateList.length === 0) { - images = await listPostImages(orgId, postId); - if (!images.length) { - const row = await queryOne<{ image_prompt?: string | null; story_key?: string | null }>( - "SELECT image_prompt, story_key FROM posts WHERE id = $1 AND organization_id = $2", - [postId, orgId] - ); - const imagePrompt = row?.image_prompt ?? ""; - if (!imagePrompt.trim()) { - return res.status(400).json({ ok: false, error: "Missing image prompt for this post." }); - } - const img = await generateCoverImage(imagePrompt); - await saveFacebookDraft({ - orgId, - postId, - storyKey: row?.story_key ?? postId, - posts: { [lang]: text }, - image: img, - imagePrompt - }); - images = await listPostImages(orgId, postId); - } - if (!images.length) { + const settings = await getPostVideoSettings(orgId, postId); + const desiredCount = settings.mediaMode === "carousel" ? settings.mediaCount : 1; + const images = await ensurePostImagesForVideo({ + orgId, + postId, + desiredCount, + imagePrompt: settings.imagePrompt, + allowAiGeneration: settings.mediaSource === "ai" + }); + if (images.length === 0) { return res.status(400).json({ ok: false, error: "No image saved for this post yet." }); } + if (settings.mediaMode === "carousel" && images.length > 1) { + const imagePaths = images.map((img) => path.resolve(process.cwd(), img.file_path)); + autoCarouselTimeline = buildCarouselTimeline(imagePaths, text); + } else { + const latest = images[images.length - 1]; + imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; + } } - const latest = images[images.length - 1]; - const imagePath = latest ? path.resolve(process.cwd(), latest.file_path) : ""; const shortsDir = process.env.SHORTS_DIR ?? "shorts"; const dateDir = new Date().toISOString().slice(0, 10); const outDir = path.resolve(process.cwd(), shortsDir, dateDir); @@ -728,6 +852,19 @@ export function postsApiRouter() { ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined, maxDurationSeconds: 180 }); + } else if (autoCarouselTimeline.length > 0) { + await buildKenBurnsSlideshow({ + images: autoCarouselTimeline, + caption: text, + voiceText: typeof audioText === "string" && audioText.trim() ? audioText : undefined, + outputPath: outPath, + audioPath, + voice: typeof voice === "string" && voice.trim() ? voice.trim() : process.env.REEL_VOICE ?? undefined, + language: lang, + audioLanguage: typeof audioLang === "string" && audioLang.trim() ? audioLang.trim() : undefined, + ttsProvider: typeof ttsProvider === "string" && ttsProvider.trim() ? ttsProvider.trim() : undefined, + maxDurationSeconds: 180 + }); } else { await buildKenBurnsReel({ imagePath, @@ -756,7 +893,7 @@ export function postsApiRouter() { }); } catch (err: any) { const message = err?.message ?? String(err); - const status = message.includes("exceeds") ? 400 : 500; + const status = message === "Post not found" ? 404 : message.includes("exceeds") ? 400 : 500; return res.status(status).json({ ok: false, error: message }); } }); @@ -896,10 +1033,10 @@ export function postsApiRouter() { .map((s: any) => ({ accountId: String(s?.accountId ?? "") })) .filter((s) => s.accountId) : []; - const selected = - selectionList.length > 0 - ? accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)) - : accounts; + if (!selectionList.length) { + return res.status(400).json({ ok: false, error: "Select at least one YouTube channel" }); + } + const selected = accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)); if (!selected.length) { return res.status(400).json({ ok: false, error: "No enabled YouTube channels selected" }); } @@ -981,10 +1118,10 @@ export function postsApiRouter() { .map((s: any) => ({ accountId: String(s?.accountId ?? "") })) .filter((s) => s.accountId) : []; - const selected = - selectionList.length > 0 - ? accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)) - : accounts; + if (!selectionList.length) { + return res.status(400).json({ ok: false, error: "Select at least one Facebook page" }); + } + const selected = accounts.filter((acc) => selectionList.some((s) => s.accountId === acc.id)); if (!selected.length) { return res.status(400).json({ ok: false, error: "No enabled Facebook pages selected" }); } diff --git a/src/db/postsRepo.ts b/src/db/postsRepo.ts index 72cfad4..57b7620 100644 --- a/src/db/postsRepo.ts +++ b/src/db/postsRepo.ts @@ -12,6 +12,7 @@ export type DraftPostRow = { translations_json?: string | null; fact_pack?: string | null; created_at: string; + job_id?: string | null; job_definition_id?: string | null; source_url?: string | null; story_key?: string | null; @@ -47,6 +48,7 @@ export async function insertPost( story_key?: string; fact_pack?: string; translations_json?: string; + job_id?: string; job_definition_id?: string; content_hash: string; created_at: string; @@ -57,9 +59,9 @@ export async function insertPost( INSERT INTO posts ( organization_id, kind, status, title, message_en, message_en_json, image_prompt, source_id, source_url, category, category_id, story_key, fact_pack, translations_json, - job_definition_id, content_hash, created_at + job_definition_id, job_id, content_hash, created_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) RETURNING id `, [ @@ -78,6 +80,7 @@ export async function insertPost( p.fact_pack ?? null, p.translations_json ?? null, p.job_definition_id ?? null, + p.job_id ?? null, p.content_hash, p.created_at ] @@ -179,6 +182,7 @@ export async function listDraftPosts( filters?: { status?: "all" | "draft" | "approved" | "published" | "rejected"; jobDefinitionId?: string | null; + jobId?: string; limit?: number; offset?: number; } @@ -199,6 +203,10 @@ export async function listDraftPosts( params.push(filters.jobDefinitionId); } } + if (filters?.jobId) { + where.push(`p.job_id = $${idx++}`); + params.push(filters.jobId); + } const clause = where.length ? `WHERE ${where.join(" AND ")}` : ""; const limit = Number(filters?.limit ?? 200); const offset = Number(filters?.offset ?? 0); @@ -215,6 +223,7 @@ export async function listDraftPosts( p.translations_json, p.fact_pack, p.created_at, + p.job_id, p.job_definition_id, p.source_url, p.story_key, diff --git a/src/jobs/worker.ts b/src/jobs/worker.ts index b6f78cf..e59bd27 100644 --- a/src/jobs/worker.ts +++ b/src/jobs/worker.ts @@ -45,7 +45,7 @@ export async function processNextJob(): Promise { }; } } - const summary = await runOnce({ ...options, orgId: job.organization_id }); + const summary = await runOnce({ ...options, orgId: job.organization_id, jobId: job.id }); await markJobCompleted(job.organization_id, job.id, JSON.stringify(summary)); if (jobDefinitionId) await markJobDefinitionRun(job.organization_id, jobDefinitionId); } catch (err: any) { diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 1771aca..b2bbee3 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -19,6 +19,7 @@ type Draft = { date: string; postId: string; storyKey: string; + jobId?: string | null; jobDefinitionId?: string | null; status?: string; kind?: string; @@ -48,6 +49,7 @@ async function listPosts( orgId: string, statusFilter?: string, jobDefinitionFilter?: string, + jobIdFilter?: string, publishedFilter?: string ): Promise { const status = @@ -61,7 +63,8 @@ async function listPosts( if (jobDefinitionFilter) jobDefinitionId = jobDefinitionFilter; } - const rows = await listDraftPosts(orgId, { status, jobDefinitionId, limit: 500 }); + const jobId = jobIdFilter ? jobIdFilter.trim() : ""; + const rows = await listDraftPosts(orgId, { status, jobDefinitionId, jobId: jobId || undefined, limit: 500 }); const postIds = rows.map((r) => r.id); const fbPublished = new Set(); const ytPublished = new Set(); @@ -167,6 +170,7 @@ async function listPosts( date: String(created).slice(0, 10), postId: row.id, storyKey: row.story_key ?? String(row.id), + jobId: row.job_id ?? null, jobDefinitionId: row.job_definition_id ?? null, status: row.status, kind: row.kind, @@ -214,8 +218,9 @@ export function pagesRouter() { const publishedAllowed = new Set(["all", "any", "facebook", "youtube", "none"]); const published = publishedAllowed.has(publishedRaw) ? publishedRaw : "all"; const jobRaw = typeof req.query.jobDefinitionId === "string" ? req.query.jobDefinitionId : "all"; + const jobIdRaw = typeof req.query.jobId === "string" ? req.query.jobId.trim() : ""; const orgId = req.org.id; - const posts = await listPosts(orgId, status, jobRaw, published); + const posts = await listPosts(orgId, status, jobRaw, jobIdRaw, published); const socialAccounts = (await listSocialAccounts(orgId)).filter((acc) => acc.enabled); const jobDefinitions = (await listJobDefinitions(orgId)).map((def) => ({ ...def, @@ -253,6 +258,7 @@ export function pagesRouter() { jobDefinitionId: jobRaw, jobDefinitionIsAll: jobRaw === "all", jobDefinitionIsNone: jobRaw === "none", + jobId: jobIdRaw, jobs, jobCounts, jobsJson: JSON.stringify(jobs), diff --git a/src/routes/render.ts b/src/routes/render.ts index e35aeff..0f8c1b2 100644 --- a/src/routes/render.ts +++ b/src/routes/render.ts @@ -53,17 +53,37 @@ async function getViteCssLinks(entryNames: string[]): Promise { if (!cssFiles.length) return ""; const selected = new Set(); + const mtimeByFile = new Map(); - // Shared PrimeVue/PrimeIcons stylesheet emitted by Vite. - for (const file of cssFiles) { - if (file.startsWith("primevue-")) selected.add(file); + async function pickLatestByPrefix(prefix: string): Promise { + const matches = cssFiles.filter((file) => file.startsWith(prefix)); + if (!matches.length) return null; + + let best: string | null = null; + let bestMtime = -1; + for (const file of matches) { + let mtime = mtimeByFile.get(file); + if (mtime == null) { + const stat = await fs.stat(path.join(viteAssetsPath, file)); + mtime = stat.mtimeMs; + mtimeByFile.set(file, mtime); + } + if (mtime > bestMtime) { + best = file; + bestMtime = mtime; + } + } + return best; } + // Shared PrimeVue/PrimeIcons stylesheet emitted by Vite. + const primeVueCss = await pickLatestByPrefix("primevue-"); + if (primeVueCss) selected.add(primeVueCss); + // Entry-local styles (e.g. schedulesApp-*.css). for (const entry of entryNames) { - for (const file of cssFiles) { - if (file.startsWith(`${entry}-`)) selected.add(file); - } + const entryCss = await pickLatestByPrefix(`${entry}-`); + if (entryCss) selected.add(entryCss); } return [...selected] diff --git a/src/runner/aiRunner.ts b/src/runner/aiRunner.ts index b88547e..ffa0b20 100644 --- a/src/runner/aiRunner.ts +++ b/src/runner/aiRunner.ts @@ -102,6 +102,7 @@ export async function runAiOnly(input: { fact_pack: undefined, translations_json: Object.keys(translatedWithTags).length ? JSON.stringify(finalPostsByLang) : undefined, job_definition_id: summary.jobDefinitionId ?? undefined, + job_id: options?.jobId ?? undefined, content_hash: contentHash, created_at: now }); diff --git a/src/runner/realRunner.ts b/src/runner/realRunner.ts index c03ca1c..1811e8c 100644 --- a/src/runner/realRunner.ts +++ b/src/runner/realRunner.ts @@ -219,6 +219,7 @@ export async function runRealRunner(input: { fact_pack: JSON.stringify(factPack), translations_json: JSON.stringify(finalPostsByLang), job_definition_id: summary.jobDefinitionId ?? undefined, + job_id: options?.jobId ?? undefined, content_hash: contentHash, created_at: now }); diff --git a/src/runner/types.ts b/src/runner/types.ts index 1eb7d9f..da8cef2 100644 --- a/src/runner/types.ts +++ b/src/runner/types.ts @@ -2,6 +2,7 @@ import type { Lang } from "../domain/types.js"; export type RunnerOptions = { orgId?: string; + jobId?: string; sourceIds?: string[]; categoryIds?: string[]; sourceTypes?: string[]; diff --git a/src/ui/index.html b/src/ui/index.html index 0347bb0..af2cb5a 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -8,6 +8,7 @@
Filter posts
+
- {{#posts}} -
-
-
date {{date}}{{#jobDefinitionId}} · job {{jobDefinitionId}}{{/jobDefinitionId}}
-
- {{#originLabel}}{{originLabel}}{{/originLabel}} - {{#similarStory}}🔁 Similar{{/similarStory}} - {{#scheduleCount}} - Scheduled · {{scheduleCount}} - {{/scheduleCount}} - {{^scheduleCount}} - - {{/scheduleCount}} - {{#publishedFacebook}}Facebook published{{/publishedFacebook}} - {{#publishedYoutube}}YouTube published{{/publishedYoutube}} - {{#status}}{{status}}{{/status}} -
-
-
-
- {{#imageUrl}}post image{{/imageUrl}} - {{^imageUrl}}
No image
{{/imageUrl}} -
-
- {{#texts}} -
-
{{lang}}
-
{{text}}
-
- {{/texts}} - {{#sources}}
{{sources}}
{{/sources}} - {{#factPack}} -
- Fact pack -
{{factPack}}
-
- {{/factPack}} -
- Manage -
-
-
-
- {{/posts}} - {{^posts}} -

No posts yet.

- {{/posts}} - - - - +
+ + diff --git a/src/ui/vue/index/App.vue b/src/ui/vue/index/App.vue new file mode 100644 index 0000000..6d15e22 --- /dev/null +++ b/src/ui/vue/index/App.vue @@ -0,0 +1,177 @@ + + + diff --git a/src/ui/vue/index/main.ts b/src/ui/vue/index/main.ts new file mode 100644 index 0000000..4a90c29 --- /dev/null +++ b/src/ui/vue/index/main.ts @@ -0,0 +1,13 @@ +import { createApp } from "vue"; +import App from "./App.vue"; +import { installPrimeVue } from "../shared/primevue"; + +function boot() { + const host = document.getElementById("posts-index-app"); + if (!host) return; + const app = createApp(App); + installPrimeVue(app); + app.mount(host); +} + +boot(); diff --git a/vite.config.ts b/vite.config.ts index 57f65a6..f395f68 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,7 @@ import vue from "@vitejs/plugin-vue"; import { fileURLToPath } from "url"; const postEntry = fileURLToPath(new URL("./src/ui/vue/post/main.ts", import.meta.url)); +const postsIndexEntry = fileURLToPath(new URL("./src/ui/vue/index/main.ts", import.meta.url)); const jobsEntry = fileURLToPath(new URL("./src/ui/vue/jobs/main.ts", import.meta.url)); const schedulesEntry = fileURLToPath(new URL("./src/ui/vue/schedules/main.ts", import.meta.url)); @@ -15,6 +16,7 @@ export default defineConfig({ rollupOptions: { input: { postApp: postEntry, + postsIndexApp: postsIndexEntry, jobsApp: jobsEntry, schedulesApp: schedulesEntry }, From 040e84a930c94ada3ec68063ef95e51d5ed55819 Mon Sep 17 00:00:00 2001 From: fardin Date: Sun, 15 Feb 2026 16:49:20 +0100 Subject: [PATCH 08/16] adding production docker-compose flow --- .dockerignore | 16 +++++++ .env.example | 5 ++- Dockerfile | 34 ++++++++++++++ README.md | 35 ++++++++++++++- docker-compose.prod.yml | 94 +++++++++++++++++++++++++++++++++++++++ package.json | 4 ++ pgadmin/servers.prod.json | 13 ++++++ scripts/migrate.ts | 4 +- 8 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.prod.yml create mode 100644 pgadmin/servers.prod.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fa09098 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +node_modules +dist +src/ui/dist +.env +.env.dev +.env.test +.env.age +facebook_posts +reels +shorts +tts_previews +post_media +tmp_voice.wav +*.wav diff --git a/.env.example b/.env.example index 38b0357..3f4f3fd 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,15 @@ OPENAI_API_KEY=your_openai_api_key NODE_ENV=production +UI_PORT=3333 # Use .env.dev for development and .env.test for tests (see package.json scripts) # Scheduler cron expression (default every 4 hours) CRON=0 */4 * * * -# Postgres connection string +# Postgres connection string (host-based dev default; prod compose overrides to postgres service) DATABASE_URL=postgres://ai_farm_brain:ai_farm_brain_password@localhost:5432/funrang_dev -# Redis (BullMQ) +# Redis (host-based dev default; prod compose overrides to redis service) REDIS_URL=redis://localhost:6379 # RSS ingest concurrency diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0d3c0c9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-bookworm-slim AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM node:20-bookworm-slim AS runtime + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg python3 python3-pip ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Local STT runtime for REEL_STT_PROVIDER=local. +RUN python3 -m pip install --no-cache-dir faster-whisper + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/migrations ./migrations +COPY --from=builder /app/src/ui ./src/ui +COPY --from=builder /app/scripts/faster_whisper_words.py ./scripts/faster_whisper_words.py + +RUN mkdir -p facebook_posts reels shorts tts_previews post_media + +EXPOSE 3333 + +CMD ["sh", "-lc", "NODE_ENV=production node dist/scripts/migrate.js && NODE_ENV=production node dist/src/server.js"] diff --git a/README.md b/README.md index 4acdfe0..92dc65a 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,36 @@ Funrang is a modern social content engine for social media managers. It discover ## Status Local MVP with working UI, job system, post review, and Facebook publishing. +## One-command setup (Docker, production-style) +### Prereqs +- Docker + Docker Compose plugin (`docker compose`) + +### 1) Create env +```bash +cp .env.example .env +``` +- Set at least `OPENAI_API_KEY` in `.env`. +- Keep `DATABASE_URL`/`REDIS_URL` as-is for Compose; `docker-compose.prod.yml` overrides them to internal service URLs. + +### 2) Start everything +```bash +docker compose -f docker-compose.prod.yml up --build -d +``` +- App: `http://localhost:3333` +- The production image compiles TypeScript during build and runs emitted JavaScript (`node`), not `tsx`. + +### 3) Stop everything +```bash +docker compose -f docker-compose.prod.yml down +``` + +### Optional ops tooling +- Start pgAdmin too: +```bash +docker compose -f docker-compose.prod.yml --profile ops up -d +``` +- pgAdmin: `http://localhost:5050` + ## Get up and running (dev) ### Prereqs - Node.js 18+ (Node 20+ recommended) @@ -90,7 +120,10 @@ docker compose ps ### Notes - `.env.dev` is used by `npm run ui` - `.env.test` is used by `npm test` -- `docker-compose.yml` brings up Postgres (`funrang_dev`), pgAdmin, and Redis +- `.env` is used by `docker-compose.prod.yml` for app secrets/config. +- `docker-compose.yml` is the development infra stack (Postgres + pgAdmin + Redis, DB: `funrang_dev`). +- `docker-compose.prod.yml` is the production-style stack (app + Postgres + Redis, DB: `funrang_prod`, and pgAdmin when `--profile ops` is used). +- In prod compose, `DATABASE_URL`/`REDIS_URL` are set to `postgres`/`redis` service hosts automatically. - If OAuth callbacks are needed from real providers, use a public callback URL (for example via ngrok) and set `FACEBOOK_REDIRECT_URI` / `YOUTUBE_REDIRECT_URI`. ### Share `.env` securely with `age` diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..8f77514 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,94 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + container_name: ai-farm-brain-app + restart: unless-stopped + ports: + - "3333:3333" + env_file: + - .env + environment: + NODE_ENV: production + UI_PORT: 3333 + DATABASE_URL: postgres://ai_farm_brain:ai_farm_brain_password@postgres:5432/funrang_prod + REDIS_URL: redis://redis:6379 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: + [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:3333').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + ] + interval: 10s + timeout: 5s + retries: 20 + volumes: + - facebook_posts_data:/app/facebook_posts + - reels_data:/app/reels + - shorts_data:/app/shorts + - tts_previews_data:/app/tts_previews + - post_media_data:/app/post_media + + postgres: + image: postgres:16 + container_name: ai-farm-brain-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ai_farm_brain + POSTGRES_PASSWORD: ai_farm_brain_password + POSTGRES_DB: funrang_prod + volumes: + - pgdata:/var/lib/postgresql/data + - ./pg-init.sql:/docker-entrypoint-initdb.d/00-init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ai_farm_brain -d funrang_prod"] + interval: 5s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + container_name: ai-farm-brain-redis + restart: unless-stopped + command: ["redis-server", "--save", "60", "1"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 20 + + pgadmin: + image: dpage/pgadmin4:8 + container_name: ai-farm-brain-pgadmin + profiles: ["ops"] + restart: unless-stopped + ports: + - "5050:80" + environment: + PGADMIN_DEFAULT_EMAIL: admin@local.dev + PGADMIN_DEFAULT_PASSWORD: admin123 + PGADMIN_CONFIG_SERVER_MODE: "False" + depends_on: + postgres: + condition: service_healthy + volumes: + - pgadmin_data:/var/lib/pgadmin + - ./pgadmin/servers.prod.json:/pgadmin4/servers.json:ro + +volumes: + pgdata: + pgadmin_data: + redis_data: + facebook_posts_data: + reels_data: + shorts_data: + tts_previews_data: + post_media_data: diff --git a/package.json b/package.json index 1823993..e003420 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,15 @@ "scripts": { "ui": "npm run ui:styles:watch & npm run ui:js:watch & DOTENV_CONFIG_PATH=.env.dev NODE_ENV=development tsx watch src/server.ts", "prod": "DOTENV_CONFIG_PATH=.env NODE_ENV=production tsx src/runner.ts", + "build:server": "tsc -p tsconfig.json --noCheck", + "build": "npm run ui:styles && npm run ui:js:build && npm run build:server", "reset-db:dev": "DOTENV_CONFIG_PATH=.env.dev NODE_ENV=development tsx scripts/reset-db.ts", "reset-db:prod": "DOTENV_CONFIG_PATH=.env NODE_ENV=production tsx scripts/reset-db.ts", "migrate": "tsx scripts/migrate.ts", "migrate:dev": "DOTENV_CONFIG_PATH=.env.dev NODE_ENV=development tsx scripts/migrate.ts", "migrate:prod": "DOTENV_CONFIG_PATH=.env NODE_ENV=production tsx scripts/migrate.ts", + "migrate:compiled": "NODE_ENV=production node dist/scripts/migrate.js", + "start:compiled": "NODE_ENV=production node dist/src/server.js", "ui:js:build": "vite build", "ui:js:watch": "vite build --watch", "ui:styles": "tailwindcss -i src/ui/styles/input.css -o src/ui/styles/tailwind.css", diff --git a/pgadmin/servers.prod.json b/pgadmin/servers.prod.json new file mode 100644 index 0000000..e4d730f --- /dev/null +++ b/pgadmin/servers.prod.json @@ -0,0 +1,13 @@ +{ + "Servers": { + "1": { + "Name": "funrang-prod", + "Group": "Servers", + "Host": "postgres", + "Port": 5432, + "MaintenanceDB": "funrang_prod", + "Username": "ai_farm_brain", + "SSLMode": "prefer" + } + } +} diff --git a/scripts/migrate.ts b/scripts/migrate.ts index e2f59ac..c0d35c9 100644 --- a/scripts/migrate.ts +++ b/scripts/migrate.ts @@ -1,11 +1,9 @@ import "dotenv/config"; import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath } from "node:url"; import { Pool } from "pg"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const migrationsDir = path.resolve(__dirname, "../migrations"); +const migrationsDir = path.resolve(process.cwd(), "migrations"); const TARGET_VERSION = "0048_posts_job_id"; From af115311822bb18fd2ee40aca89ac2efdd219cce Mon Sep 17 00:00:00 2001 From: fardin Date: Sun, 15 Feb 2026 20:56:21 +0100 Subject: [PATCH 09/16] add visibility config and add quick actions on post list view --- src/publish/youtube.ts | 2 +- src/routes/render.ts | 56 +++ src/ui/js/app.js | 11 - src/ui/js/jobs.js | 106 ---- src/ui/js/modal.js | 272 ---------- src/ui/js/posts.js | 9 - src/ui/js/runJob.js | 17 - src/ui/styles/tailwind.css | 49 ++ src/ui/vue/index/App.vue | 151 +++++- .../jobs/components/JobDefinitionDrawer.vue | 85 +++- src/ui/vue/post/App.vue | 28 +- .../vue/post/components/PostEditorPanel.vue | 90 +++- .../vue/post/components/PostHeaderPanel.vue | 464 ++++++++++++++++-- src/ui/vue/post/components/PublishPanel.vue | 160 ------ src/ui/vue/post/components/ReelsPanel.vue | 39 +- src/ui/vue/post/components/ReviewPanel.vue | 79 +-- .../post/components/SchedulePanelContent.vue | 7 +- src/ui/vue/post/components/ShortsPanel.vue | 41 +- src/ui/vue/post/stores/postStore.ts | 194 +++++++- .../components/PostModerationActions.vue | 230 +++++++++ .../shared/composables/usePostModeration.ts | 45 ++ vite.config.ts | 1 + 22 files changed, 1339 insertions(+), 797 deletions(-) delete mode 100644 src/ui/js/app.js delete mode 100644 src/ui/js/jobs.js delete mode 100644 src/ui/js/modal.js delete mode 100644 src/ui/js/posts.js delete mode 100644 src/ui/js/runJob.js delete mode 100644 src/ui/vue/post/components/PublishPanel.vue create mode 100644 src/ui/vue/shared/components/PostModerationActions.vue create mode 100644 src/ui/vue/shared/composables/usePostModeration.ts diff --git a/src/publish/youtube.ts b/src/publish/youtube.ts index 4f72e33..3ea2b34 100644 --- a/src/publish/youtube.ts +++ b/src/publish/youtube.ts @@ -111,7 +111,7 @@ async function startResumableUpload(input: { description: normalizeDescription(input.description) }, status: { - privacyStatus: input.privacyStatus || "unlisted" + privacyStatus: input.privacyStatus || "public" } }; const url = new URL("https://www.googleapis.com/upload/youtube/v3/videos"); diff --git a/src/routes/render.ts b/src/routes/render.ts index 0f8c1b2..0d39a18 100644 --- a/src/routes/render.ts +++ b/src/routes/render.ts @@ -11,6 +11,15 @@ const headerPath = path.resolve(process.cwd(), "src/ui/partials/header.html"); const sidebarPath = path.resolve(process.cwd(), "src/ui/partials/sidebar.html"); const settingsSidebarPath = path.resolve(process.cwd(), "src/ui/partials/settings-sidebar.html"); const viteAssetsPath = path.resolve(process.cwd(), "src/ui/dist/assets"); +const viteManifestPath = path.resolve(process.cwd(), "src/ui/dist/manifest.json"); + +type ViteManifestEntry = { + file: string; + css?: string[]; + imports?: string[]; +}; + +type ViteManifest = Record; async function getLayout(): Promise { if (layoutCache) return layoutCache; @@ -47,7 +56,54 @@ function getEntryNamesFromBody(bodyTemplate: string): string[] { } async function getViteCssLinks(entryNames: string[]): Promise { + async function getCssFromManifest(entries: string[]): Promise { + try { + const raw = await fs.readFile(viteManifestPath, "utf8"); + const manifest = JSON.parse(raw) as ViteManifest; + const byFile = new Map(); + for (const key of Object.keys(manifest)) { + const file = manifest[key]?.file; + if (file) byFile.set(file, key); + } + + const entryKeys = entries + .map((entry) => byFile.get(`${entry}.js`) || "") + .filter(Boolean); + if (!entryKeys.length) return []; + + const visited = new Set(); + const selected = new Set(); + const stack = [...entryKeys]; + + while (stack.length) { + const key = stack.pop(); + if (!key || visited.has(key)) continue; + visited.add(key); + const node = manifest[key]; + if (!node) continue; + for (const cssFile of node.css || []) { + if (cssFile && cssFile.endsWith(".css")) selected.add(cssFile); + } + for (const dep of node.imports || []) { + if (dep && !visited.has(dep)) stack.push(dep); + } + } + + return [...selected]; + } catch { + return null; + } + } + try { + const manifestCss = await getCssFromManifest(entryNames); + if (manifestCss && manifestCss.length) { + return manifestCss + .sort() + .map((file) => ``) + .join("\n"); + } + const files = await fs.readdir(viteAssetsPath); const cssFiles = files.filter((name) => name.endsWith(".css")); if (!cssFiles.length) return ""; diff --git a/src/ui/js/app.js b/src/ui/js/app.js deleted file mode 100644 index c139c6c..0000000 --- a/src/ui/js/app.js +++ /dev/null @@ -1,11 +0,0 @@ -import { parseJsonScript } from "./utils.js"; -import { initModal } from "./modal.js"; -import { initPosts } from "./posts.js"; - -const postsData = parseJsonScript("posts-data", []); - -const postById = new Map(postsData.map((d) => [d.id, d])); - -const modal = initModal({ draftById: postById }); - -initPosts({ postById, modal }); diff --git a/src/ui/js/jobs.js b/src/ui/js/jobs.js deleted file mode 100644 index 4cce14a..0000000 --- a/src/ui/js/jobs.js +++ /dev/null @@ -1,106 +0,0 @@ -import { escapeHtml } from "./utils.js"; - -export function initJobs({ - jobCountsEl, - jobsListEl, - refreshBtn, - initialCounts, - initialJobs, - pollMs = 5000 -}) { - let refreshing = false; - - function renderJobCounts(counts) { - if (!counts || !jobCountsEl) return; - jobCountsEl.innerHTML = ` - created ${counts.created} - in_progress ${counts.in_progress} - completed ${counts.completed} - failed ${counts.failed} - `; - } - - function formatSkips(skips) { - const entries = Object.entries(skips ?? {}); - if (entries.length === 0) return ""; - const top = entries - .sort((a, b) => b[1] - a[1]) - .slice(0, 4) - .map(([key, val]) => `${key}(${val})`) - .join(", "); - return `Skips: ${top}`; - } - - function formatJobMeta(meta) { - if (!meta) return ""; - let data = null; - try { - data = typeof meta === "string" ? JSON.parse(meta) : meta; - } catch { - return ""; - } - if (!data) return ""; - const rss = data.rss - ? `RSS: ${data.rss.itemsInserted}/${data.rss.itemsSeen} new, ${data.rss.sourcesOk}/${data.rss.sourcesTotal} ok` - : ""; - const scrape = data.scrape - ? `Scrape: ${data.scrape.itemsInserted ?? 0} items, ${data.scrape.pagesFetched ?? 0}/${data.scrape.pagesSeen ?? 0} pages` - : ""; - const posts = `Posts: ${data.createdPosts ?? 0} created, ${data.draftsSaved ?? 0} saved`; - const clusters = `Clusters: ${data.clusters ?? 0} total, ${data.selected ?? 0} selected`; - const reason = data.noPostsReason ? `Reason: ${data.noPostsReason}` : ""; - const skips = data.skips ? formatSkips(data.skips) : ""; - const parts = [rss, scrape, clusters, posts, skips, reason].filter(Boolean); - if (parts.length === 0) return ""; - return `
${parts.map((p) => `
${escapeHtml(p)}
`).join("")}
`; - } - - function renderJobsList(jobs) { - if (!jobsListEl) return; - if (!jobs || jobs.length === 0) { - jobsListEl.innerHTML = '
No jobs yet.
'; - return; - } - jobsListEl.innerHTML = jobs - .map((job) => { - const finished = job.finished_at ? `
finished ${job.finished_at}
` : ""; - const error = job.error ? `
${escapeHtml(job.error)}
` : ""; - const meta = formatJobMeta(job.meta); - return ` -
-
#${job.id}
-
${job.status}
-
created ${job.created_at}
- ${finished} - ${meta} - ${error} -
- `; - }) - .join(""); - } - - async function refreshJobs() { - if (refreshing) return; - refreshing = true; - if (refreshBtn) refreshBtn.classList.add("loading"); - try { - const res = await fetch("/api/jobs"); - const data = await res.json(); - renderJobCounts(data.counts); - renderJobsList(data.recent); - } finally { - if (refreshBtn) refreshBtn.classList.remove("loading"); - refreshing = false; - } - } - - if (refreshBtn) refreshBtn.addEventListener("click", refreshJobs); - renderJobCounts(initialCounts); - renderJobsList(initialJobs); - if (pollMs > 0) { - setInterval(refreshJobs, pollMs); - } - - return { refreshJobs }; -} diff --git a/src/ui/js/modal.js b/src/ui/js/modal.js deleted file mode 100644 index 127c6a9..0000000 --- a/src/ui/js/modal.js +++ /dev/null @@ -1,272 +0,0 @@ -export function initModal({ draftById }) { - const modal = document.getElementById("modal"); - const modalTitle = document.getElementById("modalTitle"); - const modalMeta = document.getElementById("modalMeta"); - const modalImage = document.getElementById("modalImage"); - const modalImagePlaceholder = document.getElementById("modalImagePlaceholder"); - const langSelect = document.getElementById("langSelect"); - const captionInput = document.getElementById("captionInput"); - const modalStatus = document.getElementById("modalStatus"); - const saveBtn = document.getElementById("saveBtn"); - const approveBtn = document.getElementById("approveBtn"); - const publishBtn = document.getElementById("publishBtn"); - const scheduleAt = document.getElementById("scheduleAt"); - const scheduleAccounts = document.getElementById("scheduleAccounts"); - const scheduleBtn = document.getElementById("scheduleBtn"); - const scheduleStatus = document.getElementById("scheduleStatus"); - const scheduleList = document.getElementById("scheduleList"); - const modalClose = document.getElementById("modalClose"); - const backdrop = modal?.querySelector(".modal-backdrop"); - const rejectReason = document.getElementById("rejectReason"); - const rejectNotes = document.getElementById("rejectNotes"); - const rejectBtn = document.getElementById("rejectBtn"); - const rejectStatus = document.getElementById("rejectStatus"); - - let currentDraft = null; - let currentLang = null; - let socialAccounts = []; - - try { - const raw = document.getElementById("social-accounts-data")?.textContent || "[]"; - socialAccounts = JSON.parse(raw); - } catch { - socialAccounts = []; - } - - function renderScheduleList(items) { - if (!scheduleList) return; - if (!Array.isArray(items) || items.length === 0) { - scheduleList.innerHTML = "
No schedules yet.
"; - return; - } - scheduleList.innerHTML = items - .map((s) => { - const when = new Date(s.scheduledAt); - const timeLabel = Number.isNaN(when.getTime()) ? s.scheduledAt : when.toLocaleString(); - return ` -
- ${timeLabel} - ${s.status} - ${s.channel} - ${s.accountId ? `#${s.accountId}` : ""} - -
- `; - }) - .join(""); - scheduleList.querySelectorAll(".delete-schedule").forEach((btn) => { - btn.addEventListener("click", async () => { - const row = btn.closest(".schedule-item"); - if (!row) return; - const id = Number(row.getAttribute("data-id")); - if (!Number.isFinite(id)) return; - btn.disabled = true; - try { - await fetch(`/api/schedules/${id}`, { method: "DELETE" }); - await refreshSchedules(); - } finally { - btn.disabled = false; - } - }); - }); - } - - function updateSchedulePill() { - if (!currentDraft) return; - const card = document.querySelector(`.card[data-id="${currentDraft.id}"]`); - const pill = card?.querySelector("[data-schedule-pill]"); - if (!pill) return; - const count = currentDraft.schedules?.length ?? 0; - if (count > 0) { - pill.textContent = `Scheduled · ${count}`; - pill.classList.remove("hidden"); - } else { - pill.textContent = ""; - pill.classList.add("hidden"); - } - } - - async function refreshSchedules() { - if (!currentDraft) return; - try { - const res = await fetch(`/posts/${currentDraft.id}/schedules`); - const data = await res.json(); - if (data?.ok && Array.isArray(data.schedules)) { - currentDraft.schedules = data.schedules; - renderScheduleList(currentDraft.schedules); - updateSchedulePill(); - } - } catch { - // ignore - } - } - - function openModal(draft) { - if (!modal) return; - currentDraft = draft; - const langs = Object.keys(draft.texts); - currentLang = langs.includes("en") ? "en" : langs[0]; - langSelect.innerHTML = langs - .sort() - .map((lang) => ``) - .join(""); - langSelect.value = currentLang; - captionInput.value = draft.texts[currentLang] || ""; - modalTitle.textContent = "Manage"; - modalMeta.textContent = `date ${draft.date} · id ${draft.postId}`; - modalStatus.textContent = ""; - if (scheduleStatus) scheduleStatus.textContent = ""; - if (scheduleAt) { - const now = new Date(); - const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16); - scheduleAt.value = local; - } - if (scheduleAccounts) { - const items = (socialAccounts || []).filter((acc) => acc.enabled); - scheduleAccounts.innerHTML = items.length - ? items - .map( - (acc) => - `` - ) - .join("") - : "
No enabled social accounts.
"; - } - if (rejectReason) rejectReason.value = ""; - if (rejectNotes) rejectNotes.value = ""; - if (rejectStatus) rejectStatus.textContent = ""; - if (draft.imageUrl) { - modalImage.src = draft.imageUrl; - modalImage.style.display = "block"; - modalImagePlaceholder.style.display = "none"; - } else { - modalImage.style.display = "none"; - modalImagePlaceholder.style.display = "block"; - } - renderScheduleList(draft.schedules || []); - updateSchedulePill(); - modal.classList.remove("hidden"); - modal.setAttribute("aria-hidden", "false"); - } - - function closeModal() { - if (!modal) return; - modal.classList.add("hidden"); - modal.setAttribute("aria-hidden", "true"); - currentDraft = null; - currentLang = null; - } - - async function saveDraft() { - if (!currentDraft || !currentLang) return; - modalStatus.textContent = "Saving..."; - const payload = { - lang: currentLang, - text: captionInput.value - }; - const res = await fetch(`/posts/${currentDraft.id}/update`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - if (data.ok) { - currentDraft.texts[currentLang] = payload.text; - const card = document.querySelector(`[data-id="${currentDraft.id}"]`)?.closest(".card"); - const block = card?.querySelector(`.lang-block[data-lang="${currentLang}"] pre`); - if (block) block.textContent = payload.text; - modalStatus.textContent = "Saved."; - } else { - modalStatus.textContent = data.error || "Save failed."; - } - } - - async function publishDraft() { - if (!currentDraft || !currentLang) return; - modalStatus.textContent = "Publishing..."; - const payload = { - lang: currentLang, - text: captionInput.value, - accountIds: Array.from( - scheduleAccounts?.querySelectorAll('input[type="checkbox"]:checked') ?? [] - ).map((el) => Number(el.value)) - }; - const res = await fetch(`/posts/${currentDraft.id}/publish`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - modalStatus.textContent = data.ok ? "Queued to outbox." : data.error || "Publish failed."; - } - - async function approvePost() { - if (!currentDraft) return; - modalStatus.textContent = "Approving..."; - const res = await fetch(`/posts/${currentDraft.id}/approve`, { - method: "POST", - headers: { "content-type": "application/json" } - }); - const data = await res.json(); - modalStatus.textContent = data.ok ? "Approved." : data.error || "Approve failed."; - } - - async function rejectDraft() { - if (!currentDraft) return; - if (!rejectReason || !rejectStatus) return; - const reason = rejectReason.value; - if (!reason) { - rejectStatus.textContent = "Please select a reason."; - return; - } - rejectStatus.textContent = "Rejecting..."; - const payload = { - reason, - notes: rejectNotes?.value ?? "" - }; - const res = await fetch(`/posts/${currentDraft.id}/reject`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - rejectStatus.textContent = data.ok ? "Rejected." : data.error || "Reject failed."; - } - - async function schedulePost() { - if (!currentDraft) return; - if (!scheduleAt) return; - const when = scheduleAt.value; - scheduleStatus.textContent = "Scheduling..."; - const accountIds = Array.from( - scheduleAccounts?.querySelectorAll('input[type="checkbox"]:checked') ?? [] - ).map((el) => Number(el.value)); - const res = await fetch(`/posts/${currentDraft.id}/schedule`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ scheduledAt: when, accountIds }) - }); - const data = await res.json(); - scheduleStatus.textContent = data.ok ? "Scheduled." : data.error || "Schedule failed."; - if (data.ok) { - await refreshSchedules(); - } - } - - langSelect.addEventListener("change", () => { - if (!currentDraft) return; - currentLang = langSelect.value; - captionInput.value = currentDraft.texts[currentLang] || ""; - modalStatus.textContent = ""; - }); - - saveBtn.addEventListener("click", saveDraft); - approveBtn?.addEventListener("click", approvePost); - publishBtn.addEventListener("click", publishDraft); - scheduleBtn?.addEventListener("click", schedulePost); - rejectBtn?.addEventListener("click", rejectDraft); - modalClose.addEventListener("click", closeModal); - backdrop?.addEventListener("click", closeModal); - - return { openModal, closeModal }; -} diff --git a/src/ui/js/posts.js b/src/ui/js/posts.js deleted file mode 100644 index cfd75b5..0000000 --- a/src/ui/js/posts.js +++ /dev/null @@ -1,9 +0,0 @@ -export function initPosts({ postById, modal }) { - document.querySelectorAll(".preview-btn").forEach((btn) => { - btn.addEventListener("click", () => { - const id = btn.getAttribute("data-id"); - const post = postById.get(Number(id)); - if (post) modal.openModal(post); - }); - }); -} diff --git a/src/ui/js/runJob.js b/src/ui/js/runJob.js deleted file mode 100644 index 96290e3..0000000 --- a/src/ui/js/runJob.js +++ /dev/null @@ -1,17 +0,0 @@ -export function initRunJob({ runBtn, statusEl, refreshJobs }) { - if (!runBtn) return; - runBtn.addEventListener("click", async () => { - runBtn.disabled = true; - if (statusEl) statusEl.textContent = "Running..."; - try { - const res = await fetch("/api/jobs", { method: "POST" }); - const data = await res.json(); - if (statusEl) statusEl.textContent = data.ok ? `Queued job #${data.id}` : "Failed."; - if (refreshJobs) await refreshJobs(); - } catch (e) { - if (statusEl) statusEl.textContent = "Failed."; - } finally { - runBtn.disabled = false; - } - }); -} diff --git a/src/ui/styles/tailwind.css b/src/ui/styles/tailwind.css index d4a1b9e..d84e28d 100644 --- a/src/ui/styles/tailwind.css +++ b/src/ui/styles/tailwind.css @@ -77,6 +77,10 @@ .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } +.backdrop-filter { + -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); + backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); +} .transition { transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; transition-timing-function: var(--tw-ease, ease); @@ -2651,6 +2655,42 @@ pre { syntax: "*"; inherits: false; } +@property --tw-backdrop-blur { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-invert { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-backdrop-sepia { + syntax: "*"; + inherits: false; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -2674,6 +2714,15 @@ pre { --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-backdrop-blur: initial; + --tw-backdrop-brightness: initial; + --tw-backdrop-contrast: initial; + --tw-backdrop-grayscale: initial; + --tw-backdrop-hue-rotate: initial; + --tw-backdrop-invert: initial; + --tw-backdrop-opacity: initial; + --tw-backdrop-saturate: initial; + --tw-backdrop-sepia: initial; } } } diff --git a/src/ui/vue/index/App.vue b/src/ui/vue/index/App.vue index 6d15e22..192c2eb 100644 --- a/src/ui/vue/index/App.vue +++ b/src/ui/vue/index/App.vue @@ -1,6 +1,7 @@ + + diff --git a/src/ui/vue/jobs/components/JobDefinitionDrawer.vue b/src/ui/vue/jobs/components/JobDefinitionDrawer.vue index 2575729..5aa2bbc 100644 --- a/src/ui/vue/jobs/components/JobDefinitionDrawer.vue +++ b/src/ui/vue/jobs/components/JobDefinitionDrawer.vue @@ -2,11 +2,28 @@ import { storeToRefs } from "pinia"; import { onBeforeUnmount, onMounted } from "vue"; import DrawerShell from "../../shared/components/DrawerShell.vue"; +import AppMultiSelect from "../../shared/components/ui/AppMultiSelect.vue"; import { useJobDrawerStore } from "../stores/jobDrawerStore"; const drawer = useJobDrawerStore(); const { canLoadMoreSources, filteredSources, form, isOpen, saveLabel, saving, templates, visibleCategories } = storeToRefs(drawer); +const sourceTypeOptions = [ + { label: "RSS", value: "rss" }, + { label: "Scraper", value: "scrape" }, + { label: "AI", value: "ai" } +]; + +const translationLanguageOptions = [ + { label: "English (en)", value: "en" }, + { label: "Swedish (sv)", value: "sv" }, + { label: "Farsi (fa_AF)", value: "fa_AF" }, + { label: "Pashto (ps)", value: "ps" }, + { label: "Hindi (hi)", value: "hi" }, + { label: "Urdu (ur)", value: "ur" }, + { label: "Hinglish (hinglish)", value: "hinglish" } +]; + function onEsc(event: KeyboardEvent) { if (event.key === "Escape") { drawer.close(); @@ -59,27 +76,45 @@ onBeforeUnmount(() => {
@@ -149,15 +184,17 @@ onBeforeUnmount(() => {
Translations
Only selected languages will be translated for this job.
diff --git a/src/ui/vue/post/App.vue b/src/ui/vue/post/App.vue index c4cf388..b767478 100644 --- a/src/ui/vue/post/App.vue +++ b/src/ui/vue/post/App.vue @@ -4,7 +4,6 @@ import PostHeaderPanel from "./components/PostHeaderPanel.vue"; import PreviewGrid from "./components/PreviewGrid.vue"; import PostEditorPanel from "./components/PostEditorPanel.vue"; import PostMediaPanel from "./components/PostMediaPanel.vue"; -import PublishPanel from "./components/PublishPanel.vue"; import MediaEditor from "./components/MediaEditor.vue"; import ReelsPanel from "./components/ReelsPanel.vue"; import ShortsPanel from "./components/ShortsPanel.vue"; @@ -20,22 +19,21 @@ import FactPackPanel from "./components/FactPackPanel.vue";