فهرست منبع

Select Account on Schedule Page

Benjamin Harris 1 ماه پیش
والد
کامیت
5e1e456bd0

+ 7 - 3
services/facebook/index.js

@@ -109,11 +109,15 @@ class FacebookService extends BasePlatformService {
     return allItems;
     return allItems;
   }
   }
 
 
-  async publishPost({ content, link, imageUrl } = {}) {
-    const pages = await this._getPages();
-    if (pages.length === 0) throw new Error('No Facebook Pages connected');
+  async publishPost({ content, link, imageUrl, accountId } = {}) {
+    const allPages = await this._getPages();
+    if (allPages.length === 0) throw new Error('No Facebook Pages connected');
     if (!content) throw new Error('content is required');
     if (!content) throw new Error('content is required');
 
 
+    // If a specific page is requested, target only that page
+    const pages = accountId ? allPages.filter((p) => p.id === accountId) : allPages;
+    if (pages.length === 0) throw new Error(`Facebook page ${accountId} not found or not connected`);
+
     const results = [];
     const results = [];
     for (const page of pages) {
     for (const page of pages) {
       const params = { message: content, access_token: page.accessToken };
       const params = { message: content, access_token: page.accessToken };

+ 37 - 0
services/gateway/server.js

@@ -42,6 +42,43 @@ async function deleteCredentials(id) {
   await db.collection('platform_credentials').deleteOne({ _id: id });
   await db.collection('platform_credentials').deleteOne({ _id: id });
 }
 }
 
 
+// ─── Platform service URLs ────────────────────────────────────────────────────
+
+const PLATFORM_SERVICES = {
+  twitter:   process.env.TWITTER_SERVICE_URL   || 'http://twitter:3001',
+  linkedin:  process.env.LINKEDIN_SERVICE_URL  || 'http://linkedin:3002',
+  mastodon:  process.env.MASTODON_SERVICE_URL  || 'http://mastodon:3003',
+  bluesky:   process.env.BLUESKY_SERVICE_URL   || 'http://bluesky:3004',
+  instagram: process.env.INSTAGRAM_SERVICE_URL || 'http://instagram:3005',
+  facebook:  process.env.FACEBOOK_SERVICE_URL  || 'http://facebook:3006',
+};
+
+// Direct multi-platform post endpoint.
+// Body: { content: string, destinations: Array<{ platform, accountId?, imageUrl?, videoUrl?, link? }> }
+app.post('/post', async (request, reply) => {
+  const { content, destinations = [] } = request.body || {};
+  if (!content?.trim()) return reply.code(400).send({ error: 'content is required' });
+  if (!destinations.length) return reply.code(400).send({ error: 'destinations must not be empty' });
+
+  const results = await Promise.allSettled(
+    destinations.map(async ({ platform, accountId, imageUrl, videoUrl, link }) => {
+      const serviceUrl = PLATFORM_SERVICES[platform];
+      if (!serviceUrl) throw new Error(`Unknown platform: ${platform}`);
+      const res = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link }, { timeout: 30000 });
+      return { platform, accountId, ...res.data };
+    })
+  );
+
+  const output = results.map((r, i) =>
+    r.status === 'fulfilled'
+      ? r.value
+      : { platform: destinations[i].platform, accountId: destinations[i].accountId, success: false, error: r.reason?.message }
+  );
+
+  const anyFailed = output.some((r) => !r.success);
+  return reply.code(anyFailed ? 207 : 200).send({ results: output });
+});
+
 // ─── Legacy post route ────────────────────────────────────────────────────────
 // ─── Legacy post route ────────────────────────────────────────────────────────
 
 
 let rabbitMQProducer = new RabbitMQProducer();
 let rabbitMQProducer = new RabbitMQProducer();

+ 7 - 3
services/instagram/index.js

@@ -116,14 +116,18 @@ class InstagramService extends BasePlatformService {
   }
   }
 
 
   // Instagram requires media (image_url or video_url) — text-only posts are not supported.
   // Instagram requires media (image_url or video_url) — text-only posts are not supported.
