Преглед изворни кода

Monthly content calendar — AI-generated narrative brief + platform-native posts

New POST /ai/content-calendar endpoint builds a full content plan: loads the
account profile for brand context, then calls the active AI provider to produce
a narrative brief (monthly theme, 3-4 content pillars, tone guidance, per-
platform strategy notes) plus 3 platform-native sample posts per selected
platform. Results are stored in a new content_calendars collection and returned
immediately. GET /ai/content-calendar/:id retrieves a saved calendar.

New CalendarPlan view at /calendar-plan (nav: "Content Plan"): month picker,
account context selector, platform checkboxes (10 platforms), Generate button.
Results show the narrative brief card + posts grouped by platform, each with
week number, post type badge (educational / promotional / engagement /
storytelling), full copy, hashtags, and a "Draft" button that pre-fills
Compose. "Save All Drafts" saves every post to the Drafts tab in one action.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris пре 3 недеља
родитељ
комит
189c1d4cbf

+ 143 - 0
services/gateway/server.js

@@ -917,6 +917,149 @@ app.post('/ai/stream', async (request, reply) => {
   }
 });
 
+// ─── Monthly Content Calendar ─────────────────────────────────────────────────
+
+// POST /ai/content-calendar — generate a monthly content plan with narrative brief + sample posts
+// Body: { accountKey?, platforms[], month? (YYYY-MM) }
+app.post('/ai/content-calendar', async (request, reply) => {
+  const { accountKey, platforms = [], month } = request.body || {};
+  if (!platforms.length) return reply.code(400).send({ error: 'Select at least one platform' });
+
+  const db = await getDb();
+  const calMonth = month || new Date().toISOString().slice(0, 7);
+  const [year, mon] = calMonth.split('-');
+  const monthName = new Date(`${calMonth}-01`).toLocaleString('en', { month: 'long', year: 'numeric' });
+
+  // Load account profile for context
+  const profileKey = accountKey || null;
+  const profile = profileKey
+    ? await db.collection('account_profiles').findOne({ _id: profileKey })
+    : await db.collection('account_profiles').findOne({});
+
+  const contextParts = ['You are a social media content strategist.'];
+  if (profile) {
+    if (profile.businessName)   contextParts.push(`Business: ${profile.businessName}`);
+    if (profile.description)    contextParts.push(`Description: ${profile.description}`);
+    if (profile.industry)       contextParts.push(`Industry: ${profile.industry}`);
+    if (profile.toneOfVoice)    contextParts.push(`Tone of voice: ${profile.toneOfVoice}`);
+    if (profile.targetAudience) contextParts.push(`Target audience: ${profile.targetAudience}`);
+    if (profile.keywords)       contextParts.push(`Keywords: ${profile.keywords}`);
+  }
+  const brandContext = contextParts.join('\n');
+
+  // Per-platform post count: 3 per platform (weeks 1–3)
+  const platformList = platforms.slice(0, 5).join(', ');
+  const postsPerPlatform = 3;
+  const totalPosts = platforms.slice(0, 5).length * postsPerPlatform;
+
+  const system = 'You are a social media content strategist. Return only valid JSON with no explanation, no markdown code blocks.';
+  const prompt = `${brandContext}
+
+Create a content calendar for ${monthName} across these platforms: ${platformList}.
+
+Return a JSON object with exactly these fields:
+{
+  "brief": {
+    "theme": "The overarching monthly narrative theme in one sentence",
+    "pillars": ["3-4 content pillars that anchor all posts this month"],
+    "toneGuidance": "One sentence on tone and voice for this month",
+    "platformNotes": {
+      ${platforms.slice(0, 5).map((p) => `"${p}": "One sentence of platform-specific content strategy for ${p}"`).join(',\n      ')}
+    }
+  },
+  "posts": [
+    ${platforms.slice(0, 5).flatMap((p, pi) =>
+      [1, 2, 3].map((w, wi) => `{
+      "platform": "${p}",
+      "week": ${w},
+      "content": "<full post text ready to publish, following platform-specific best practices>",
+      "hashtags": ["<2-4 relevant hashtags>"],
+      "postType": "<educational|promotional|engagement|storytelling>",
+      "suggestedDay": "<best day of week to publish on ${p}>"
+    }${pi < platforms.length - 1 || wi < 2 ? ',' : ''}`)).join('\n    ')}
+  ]
+}
+
+Important: Each post must follow platform conventions — LinkedIn hooks in first 2 lines, TikTok scripts with second-0 hook, Instagram assuming silent viewing, etc.
+Return ONLY the JSON object.`;
+
+  try {
+    const pconf = await getActiveProviderConfig();
+    const model = pconf.model;
+    let text = '';
+
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 180000 });
+      text = res.data.response;
+    } else if (pconf.provider === 'openai' || pconf.provider === 'groq') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: `${pconf.provider} API key not configured` });
+      const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model, messages: buildOpenAIMessages(prompt, system), stream: false,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 180000 });
+      text = res.data.choices[0]?.message?.content || '';
+    } else if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
+      const res = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
+        { contents: buildGeminiContents(prompt, system) },
+        { timeout: 180000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    let calendar = null;
+    try {
+      const jsonStr = (text.match(/\{[\s\S]*\}/) || ['{}'])[0];
+      calendar = JSON.parse(jsonStr);
+      if (!calendar.brief || !Array.isArray(calendar.posts)) throw new Error();
+      // Normalise
+      if (!Array.isArray(calendar.brief.pillars)) calendar.brief.pillars = [];
+      if (typeof calendar.brief.theme !== 'string') calendar.brief.theme = '';
+      calendar.posts = calendar.posts.filter((p) => p && typeof p.content === 'string').slice(0, totalPosts);
+    } catch {
+      return reply.code(503).send({ error: 'AI returned invalid calendar format — try again' });
+    }
+
+    const doc = {
+      accountKey: accountKey || null,
+      month: calMonth,
+      monthName,
+      platforms,
+      brief: calendar.brief,
+      posts: calendar.posts,
+      createdAt: new Date(),
+    };
+    const result = await db.collection('content_calendars').insertOne(doc);
+    log.info({ action: 'content_calendar', month: calMonth, platforms: platforms.join(','), posts: calendar.posts.length, outcome: 'success' });
+    return { success: true, calendarId: result.insertedId.toString(), ...doc };
+  } catch (err) {
+    return reply.code(503).send({ error: 'Calendar generation failed', detail: err.message });
+  }
+});
+
+// GET /ai/content-calendar/:id — retrieve a saved calendar
+app.get('/ai/content-calendar/:id', async (request, reply) => {
+  let oid;
+  try { oid = new ObjectId(request.params.id); } catch { return reply.code(400).send({ error: 'Invalid id' }); }
+  const db = await getDb();
+  const cal = await db.collection('content_calendars').findOne({ _id: oid });
+  if (!cal) return reply.code(404).send({ error: 'Calendar not found' });
+  return { calendarId: cal._id.toString(), ...cal };
+});
+
+// GET /ai/content-calendars — list recent calendars
+app.get('/ai/content-calendars', async (request, reply) => {
+  const db = await getDb();
+  const cals = await db.collection('content_calendars')
+    .find({}, { projection: { posts: 0 } })
+    .sort({ createdAt: -1 })
+    .limit(20)
+    .toArray();
+  return { calendars: cals.map((c) => ({ calendarId: c._id.toString(), month: c.month, monthName: c.monthName, platforms: c.platforms, brief: c.brief, createdAt: c.createdAt })) };
+});
+
 // ─── Bulk AI Draft Generation ─────────────────────────────────────────────────
 
 // POST /ai/bulk-draft — kick off a batch; returns batchId immediately (non-blocking)

+ 4 - 3
ui/src/components/NavBar.vue

@@ -65,9 +65,10 @@ const navLinks = [
   { to: '/compose',   label: 'nav.compose' },
   { to: '/media',     label: 'nav.media' },
   { to: '/scheduler', label: 'nav.scheduler' },
-  { to: '/analytics',    label: 'nav.analytics' },
-  { to: '/competitors',  label: 'nav.competitors' },
-  { to: '/settings',     label: 'nav.settings' },
+  { to: '/analytics',      label: 'nav.analytics' },
+  { to: '/calendar-plan',  label: 'nav.calendarPlan' },
+  { to: '/competitors',    label: 'nav.competitors' },
+  { to: '/settings',       label: 'nav.settings' },
 ]
 
 const currentLocale = computed(

+ 20 - 0
ui/src/locales/en.ts

@@ -5,6 +5,7 @@ export default {
     media: 'Media',
     scheduler: 'Scheduler',
     analytics: 'Analytics',
+    calendarPlan: 'Content Plan',
     competitors: 'Competitors',
     settings: 'Settings',
   },
@@ -94,6 +95,25 @@ export default {
     dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
   },
 
+  calendarPlan: {
+    title: 'Content Plan',
+    subtitle: 'AI-generated monthly content calendar with narrative brief and platform-native posts.',
+    month: 'Month',
+    account: 'Account context',
+    allAccounts: 'All accounts',
+    platforms: 'Platforms',
+    generate: 'Generate Plan',
+    generating: 'Generating…',
+    briefTitle: 'Narrative Brief',
+    theme: 'Monthly theme',
+    pillars: 'Content pillars',
+    toneGuidance: 'Tone guidance',
+    saveAllDrafts: 'Save {count} posts as Drafts',
+    savingAll: 'Saving drafts…',
+    draft: 'Draft',
+    week: 'Week {n}',
+  },
+
   media: {
     title: 'Media Library',
     fileCount: '{count} files',

+ 20 - 0
ui/src/locales/tr.ts

@@ -5,6 +5,7 @@ export default {
     media: 'Medya',
     scheduler: 'Zamanlama',
     analytics: 'Analitik',
+    calendarPlan: 'İçerik Planı',
     competitors: 'Rakipler',
     settings: 'Ayarlar',
   },
@@ -94,6 +95,25 @@ export default {
     dayNamesShort: ['Paz', 'Pzt', 'Sal', 'Çar', 'Per', 'Cum', 'Cmt'],
   },
 
+  calendarPlan: {
+    title: 'İçerik Planı',
+    subtitle: 'YZ tarafından oluşturulan aylık içerik takvimi: anlatı özeti ve platforma özel gönderiler.',
+    month: 'Ay',
+    account: 'Hesap bağlamı',
+    allAccounts: 'Tüm hesaplar',
+    platforms: 'Platformlar',
+    generate: 'Plan Oluştur',
+    generating: 'Oluşturuluyor…',
+    briefTitle: 'Anlatı Özeti',
+    theme: 'Aylık tema',
+    pillars: 'İçerik sütunları',
+    toneGuidance: 'Ton rehberi',
+    saveAllDrafts: '{count} gönderiyi Taslak olarak kaydet',
+    savingAll: 'Taslaklar kaydediliyor…',
+    draft: 'Taslağa al',
+    week: '{n}. hafta',
+  },
+
   media: {
     title: 'Medya Kütüphanesi',
     fileCount: '{count} dosya',

+ 5 - 0
ui/src/router/index.ts

@@ -37,6 +37,11 @@ const router = createRouter({
       name: 'competitors',
       component: () => import('../views/Competitors.vue'),
     },
+    {
+      path: '/calendar-plan',
+      name: 'calendarPlan',
+      component: () => import('../views/CalendarPlan.vue'),
+    },
     {
       path: '/settings',
       name: 'settings',

+ 281 - 0
ui/src/views/CalendarPlan.vue

@@ -0,0 +1,281 @@
+<template>
+  <div class="min-h-screen bg-gray-950 text-gray-100">
+    <div class="max-w-5xl mx-auto px-6 py-8">
+
+      <!-- Header -->
+      <div class="mb-8">
+        <h1 class="text-2xl font-bold text-white">{{ $t('calendarPlan.title') }}</h1>
+        <p class="text-sm text-gray-500 mt-1">{{ $t('calendarPlan.subtitle') }}</p>
+      </div>
+
+      <!-- Configuration card -->
+      <div class="bg-gray-900 border border-gray-800 rounded-2xl p-6 mb-6">
+        <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
+          <!-- Month -->
+          <div>
+            <label class="block text-xs text-gray-400 mb-1.5">{{ $t('calendarPlan.month') }}</label>
+            <input
+              v-model="selectedMonth"
+              type="month"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-violet-500"
+            />
+          </div>
+
+          <!-- Account -->
+          <div>
+            <label class="block text-xs text-gray-400 mb-1.5">{{ $t('calendarPlan.account') }}</label>
+            <select
+              v-model="selectedAccount"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-violet-500"
+            >
+              <option value="">{{ $t('calendarPlan.allAccounts') }}</option>
+              <option v-for="acc in connectedAccounts" :key="acc.key" :value="acc.key">{{ acc.label }}</option>
+            </select>
+          </div>
+
+          <!-- Generate button -->
+          <div class="flex items-end">
+            <button
+              @click="generate"
+              :disabled="loading || !selectedPlatforms.length"
+              class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+            >
+              <i class="fa-solid fa-calendar-days text-xs" :class="{ 'animate-pulse': loading }"></i>
+              {{ loading ? $t('calendarPlan.generating') : $t('calendarPlan.generate') }}
+            </button>
+          </div>
+        </div>
+
+        <!-- Platform checkboxes -->
+        <div>
+          <label class="block text-xs text-gray-400 mb-2">{{ $t('calendarPlan.platforms') }}</label>
+          <div class="flex flex-wrap gap-2">
+            <label
+              v-for="p in PLATFORMS"
+              :key="p.key"
+              class="flex items-center gap-1.5 px-2.5 py-1 rounded-lg border text-xs cursor-pointer transition-colors"
+              :class="selectedPlatforms.includes(p.key)
+                ? 'text-white border-transparent'
+                : 'border-gray-700 text-gray-400 hover:border-gray-500'"
+              :style="selectedPlatforms.includes(p.key) ? { background: p.color + '33', borderColor: p.color } : {}"
+            >
+              <input type="checkbox" class="sr-only" :value="p.key" v-model="selectedPlatforms" />
+              <i :class="p.icon" class="text-[11px]"></i>
+              {{ p.label }}
+            </label>
+          </div>
+        </div>
+      </div>
+
+      <!-- Error -->
+      <div v-if="error" class="mb-6 p-3 bg-red-900/40 border border-red-700 rounded-xl text-red-300 text-sm">
+        {{ error }}
+      </div>
+
+      <!-- Calendar results -->
+      <div v-if="calendar">
+        <!-- Narrative brief -->
+        <div class="bg-gray-900 border border-violet-800/40 rounded-2xl p-6 mb-6">
+          <div class="flex items-center justify-between mb-4">
+            <div>
+              <h2 class="font-semibold text-white">{{ $t('calendarPlan.briefTitle') }}</h2>
+              <p class="text-xs text-gray-500 mt-0.5">{{ calendar.monthName }}</p>
+            </div>
+            <button
+              @click="saveAllDrafts"
+              :disabled="savingAll"
+              class="flex items-center gap-1.5 text-sm px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded-lg text-white transition-colors"
+            >
+              <i class="fa-solid fa-floppy-disk text-xs"></i>
+              {{ savingAll ? $t('calendarPlan.savingAll') : $t('calendarPlan.saveAllDrafts', { count: calendar.posts.length }) }}
+            </button>
+          </div>
+
+          <div class="p-4 bg-gray-800/50 rounded-xl mb-4">
+            <div class="text-xs text-violet-400 font-medium mb-1">{{ $t('calendarPlan.theme') }}</div>
+            <p class="text-sm text-gray-200">{{ calendar.brief.theme }}</p>
+          </div>
+
+          <div class="grid grid-cols-2 gap-3">
+            <div>
+              <div class="text-xs text-gray-400 mb-2">{{ $t('calendarPlan.pillars') }}</div>
+              <ul class="space-y-1">
+                <li v-for="p in calendar.brief.pillars" :key="p" class="flex gap-1.5 text-xs text-gray-300">
+                  <span class="text-violet-400">▪</span>{{ p }}
+                </li>
+              </ul>
+            </div>
+            <div v-if="calendar.brief.toneGuidance">
+              <div class="text-xs text-gray-400 mb-2">{{ $t('calendarPlan.toneGuidance') }}</div>
+              <p class="text-xs text-gray-300 italic">{{ calendar.brief.toneGuidance }}</p>
+            </div>
+          </div>
+        </div>
+
+        <!-- Posts by platform -->
+        <div class="space-y-4">
+          <div
+            v-for="platform in calendar.platforms"
+            :key="platform"
+            class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden"
+          >
+            <!-- Platform header -->
+            <div class="px-5 py-3 border-b border-gray-800 flex items-center gap-2">
+              <i :class="platformIcon(platform)" class="text-sm" :style="{ color: platformColor(platform) }"></i>
+              <span class="font-medium text-sm capitalize">{{ platform }}</span>
+              <span v-if="calendar.brief.platformNotes?.[platform]" class="text-xs text-gray-500 ml-2 italic">{{ calendar.brief.platformNotes[platform] }}</span>
+            </div>
+
+            <!-- Posts for this platform -->
+            <div class="divide-y divide-gray-800/60">
+              <div
+                v-for="post in postsForPlatform(platform)"
+                :key="post.week + post.content.slice(0, 20)"
+                class="p-4"
+              >
+                <div class="flex items-start gap-3">
+                  <div class="flex-1 min-w-0">
+                    <div class="flex items-center gap-2 mb-1.5">
+                      <span class="text-xs text-gray-500">{{ $t('calendarPlan.week', { n: post.week }) }}</span>
+                      <span v-if="post.suggestedDay" class="text-xs text-gray-500">· {{ post.suggestedDay }}</span>
+                      <span
+                        class="text-xs px-1.5 py-0.5 rounded capitalize"
+                        :class="{
+                          'bg-blue-900/40 text-blue-300': post.postType === 'educational',
+                          'bg-violet-900/40 text-violet-300': post.postType === 'promotional',
+                          'bg-green-900/40 text-green-300': post.postType === 'engagement',
+                          'bg-amber-900/40 text-amber-300': post.postType === 'storytelling',
+                        }"
+                      >{{ post.postType }}</span>
+                    </div>
+                    <p class="text-sm text-gray-200 mb-2 whitespace-pre-line leading-relaxed">{{ post.content }}</p>
+                    <div v-if="post.hashtags?.length" class="flex flex-wrap gap-1">
+                      <span v-for="tag in post.hashtags" :key="tag" class="text-xs text-emerald-400 font-mono">{{ tag }}</span>
+                    </div>
+                  </div>
+                  <button
+                    @click="draftPost(post.content)"
+                    class="shrink-0 flex items-center gap-1 text-xs px-2.5 py-1.5 bg-violet-700 hover:bg-violet-600 text-white rounded-lg"
+                  >
+                    <i class="fa-solid fa-pen-to-square text-[10px]"></i>
+                    {{ $t('calendarPlan.draft') }}
+                  </button>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import axios from 'axios'
+import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
+import { useComposeStore } from '../stores/compose'
+
+const router = useRouter()
+const platformsStore = usePlatformsStore()
+const composeStore = useComposeStore()
+
+const PLATFORMS = [
+  { key: 'linkedin',  label: 'LinkedIn',   color: '#0077B5', icon: 'fa-brands fa-linkedin' },
+  { key: 'instagram', label: 'Instagram',  color: '#E1306C', icon: 'fa-brands fa-instagram' },
+  { key: 'facebook',  label: 'Facebook',   color: '#1877F2', icon: 'fa-brands fa-facebook' },
+  { key: 'twitter',   label: 'Twitter/X',  color: '#000000', icon: 'fa-brands fa-x-twitter' },
+  { key: 'tiktok',    label: 'TikTok',     color: '#EE1D52', icon: 'fa-brands fa-tiktok' },
+  { key: 'pinterest', label: 'Pinterest',  color: '#E60023', icon: 'fa-brands fa-pinterest' },
+  { key: 'youtube',   label: 'YouTube',    color: '#FF0000', icon: 'fa-brands fa-youtube' },
+  { key: 'mastodon',  label: 'Mastodon',   color: '#6364FF', icon: 'fa-brands fa-mastodon' },
+  { key: 'bluesky',   label: 'Bluesky',    color: '#0085FF', icon: 'fa-brands fa-bluesky' },
+  { key: 'reddit',    label: 'Reddit',     color: '#FF4500', icon: 'fa-brands fa-reddit' },
+]
+
+const selectedMonth   = ref(new Date().toISOString().slice(0, 7))
+const selectedAccount = ref('')
+const selectedPlatforms = ref<string[]>(['linkedin', 'instagram'])
+const loading    = ref(false)
+const savingAll  = ref(false)
+const error      = ref('')
+const calendar   = ref<any>(null)
+
+interface ProfileAccount { key: string; label: string }
+const connectedAccounts = computed((): ProfileAccount[] => {
+  const list: ProfileAccount[] = []
+  for (const [platform] of Object.entries(PLATFORM_META)) {
+    if (['facebook', 'instagram', 'pinterest', 'tiktok'].includes(platform)) continue
+    if (platformsStore.isConnected(platform)) {
+      list.push({ key: platform, label: platform.charAt(0).toUpperCase() + platform.slice(1) })
+    }
+  }
+  for (const page of platformsStore.connectedPages) {
+    list.push({ key: `facebook:${page.id}`, label: page.name })
+  }
+  for (const acc of platformsStore.connectedIgAccounts) {
+    list.push({ key: `instagram:${acc.id}`, label: `@${acc.username}` })
+  }
+  return list
+})
+
+function platformColor(p: string): string {
+  return PLATFORMS.find((x) => x.key === p)?.color ?? '#6b7280'
+}
+function platformIcon(p: string): string {
+  return PLATFORMS.find((x) => x.key === p)?.icon ?? 'fa-solid fa-globe'
+}
+
+function postsForPlatform(platform: string) {
+  return (calendar.value?.posts ?? []).filter((p: any) => p.platform === platform)
+}
+
+async function generate() {
+  if (!selectedPlatforms.value.length) return
+  loading.value = true
+  error.value = ''
+  try {
+    const res = await axios.post('/api/ai/content-calendar', {
+      accountKey: selectedAccount.value || undefined,
+      platforms: selectedPlatforms.value,
+      month: selectedMonth.value,
+    })
+    calendar.value = res.data
+  } catch (err: any) {
+    error.value = err.response?.data?.error || 'Calendar generation failed'
+  } finally {
+    loading.value = false
+  }
+}
+
+function draftPost(content: string) {
+  composeStore.content = content
+  router.push('/compose')
+}
+
+async function saveAllDrafts() {
+  if (!calendar.value?.posts?.length) return
+  savingAll.value = true
+  try {
+    for (const post of calendar.value.posts) {
+      await axios.post('/api/drafts', {
+        content: post.content,
+        mediaUrl: '',
+        scheduledAt: '',
+        destinations: [],
+      })
+    }
+    alert(`Saved ${calendar.value.posts.length} drafts — find them in Scheduler → Drafts.`)
+  } catch {
+    error.value = 'Failed to save some drafts'
+  } finally {
+    savingAll.value = false
+  }
+}
+
+onMounted(() => {
+  platformsStore.fetchMetaConnections()
+})
+</script>