-  async publishPost({ content, imageUrl, videoUrl } = {}) {
-    const accounts = await this._getAccounts();
-    if (accounts.length === 0) throw new Error('No Instagram accounts connected');
+  async publishPost({ content, imageUrl, videoUrl, accountId } = {}) {
+    const allAccounts = await this._getAccounts();
+    if (allAccounts.length === 0) throw new Error('No Instagram accounts connected');
 
 
     if (!imageUrl && !videoUrl) {
     if (!imageUrl && !videoUrl) {
       throw new Error('Instagram requires imageUrl or videoUrl — text-only posts are not supported by the Graph API');
       throw new Error('Instagram requires imageUrl or videoUrl — text-only posts are not supported by the Graph API');
     }
     }
 
 
+    // If a specific account is requested, target only that account
+    const accounts = accountId ? allAccounts.filter((a) => a.id === accountId) : allAccounts;
+    if (accounts.length === 0) throw new Error(`Instagram account ${accountId} not found or not connected`);
+
     const results = [];
     const results = [];
     for (const account of accounts) {
     for (const account of accounts) {
       const containerParams = {
       const containerParams = {

+ 24 - 15
services/scheduler/index.js

@@ -23,23 +23,29 @@ let redis;
 // ─── Job Worker ──────────────────────────────────────────────────────────────
 // ─── Job Worker ──────────────────────────────────────────────────────────────
 
 
 async function processPostJob(job) {
 async function processPostJob(job) {
-  const { postId, content, platforms, media = [] } = job.data;
-  console.log(`[Scheduler] Job ${job.id} çalışıyor: ${platforms.join(', ')}`);
+  // destinations: [{ platform, accountId?, imageUrl?, videoUrl?, link? }]
+  // Falls back to legacy { platforms: string[] } format
+  const { postId, content, destinations, platforms, media = [] } = job.data;
+
+  const destList = destinations || (platforms || []).map((p) => ({ platform: p }));
+  console.log(`[Scheduler] Job ${job.id}: ${destList.map((d) => d.accountId ? `${d.platform}:${d.accountId}` : d.platform).join(', ')}`);
 
 
   const db = await getDb();
   const db = await getDb();
   const results = {};
   const results = {};
 
 
-  for (const platform of platforms) {
+  for (const dest of destList) {
+    const { platform, accountId, imageUrl, videoUrl, link } = dest;
+    const resultKey = accountId ? `${platform}:${accountId}` : platform;
     const serviceUrl = PLATFORM_SERVICES[platform];
     const serviceUrl = PLATFORM_SERVICES[platform];
     if (!serviceUrl) {
     if (!serviceUrl) {
-      results[platform] = { success: false, error: 'Bilinmeyen platform' };
+      results[resultKey] = { success: false, error: 'Unknown platform' };
       continue;
       continue;
     }
     }
     try {
     try {
-      const response = await axios.post(`${serviceUrl}/post`, { content, media }, { timeout: 30000 });
-      results[platform] = { success: true, ...response.data.result };
+      const response = await axios.post(`${serviceUrl}/post`, { content, accountId, imageUrl, videoUrl, link, media }, { timeout: 30000 });
+      results[resultKey] = { success: true, ...response.data.result };
     } catch (err) {
     } catch (err) {
-      results[platform] = { success: false, error: err.message };
+      results[resultKey] = { success: false, error: err.message };
     }
     }
   }
   }
 
 
@@ -72,32 +78,35 @@ async function processPostJob(job) {
 
 
 app.get('/health', async () => ({ status: 'ok', service: 'scheduler' }));
 app.get('/health', async () => ({ status: 'ok', service: 'scheduler' }));
 
 
-// Yeni zamanlanmış gönderi oluştur
+// Create a scheduled post.
+// Body: { content, scheduledAt, destinations: [{ platform, accountId?, imageUrl?, videoUrl?, link? }] }
+// Legacy { platforms: string[] } still accepted for backwards compatibility.
 app.post('/schedule', async (request, reply) => {
 app.post('/schedule', async (request, reply) => {
-  const { postId, content, platforms, scheduledAt, media = [] } = request.body;
+  const { postId, content, destinations, platforms, scheduledAt, media = [] } = request.body;
+
+  const destList = destinations || (platforms || []).map((p) => ({ platform: p }));
 
 
-  if (!content || !platforms?.length || !scheduledAt) {
-    return reply.code(400).send({ error: 'content, platforms ve scheduledAt zorunlu' });
+  if (!content || !destList.length || !scheduledAt) {
+    return reply.code(400).send({ error: 'content, destinations, and scheduledAt are required' });
   }
   }
 
 
   const delay = new Date(scheduledAt).getTime() - Date.now();
   const delay = new Date(scheduledAt).getTime() - Date.now();
   if (delay < 0) {
   if (delay < 0) {
-    return reply.code(400).send({ error: 'scheduledAt geçmiş bir tarih olamaz' });
+    return reply.code(400).send({ error: 'scheduledAt must be in the future' });
   }
   }
 
 
   const job = await postQueue.add(
   const job = await postQueue.add(
     'scheduled-post',
     'scheduled-post',
-    { postId, content, platforms, media },
+    { postId, content, destinations: destList, media },
     { delay, attempts: 3, backoff: { type: 'exponential', delay: 60000 } }
     { delay, attempts: 3, backoff: { type: 'exponential', delay: 60000 } }
   );
   );
 
 
-  // MongoDB kayıt
   const db = await getDb();
   const db = await getDb();
   await db.collection('scheduled_jobs').insertOne({
   await db.collection('scheduled_jobs').insertOne({
     postId,
     postId,
     type: 'one-time',
     type: 'one-time',
     scheduledAt: new Date(scheduledAt),
     scheduledAt: new Date(scheduledAt),
-    platforms,
+    destinations: destList,
     status: 'pending',
     status: 'pending',
     attempts: 0,
     attempts: 0,
     maxAttempts: 3,
     maxAttempts: 3,

+ 9 - 2
ui/src/locales/en.ts

@@ -21,15 +21,22 @@ export default {
 
 
   compose: {
   compose: {
     title: 'New Post',
     title: 'New Post',
-    platformsLabel: 'Share to platforms',
+    destinationsLabel: 'Post to',
     placeholder: "What's on your mind?",
     placeholder: "What's on your mind?",
-    schedulingLabel: 'Schedule (optional)',
     cancel: 'Cancel',
     cancel: 'Cancel',
     schedule: 'Schedule',
     schedule: 'Schedule',
     scheduling: 'Scheduling...',
     scheduling: 'Scheduling...',
     send: 'Post →',
     send: 'Post →',
+    postAndSchedule: 'Post & Schedule',
     sending: 'Posting...',
     sending: 'Posting...',
     successMessage: 'Post sent successfully.',
     successMessage: 'Post sent successfully.',
+    scheduleTitle: 'Schedule this destination (leave empty to post now)',
+    facebookPages: 'Facebook Pages',
+    instagramAccounts: 'Instagram Accounts',
+    igImagePlaceholder: 'Image URL (required for Instagram)',
+    igImageRequired: 'Add an image URL for: {accounts}',
+    noDestinations: 'No platforms configured.',
+    goToSettings: 'Go to Settings →',
   },
   },
 
 
   scheduler: {
   scheduler: {

+ 9 - 2
ui/src/locales/tr.ts

@@ -21,15 +21,22 @@ export default {
 
 
   compose: {
   compose: {
     title: 'Yeni Gönderi',
     title: 'Yeni Gönderi',
-    platformsLabel: 'Paylaşılacak platformlar',
+    destinationsLabel: 'Şuralara gönder',
     placeholder: 'Ne düşünüyorsun?',
     placeholder: 'Ne düşünüyorsun?',
-    schedulingLabel: 'Zamanlama (opsiyonel)',
     cancel: 'İptal',
     cancel: 'İptal',
     schedule: 'Zamanla',
     schedule: 'Zamanla',
     scheduling: 'Zamanlanıyor...',
     scheduling: 'Zamanlanıyor...',
     send: 'Gönder →',
     send: 'Gönder →',
+    postAndSchedule: 'Gönder & Zamanla',
     sending: 'Gönderiliyor...',
     sending: 'Gönderiliyor...',
     successMessage: 'Gönderi başarıyla gönderildi.',
     successMessage: 'Gönderi başarıyla gönderildi.',
+    scheduleTitle: 'Bu hedef için zamanlama (boş bırakırsan hemen gönderilir)',
+    facebookPages: 'Facebook Sayfaları',
+    instagramAccounts: 'Instagram Hesapları',
+    igImagePlaceholder: 'Görsel URL\'si (Instagram için zorunlu)',
+    igImageRequired: 'Görsel URL\'si ekle: {accounts}',
+    noDestinations: 'Hiçbir platform yapılandırılmamış.',
+    goToSettings: 'Ayarlara git →',
   },
   },
 
 
   scheduler: {
   scheduler: {

+ 141 - 44
ui/src/stores/compose.ts

@@ -1,27 +1,41 @@
 import { defineStore } from 'pinia'
 import { defineStore } from 'pinia'
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import axios from 'axios'
 import axios from 'axios'
+import { usePlatformsStore, PLATFORM_META } from './platforms'
+
+export interface Destination {
+  key: string        // 'twitter', 'facebook:PAGE_ID', 'instagram:ACCOUNT_ID'
+  platform: string
+  accountId?: string
+  label: string
+  color: string
+  picture?: string
+  selected: boolean
+  scheduledAt: string  // empty string = post immediately
+  imageUrl?: string    // instagram only
+}
+
+const CHAR_LIMITS: Record<string, number> = {
+  twitter: 280,
+  mastodon: 500,
+  bluesky: 300,
+  linkedin: 3000,
+  reddit: 40000,
+}
+
+const STANDARD_PLATFORMS = ['twitter', 'mastodon', 'bluesky', 'linkedin', 'reddit', 'youtube']
 
 
 export const useComposeStore = defineStore('compose', () => {
 export const useComposeStore = defineStore('compose', () => {
   const content = ref('')
   const content = ref('')
-  const selectedPlatforms = ref<string[]>(['twitter', 'mastodon', 'bluesky'])
-  const scheduledAt = ref<string>('')
+  const destinations = ref<Destination[]>([])
   const sending = ref(false)
   const sending = ref(false)
   const lastResult = ref<Record<string, unknown> | null>(null)
   const lastResult = ref<Record<string, unknown> | null>(null)
 
 
-  const CHAR_LIMITS: Record<string, number> = {
-    twitter: 280,
-    mastodon: 500,
-    bluesky: 300,
-    linkedin: 3000,
-    reddit: 40000,
-  }
-
   function charLimit(platform: string): number {
   function charLimit(platform: string): number {
     return CHAR_LIMITS[platform] ?? 9999
     return CHAR_LIMITS[platform] ?? 9999
   }
   }
 
 
-  function charCount(platform: string): number {
+  function charCount(): number {
     return content.value.length
     return content.value.length
   }
   }
 
 
@@ -29,53 +43,136 @@ export const useComposeStore = defineStore('compose', () => {
     return content.value.length > charLimit(platform)
     return content.value.length > charLimit(platform)
   }
   }
 
 
-  function togglePlatform(platform: string) {
-    const idx = selectedPlatforms.value.indexOf(platform)
-    if (idx >= 0) {
-      selectedPlatforms.value.splice(idx, 1)
-    } else {
-      selectedPlatforms.value.push(platform)
+  const selectedDestinations = computed(() => destinations.value.filter((d) => d.selected))
+
+  const hasImmediateDestinations = computed(() =>
+    selectedDestinations.value.some((d) => !d.scheduledAt)
+  )
+
+  const hasScheduledDestinations = computed(() =>
+    selectedDestinations.value.some((d) => !!d.scheduledAt)
+  )
+
+  function initDestinations() {
+    const platformsStore = usePlatformsStore()
+    const next: Destination[] = []
+
+    // Standard platforms (one toggle per platform)
+    for (const p of STANDARD_PLATFORMS) {
+      const meta = PLATFORM_META[p]
+      if (!meta) continue
+      next.push({
+        key: p,
+        platform: p,
+        label: meta.label,
+        color: meta.color,
+        selected: false,
+        scheduledAt: '',
+      })
     }
     }
-  }
 
 
-  async function sendNow() {
-    if (!content.value.trim() || !selectedPlatforms.value.length) return
-    sending.value = true
-    try {
-      const res = await axios.post('/api/', {
-        message: content.value,
-        platforms: selectedPlatforms.value,
+    // Facebook pages
+    for (const page of platformsStore.connectedPages) {
+      next.push({
+        key: `facebook:${page.id}`,
+        platform: 'facebook',
+        accountId: page.id,
+        label: page.name,
+        color: PLATFORM_META.facebook.color,
+        picture: page.picture,
+        selected: false,
+        scheduledAt: '',
+      })
+    }
+
+    // Instagram accounts
+    for (const account of platformsStore.connectedIgAccounts) {
+      next.push({
+        key: `instagram:${account.id}`,
+        platform: 'instagram',
+        accountId: account.id,
+        label: `@${account.username}`,
+        color: PLATFORM_META.instagram.color,
+        picture: account.avatar,
+        selected: false,
+        scheduledAt: '',
+        imageUrl: '',
       })
       })
-      lastResult.value = res.data
-      content.value = ''
-    } catch (err) {
-      console.error('Send error:', err)
-    } finally {
-      sending.value = false
     }
     }
+
+    destinations.value = next
+  }
+
+  function toggleDestination(key: string) {
+    const dest = destinations.value.find((d) => d.key === key)
+    if (dest) dest.selected = !dest.selected
   }
   }
 
 
-  async function schedulePost() {
-    if (!content.value.trim() || !selectedPlatforms.value.length || !scheduledAt.value) return
+  async function post() {
+    if (!content.value.trim() || !selectedDestinations.value.length) return
     sending.value = true
     sending.value = true
+    lastResult.value = null
+
     try {
     try {
-      const res = await axios.post('/scheduler/schedule', {
-        content: content.value,
-        platforms: selectedPlatforms.value,
-        scheduledAt: scheduledAt.value,
-      })
-      lastResult.value = res.data
+      const immediate = selectedDestinations.value.filter((d) => !d.scheduledAt)
+      const scheduled = selectedDestinations.value.filter((d) => !!d.scheduledAt)
+
+      const calls: Promise<unknown>[] = []
+
+      if (immediate.length) {
+        calls.push(
+          axios.post('/api/post', {
+            content: content.value,
+            destinations: immediate.map(({ platform, accountId, imageUrl }) => ({
+              platform,
+              ...(accountId && { accountId }),
+              ...(imageUrl && { imageUrl }),
+            })),
+          })
+        )
+      }
+
+      // Each unique scheduledAt time gets its own scheduler call
+      const byTime = new Map<string, Destination[]>()
+      for (const dest of scheduled) {
+        const existing = byTime.get(dest.scheduledAt) || []
+        existing.push(dest)
+        byTime.set(dest.scheduledAt, existing)
+      }
+
+      for (const [scheduledAt, dests] of byTime) {
+        calls.push(
+          axios.post('/scheduler/schedule', {
+            content: content.value,
+            scheduledAt,
+            destinations: dests.map(({ platform, accountId, imageUrl }) => ({
+              platform,
+              ...(accountId && { accountId }),
+              ...(imageUrl && { imageUrl }),
+            })),
+          })
+        )
+      }
+
+      const results = await Promise.allSettled(calls)
+      lastResult.value = { ok: results.every((r) => r.status === 'fulfilled') }
       content.value = ''
       content.value = ''
-      scheduledAt.value = ''
+      destinations.value.forEach((d) => {
+        d.selected = false
+        d.scheduledAt = ''
+        if (d.imageUrl !== undefined) d.imageUrl = ''
+      })
     } catch (err) {
     } catch (err) {
-      console.error('Schedule error:', err)
+      console.error('Compose post error:', err)
     } finally {
     } finally {
       sending.value = false
       sending.value = false
     }
     }
   }
   }
 
 
   return {
   return {
-    content, selectedPlatforms, scheduledAt, sending, lastResult,
-    charLimit, charCount, isOverLimit, togglePlatform, sendNow, schedulePost,
+    content, destinations, sending, lastResult,
+    selectedDestinations, hasImmediateDestinations, hasScheduledDestinations,
+    charLimit, charCount, isOverLimit,
+    initDestinations, toggleDestination, post,
   }
   }
 })
 })

+ 29 - 2
ui/src/stores/feed.ts

@@ -1,6 +1,7 @@
 import { defineStore } from 'pinia'
 import { defineStore } from 'pinia'
 import { ref, computed } from 'vue'
 import { ref, computed } from 'vue'
 import axios from 'axios'
 import axios from 'axios'
+import { usePlatformsStore } from './platforms'
 
 
 export interface FeedItem {
 export interface FeedItem {
   _id?: string
   _id?: string
@@ -21,13 +22,29 @@ export const useFeedStore = defineStore('feed', () => {
   const items = ref<FeedItem[]>([])
   const items = ref<FeedItem[]>([])
   const loading = ref(false)
   const loading = ref(false)
   const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin', 'instagram', 'facebook']))
   const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin', 'instagram', 'facebook']))
+  const activePageIds = ref<Set<string>>(new Set())
+  const activeIgAccountIds = ref<Set<string>>(new Set())
   const activeTag = ref<string | null>(null)
   const activeTag = ref<string | null>(null)
   const searchQuery = ref('')
   const searchQuery = ref('')
 
 
   const filteredItems = computed(() => {
   const filteredItems = computed(() => {
+    const platformsStore = usePlatformsStore()
     return items.value.filter((item) => {
     return items.value.filter((item) => {
       if (!activePlatforms.value.has(item.platform)) return false
       if (!activePlatforms.value.has(item.platform)) return false
       if (activeTag.value && !item.tags.includes(activeTag.value)) return false
       if (activeTag.value && !item.tags.includes(activeTag.value)) return false
+
+      // Facebook page sub-filter (only when specific pages are selected)
+      if (item.platform === 'facebook' && activePageIds.value.size > 0) {
+        const activePages = platformsStore.connectedPages.filter((p) => activePageIds.value.has(p.id))
+        if (!activePages.some((p) => p.name === item.author.username || p.name === item.author.name)) return false
+      }
+
+      // Instagram account sub-filter (only when specific accounts are selected)
+      if (item.platform === 'instagram' && activeIgAccountIds.value.size > 0) {
+        const activeAccounts = platformsStore.connectedIgAccounts.filter((a) => activeIgAccountIds.value.has(a.id))
+        if (!activeAccounts.some((a) => a.username === item.author.username)) return false
+      }
+
       if (searchQuery.value) {
       if (searchQuery.value) {
         const q = searchQuery.value.toLowerCase()
         const q = searchQuery.value.toLowerCase()
         return (
         return (
@@ -78,8 +95,18 @@ export const useFeedStore = defineStore('feed', () => {
     }
     }
   }
   }
 
 
+  function togglePage(pageId: string) {
+    if (activePageIds.value.has(pageId)) activePageIds.value.delete(pageId)
+    else activePageIds.value.add(pageId)
+  }
+
+  function toggleIgAccount(accountId: string) {
+    if (activeIgAccountIds.value.has(accountId)) activeIgAccountIds.value.delete(accountId)
+    else activeIgAccountIds.value.add(accountId)
+  }
+
   return {
   return {
-    items, loading, activePlatforms, activeTag, searchQuery,
-    filteredItems, fetchFeeds, refreshFeeds, addItem, togglePlatform,
+    items, loading, activePlatforms, activePageIds, activeIgAccountIds, activeTag, searchQuery,
+    filteredItems, fetchFeeds, refreshFeeds, addItem, togglePlatform, togglePage, toggleIgAccount,
   }
   }
 })
 })

+ 215 - 68
ui/src/views/Compose.vue

@@ -1,85 +1,197 @@
 <template>
 <template>
   <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
   <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
     <div class="max-w-2xl mx-auto">
     <div class="max-w-2xl mx-auto">
-      <h1 class="text-2xl font-bold mb-6">{{ $t('compose.title') }}</h1>
-
-      <!-- Platform seçimi -->
-      <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
-        <p class="text-sm text-gray-400 mb-3">{{ $t('compose.platformsLabel') }}</p>
-        <div class="flex flex-wrap gap-2">
-          <button
-            v-for="(meta, key) in PLATFORM_META"
-            :key="key"
-            @click="composeStore.togglePlatform(key)"
-            class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all border"
-            :class="composeStore.selectedPlatforms.includes(key)
-              ? 'text-white border-transparent'
-              : 'text-gray-500 border-gray-700 hover:border-gray-500'"
-            :style="composeStore.selectedPlatforms.includes(key)
-              ? { backgroundColor: meta.color, borderColor: meta.color }
-              : {}"
-          >
-            {{ $t(`platforms.${key}`) }}
-            <span v-if="composeStore.selectedPlatforms.includes(key)" class="text-xs opacity-75">
-              {{ composeStore.charCount(key) }}/{{ composeStore.charLimit(key) }}
-            </span>
-          </button>
-        </div>
+      <div class="flex items-center justify-between mb-6">
+        <h1 class="text-2xl font-bold">{{ $t('compose.title') }}</h1>
+        <router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">
+          {{ $t('compose.cancel') }}
+        </router-link>
       </div>
       </div>
 
 
-      <!-- Editör -->
+      <!-- Content editor -->
       <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
       <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
         <textarea
         <textarea
           v-model="composeStore.content"
           v-model="composeStore.content"
           :placeholder="$t('compose.placeholder')"
           :placeholder="$t('compose.placeholder')"
-          rows="6"
+          rows="5"
           class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed"
           class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed"
         ></textarea>
         ></textarea>
+      </div>
 
 
-        <div v-if="composeStore.selectedPlatforms.length" class="flex flex-wrap gap-3 pt-3 border-t border-gray-800 mt-2">
-          <span
-            v-for="p in composeStore.selectedPlatforms"
-            :key="p"
-            class="text-xs"
-            :class="composeStore.isOverLimit(p) ? 'text-red-400' : 'text-gray-500'"
+      <!-- Destinations -->
+      <div class="bg-gray-900 border border-gray-800 rounded-xl mb-4 overflow-hidden">
+        <div class="px-4 py-3 border-b border-gray-800">
+          <p class="text-sm font-medium text-gray-300">{{ $t('compose.destinationsLabel') }}</p>
+        </div>
+
+        <!-- Standard platforms -->
+        <div v-if="standardDestinations.length" class="divide-y divide-gray-800/60">
+          <div
+            v-for="dest in standardDestinations"
+            :key="dest.key"
+            class="px-4 py-3"
           >
           >
-            {{ $t(`platforms.${p}`) }}: {{ composeStore.charCount(p) }}/{{ composeStore.charLimit(p) }}
-          </span>
+            <div class="flex items-center gap-3">
+              <!-- Toggle -->
+              <button
+                @click="composeStore.toggleDestination(dest.key)"
+                class="w-5 h-5 rounded border flex-shrink-0 flex items-center justify-center transition-colors"
+                :style="dest.selected
+                  ? { backgroundColor: dest.color, borderColor: dest.color }
+                  : { borderColor: '#4b5563' }"
+              >
+                <svg v-if="dest.selected" class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
+                  <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
+                </svg>
+              </button>
+
+              <!-- Label + char count -->
+              <span
+                class="flex-1 text-sm font-medium"
+                :style="dest.selected ? { color: dest.color } : { color: '#9ca3af' }"
+              >{{ dest.label }}</span>
+              <span
+                v-if="dest.selected && composeStore.charLimit(dest.platform) < 9999"
+                class="text-xs flex-shrink-0"
+                :class="composeStore.isOverLimit(dest.platform) ? 'text-red-400' : 'text-gray-600'"
+              >
+                {{ composeStore.charCount() }}/{{ composeStore.charLimit(dest.platform) }}
+              </span>
+
+              <!-- Per-destination schedule -->
+              <input
+                v-if="dest.selected"
+                v-model="dest.scheduledAt"
+                type="datetime-local"
+                class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-gray-300 focus:outline-none focus:border-blue-500 flex-shrink-0"
+                :title="$t('compose.scheduleTitle')"
+              />
+            </div>
+          </div>
+        </div>
+
+        <!-- Facebook Pages section -->
+        <template v-if="facebookDestinations.length">
+          <div class="px-4 py-2 bg-gray-800/40 border-t border-gray-800/60">
+            <p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ $t('compose.facebookPages') }}</p>
+          </div>
+          <div class="divide-y divide-gray-800/60">
+            <div
+              v-for="dest in facebookDestinations"
+              :key="dest.key"
+              class="px-4 py-3"
+            >
+              <div class="flex items-center gap-3">
+                <button
+                  @click="composeStore.toggleDestination(dest.key)"
+                  class="w-5 h-5 rounded border flex-shrink-0 flex items-center justify-center transition-colors"
+                  :style="dest.selected
+                    ? { backgroundColor: dest.color, borderColor: dest.color }
+                    : { borderColor: '#4b5563' }"
+                >
+                  <svg v-if="dest.selected" class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
+                  </svg>
+                </button>
+
+                <img v-if="dest.picture" :src="dest.picture" class="w-6 h-6 rounded-full flex-shrink-0 object-cover" />
+                <span v-else class="w-6 h-6 rounded-full flex-shrink-0 flex items-center justify-center text-white text-xs font-bold" style="background:#1877F2">f</span>
+
+                <span class="flex-1 text-sm" :class="dest.selected ? 'text-white' : 'text-gray-400'">{{ dest.label }}</span>
+
+                <input
+                  v-if="dest.selected"
+                  v-model="dest.scheduledAt"
+                  type="datetime-local"
+                  class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-gray-300 focus:outline-none focus:border-blue-500 flex-shrink-0"
+                  :title="$t('compose.scheduleTitle')"
+                />
+              </div>
+            </div>
+          </div>
+        </template>
+
+        <!-- Instagram Accounts section -->
+        <template v-if="instagramDestinations.length">
+          <div class="px-4 py-2 bg-gray-800/40 border-t border-gray-800/60">
+            <p class="text-xs font-semibold text-gray-500 uppercase tracking-wider">{{ $t('compose.instagramAccounts') }}</p>
+          </div>
+          <div class="divide-y divide-gray-800/60">
+            <div
+              v-for="dest in instagramDestinations"
+              :key="dest.key"
+              class="px-4 py-3"
+            >
+              <div class="flex items-center gap-3">
+                <button
+                  @click="composeStore.toggleDestination(dest.key)"
+                  class="w-5 h-5 rounded border flex-shrink-0 flex items-center justify-center transition-colors"
+                  :style="dest.selected
+                    ? { backgroundColor: dest.color, borderColor: dest.color }
+                    : { borderColor: '#4b5563' }"
+                >
+                  <svg v-if="dest.selected" class="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
+                    <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
+                  </svg>
+                </button>
+
+                <img v-if="dest.picture" :src="dest.picture" class="w-6 h-6 rounded-full flex-shrink-0 object-cover" />
+                <span v-else class="w-6 h-6 rounded-full flex-shrink-0 flex items-center justify-center text-white text-xs font-bold" style="background:#E1306C">I</span>
+
+                <span class="flex-1 text-sm" :class="dest.selected ? 'text-white' : 'text-gray-400'">{{ dest.label }}</span>
+
+                <input
+                  v-if="dest.selected"
+                  v-model="dest.scheduledAt"
+                  type="datetime-local"
+                  class="bg-gray-800 border border-gray-700 rounded-lg px-2 py-1 text-xs text-gray-300 focus:outline-none focus:border-blue-500 flex-shrink-0"
+                  :title="$t('compose.scheduleTitle')"
+                />
+              </div>
+
+              <!-- Instagram image URL (required) -->
+              <div v-if="dest.selected" class="mt-2 ml-8">
+                <input
+                  v-model="dest.imageUrl"
+                  type="url"
+                  :placeholder="$t('compose.igImagePlaceholder')"
+                  class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-xs text-gray-300 placeholder-gray-600 focus:outline-none focus:border-pink-500"
+                />
+              </div>
+            </div>
+          </div>
+        </template>
+
+        <!-- Empty state: no destinations configured -->
+        <div
+          v-if="!standardDestinations.length && !facebookDestinations.length && !instagramDestinations.length"
+          class="px-4 py-6 text-center text-gray-600 text-sm"
+        >
+          {{ $t('compose.noDestinations') }}
+          <router-link to="/settings" class="text-blue-400 hover:text-blue-300 ml-1">{{ $t('compose.goToSettings') }}</router-link>
         </div>
         </div>
       </div>
       </div>
 
 
-      <!-- Zamanlama -->
-      <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-6">
-        <p class="text-sm text-gray-400 mb-2">{{ $t('compose.schedulingLabel') }}</p>
-        <input
-          v-model="composeStore.scheduledAt"
-          type="datetime-local"
-          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-blue-500"
-        />
+      <!-- Instagram warning: image required -->
+      <div
+        v-if="igWithoutImage.length"
+        class="mb-4 bg-amber-900/30 border border-amber-700/50 rounded-xl px-4 py-3 text-xs text-amber-300"
+      >
+        {{ $t('compose.igImageRequired', { accounts: igWithoutImage.map((d) => d.label).join(', ') }) }}
       </div>
       </div>
 
 
-      <!-- Butonlar -->
-      <div class="flex gap-3 justify-end">
-        <router-link to="/dashboard" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">
-          {{ $t('compose.cancel') }}
-        </router-link>
-        <button
-          v-if="composeStore.scheduledAt"
-          @click="handleSchedule"
-          :disabled="composeStore.sending || !composeStore.content.trim()"
-          class="px-5 py-2 bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
-        >
-          {{ composeStore.sending ? $t('compose.scheduling') : `⏰ ${$t('compose.schedule')}` }}
-        </button>
+      <!-- Action button -->
+      <div class="flex justify-end">
         <button
         <button
-          @click="handleSend"
-          :disabled="composeStore.sending || !composeStore.content.trim() || !composeStore.selectedPlatforms.length"
-          class="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+          @click="handlePost"
+          :disabled="composeStore.sending || !canPost"
+          class="px-6 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
         >
         >
-          {{ composeStore.sending ? $t('compose.sending') : $t('compose.send') }}
+          {{ composeStore.sending ? $t('compose.sending') : postButtonLabel }}
         </button>
         </button>
       </div>
       </div>
 
 
+      <!-- Success -->
       <div v-if="composeStore.lastResult" class="mt-4 bg-green-900/30 border border-green-700 rounded-xl p-4 text-sm text-green-300">
       <div v-if="composeStore.lastResult" class="mt-4 bg-green-900/30 border border-green-700 rounded-xl p-4 text-sm text-green-300">
         {{ $t('compose.successMessage') }}
         {{ $t('compose.successMessage') }}
       </div>
       </div>
@@ -88,20 +200,55 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { useComposeStore } from '../stores/compose'
-import { PLATFORM_META } from '../stores/platforms'
+import { computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { useComposeStore } from '../stores/compose'
+import { usePlatformsStore } from '../stores/platforms'
 
 
+const { t } = useI18n()
 const composeStore = useComposeStore()
 const composeStore = useComposeStore()
+const platformsStore = usePlatformsStore()
 const router = useRouter()
 const router = useRouter()
 
 
-async function handleSend() {
-  await composeStore.sendNow()
-  if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
-}
+onMounted(async () => {
+  await Promise.all([
+    platformsStore.fetchStatuses(),
+    platformsStore.fetchMetaConnections(),
+  ])
+  composeStore.initDestinations()
+})
+
+const standardDestinations = computed(() =>
+  composeStore.destinations.filter((d) => !d.accountId)
+)
+const facebookDestinations = computed(() =>
+  composeStore.destinations.filter((d) => d.platform === 'facebook' && d.accountId)
+)
+const instagramDestinations = computed(() =>
+  composeStore.destinations.filter((d) => d.platform === 'instagram' && d.accountId)
+)
 
 
-async function handleSchedule() {
-  await composeStore.schedulePost()
-  if (composeStore.lastResult) setTimeout(() => router.push('/scheduler'), 1500)
+// Instagram accounts that are selected but missing an imageUrl
+const igWithoutImage = computed(() =>
+  instagramDestinations.value.filter((d) => d.selected && !d.imageUrl?.trim())
+)
+
+const canPost = computed(() =>
+  !!composeStore.content.trim() &&
+  composeStore.selectedDestinations.length > 0 &&
+  igWithoutImage.value.length === 0
+)
+
+const postButtonLabel = computed(() => {
+  const { hasImmediateDestinations, hasScheduledDestinations } = composeStore
+  if (hasImmediateDestinations && hasScheduledDestinations) return t('compose.postAndSchedule')
+  if (hasScheduledDestinations) return `⏰ ${t('compose.schedule')}`
+  return t('compose.send')
+})
+
+async function handlePost() {
+  await composeStore.post()
+  if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
 }
 }
 </script>
 </script>

+ 18 - 8
ui/src/views/Dashboard.vue

@@ -28,10 +28,14 @@
 
 
           <!-- Facebook sub-pages -->
           <!-- Facebook sub-pages -->
           <template v-if="key === 'facebook' && platformsStore.connectedPages.length">
           <template v-if="key === 'facebook' && platformsStore.connectedPages.length">
-            <div
+            <button
               v-for="page in platformsStore.connectedPages"
               v-for="page in platformsStore.connectedPages"
               :key="page.id"
               :key="page.id"
-              class="flex items-center gap-2 pl-6 pr-2 py-1 mb-0.5"
+              @click="feedStore.togglePage(page.id)"
+              class="flex items-center gap-2 w-full pl-6 pr-2 py-1 mb-0.5 rounded-lg text-left transition-colors"
+              :class="feedStore.activePageIds.has(page.id)
+                ? 'bg-blue-900/30 text-white'
+                : 'text-gray-500 hover:bg-gray-800/60 hover:text-gray-300'"
             >
             >
               <img v-if="page.picture" :src="page.picture" class="w-3.5 h-3.5 rounded-full flex-shrink-0" />
               <img v-if="page.picture" :src="page.picture" class="w-3.5 h-3.5 rounded-full flex-shrink-0" />
               <span
               <span
@@ -39,16 +43,21 @@
                 class="w-3.5 h-3.5 rounded-full flex-shrink-0 flex items-center justify-center text-white"
                 class="w-3.5 h-3.5 rounded-full flex-shrink-0 flex items-center justify-center text-white"
                 style="background:#1877F2; font-size:8px"
                 style="background:#1877F2; font-size:8px"
               >f</span>
               >f</span>
-              <span class="text-xs text-gray-500 truncate">{{ page.name }}</span>
-            </div>
+              <span class="text-xs truncate flex-1">{{ page.name }}</span>
+              <span v-if="feedStore.activePageIds.has(page.id)" class="w-1.5 h-1.5 rounded-full bg-blue-400 flex-shrink-0"></span>
+            </button>
           </template>
           </template>
 
 
           <!-- Instagram sub-accounts -->
           <!-- Instagram sub-accounts -->
           <template v-if="key === 'instagram' && platformsStore.connectedIgAccounts.length">
           <template v-if="key === 'instagram' && platformsStore.connectedIgAccounts.length">
-            <div
+            <button
               v-for="account in platformsStore.connectedIgAccounts"
               v-for="account in platformsStore.connectedIgAccounts"
               :key="account.id"
               :key="account.id"
-              class="flex items-center gap-2 pl-6 pr-2 py-1 mb-0.5"
+              @click="feedStore.toggleIgAccount(account.id)"
+              class="flex items-center gap-2 w-full pl-6 pr-2 py-1 mb-0.5 rounded-lg text-left transition-colors"
+              :class="feedStore.activeIgAccountIds.has(account.id)
+                ? 'bg-pink-900/30 text-white'
+                : 'text-gray-500 hover:bg-gray-800/60 hover:text-gray-300'"
             >
             >
               <img v-if="account.avatar" :src="account.avatar" class="w-3.5 h-3.5 rounded-full flex-shrink-0" />
               <img v-if="account.avatar" :src="account.avatar" class="w-3.5 h-3.5 rounded-full flex-shrink-0" />
               <span
               <span
@@ -56,8 +65,9 @@
                 class="w-3.5 h-3.5 rounded-full flex-shrink-0 flex items-center justify-center text-white"
                 class="w-3.5 h-3.5 rounded-full flex-shrink-0 flex items-center justify-center text-white"
                 style="background:#E1306C; font-size:8px"
                 style="background:#E1306C; font-size:8px"
               >I</span>
               >I</span>
-              <span class="text-xs text-gray-500 truncate">@{{ account.username }}</span>
-            </div>
+              <span class="text-xs truncate flex-1">@{{ account.username }}</span>
+              <span v-if="feedStore.activeIgAccountIds.has(account.id)" class="w-1.5 h-1.5 rounded-full bg-pink-400 flex-shrink-0"></span>
+            </button>
           </template>
           </template>
         </template>
         </template>
       </div>
       </div>