Explorar o código

New Posts with Image & Preview + Image/Video

Benjamin Harris hai 1 mes
pai
achega
23bff2c190

+ 1 - 0
.gitignore

@@ -1,3 +1,4 @@
+CLAUDE.md
 .env
 
 # Logs

+ 143 - 0
ui/src/components/compose/PostPreview.vue

@@ -0,0 +1,143 @@
+<template>
+  <div class="h-full flex flex-col">
+    <!-- Preview tab strip -->
+    <div v-if="selectedDestinations.length > 1" class="flex gap-1 mb-3 flex-wrap">
+      <button
+        v-for="dest in selectedDestinations"
+        :key="dest.key"
+        @click="$emit('update:activeKey', dest.key)"
+        class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors"
+        :class="activeKey === dest.key ? 'text-white' : 'text-gray-500 hover:text-gray-300 bg-transparent'"
+        :style="activeKey === dest.key ? { backgroundColor: dest.color } : {}"
+      >
+        <img v-if="dest.picture" :src="dest.picture" class="w-4 h-4 rounded-full object-cover" />
+        <span v-else class="w-4 h-4 rounded-full flex items-center justify-center text-white font-bold" style="font-size:9px" :style="{ backgroundColor: dest.color }">
+          {{ dest.label[0] }}
+        </span>
+        {{ dest.label }}
+      </button>
+    </div>
+
+    <!-- No destination selected -->
+    <div v-if="!activeDest" class="flex-1 flex flex-col items-center justify-center text-gray-600 text-sm gap-2">
+      <span class="text-3xl">👁</span>
+      <p>Select an account to see a preview</p>
+    </div>
+
+    <!-- Facebook preview -->
+    <div v-else-if="activeDest.platform === 'facebook'" class="bg-white rounded-xl shadow overflow-hidden text-gray-900">
+      <div class="flex items-center gap-2 p-3">
+        <img v-if="activeDest.picture" :src="activeDest.picture" class="w-9 h-9 rounded-full object-cover flex-shrink-0" />
+        <div v-else class="w-9 h-9 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-sm" style="background:#1877F2">f</div>
+        <div>
+          <p class="font-semibold text-sm leading-tight">{{ activeDest.label }}</p>
+          <p class="text-xs text-gray-400 leading-tight">Just now · <span class="text-gray-400">🌐</span></p>
+        </div>
+      </div>
+      <p v-if="content" class="px-3 pb-3 text-sm whitespace-pre-wrap">{{ content }}</p>
+      <p v-else class="px-3 pb-3 text-sm text-gray-300 italic">Start writing to see preview…</p>
+      <img v-if="mediaUrl" :src="mediaUrl" class="w-full object-cover max-h-72" @error="imgError = true" />
+      <div class="border-t border-gray-100 mx-3"></div>
+      <div class="flex px-3 py-2 gap-1 text-gray-500 text-xs">
+        <button class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded hover:bg-gray-50 font-medium">👍 Like</button>
+        <button class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded hover:bg-gray-50 font-medium">💬 Comment</button>
+        <button class="flex-1 flex items-center justify-center gap-1 py-1.5 rounded hover:bg-gray-50 font-medium">↗ Share</button>
+      </div>
+    </div>
+
+    <!-- Instagram preview -->
+    <div v-else-if="activeDest.platform === 'instagram'" class="bg-white rounded-xl shadow overflow-hidden text-gray-900">
+      <div class="flex items-center gap-2 p-2.5">
+        <img v-if="activeDest.picture" :src="activeDest.picture" class="w-8 h-8 rounded-full object-cover flex-shrink-0 ring-2 ring-pink-400 ring-offset-1" />
+        <div v-else class="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold" style="background:#E1306C">I</div>
+        <span class="font-semibold text-sm">{{ activeDest.label.replace('@','') }}</span>
+        <span class="ml-auto text-gray-400 text-xl leading-none">···</span>
+      </div>
+      <div class="w-full aspect-square bg-gray-100 flex items-center justify-center overflow-hidden">
+        <img v-if="mediaUrl" :src="mediaUrl" class="w-full h-full object-cover" />
+        <div v-else class="flex flex-col items-center gap-2 text-gray-400">
+          <svg class="w-10 h-10" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
+          <p class="text-xs">Add an image URL</p>
+        </div>
+      </div>
+      <div class="flex items-center gap-3 px-3 py-2 text-gray-700">
+        <span class="text-lg">❤️</span><span class="text-lg">💬</span><span class="text-lg">↗</span>
+        <span class="ml-auto text-lg">🔖</span>
+      </div>
+      <div class="px-3 pb-3 text-sm">
+        <span class="font-semibold">{{ activeDest.label.replace('@','') }}</span>
+        <span class="ml-1 text-gray-700">{{ content || '' }}</span>
+      </div>
+    </div>
+
+    <!-- Twitter/X preview -->
+    <div v-else-if="activeDest.platform === 'twitter'" class="bg-white rounded-xl shadow overflow-hidden text-gray-900 p-4">
+      <div class="flex gap-3">
+        <div class="w-10 h-10 rounded-full bg-black flex items-center justify-center text-white font-bold flex-shrink-0">X</div>
+        <div class="flex-1 min-w-0">
+          <div class="flex items-center gap-1 flex-wrap">
+            <span class="font-bold text-sm">Twitter / X</span>
+            <svg class="w-4 h-4 text-blue-400" viewBox="0 0 24 24" fill="currentColor"><path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
+            <span class="text-gray-400 text-sm">· Just now</span>
+          </div>
+          <p class="text-sm mt-1 whitespace-pre-wrap">{{ content || 'Start writing…' }}</p>
+          <img v-if="mediaUrl" :src="mediaUrl" class="mt-2 rounded-2xl w-full object-cover max-h-52 border border-gray-100" />
+          <div class="flex gap-6 text-gray-500 text-sm mt-3">
+            <span>💬</span><span>↺</span><span>❤</span><span>↗</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- LinkedIn preview -->
+    <div v-else-if="activeDest.platform === 'linkedin'" class="bg-white rounded-xl shadow overflow-hidden text-gray-900 p-4">
+      <div class="flex items-center gap-2 mb-3">
+        <div class="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-sm flex-shrink-0">in</div>
+        <div>
+          <p class="font-semibold text-sm leading-tight">Your Name</p>
+          <p class="text-xs text-gray-400">Just now · 🌐</p>
+        </div>
+      </div>
+      <p class="text-sm whitespace-pre-wrap mb-3">{{ content || 'Start writing…' }}</p>
+      <img v-if="mediaUrl" :src="mediaUrl" class="w-full object-cover rounded max-h-64 mb-3" />
+      <div class="border-t border-gray-100 pt-2 flex gap-4 text-gray-500 text-xs">
+        <span>👍 Like</span><span>💬 Comment</span><span>↗ Share</span>
+      </div>
+    </div>
+
+    <!-- Generic preview -->
+    <div v-else class="bg-gray-900 rounded-xl shadow overflow-hidden border border-gray-800 p-4">
+      <div class="flex items-center gap-2 mb-3">
+        <div class="w-8 h-8 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0" :style="{ backgroundColor: activeDest.color }">
+          {{ activeDest.label[0] }}
+        </div>
+        <div>
+          <p class="font-semibold text-sm text-white leading-tight">{{ activeDest.label }}</p>
+          <p class="text-xs text-gray-500">Just now</p>
+        </div>
+      </div>
+      <p class="text-sm text-gray-300 whitespace-pre-wrap">{{ content || 'Start writing…' }}</p>
+      <img v-if="mediaUrl" :src="mediaUrl" class="w-full object-cover rounded-lg mt-3 max-h-52" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import type { Destination } from '../../stores/compose'
+
+const props = defineProps<{
+  selectedDestinations: Destination[]
+  activeKey: string
+  content: string
+  mediaUrl: string
+}>()
+
+defineEmits<{ (e: 'update:activeKey', key: string): void }>()
+
+const imgError = ref(false)
+
+const activeDest = computed(() =>
+  props.selectedDestinations.find((d) => d.key === props.activeKey) ?? props.selectedDestinations[0] ?? null
+)
+</script>

+ 6 - 8
ui/src/locales/en.ts

@@ -25,16 +25,14 @@ export default {
     placeholder: "What's on your mind?",
     cancel: 'Cancel',
     schedule: 'Schedule',
-    scheduling: 'Scheduling...',
-    send: 'Post →',
-    postAndSchedule: 'Post & Schedule',
+    send: 'Post Now →',
     sending: 'Posting...',
     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}',
+    scheduleTitle: 'Schedule post (leave empty to post now)',
+    preview: 'Preview',
+    addMedia: 'Add image / video URL',
+    mediaUrlPlaceholder: 'Paste image or video URL, then press Enter…',
+    igImageRequired: 'Instagram requires an image or video URL.',
     noDestinations: 'No platforms configured.',
     goToSettings: 'Go to Settings →',
   },

+ 6 - 8
ui/src/locales/tr.ts

@@ -25,16 +25,14 @@ export default {
     placeholder: 'Ne düşünüyorsun?',
     cancel: 'İptal',
     schedule: 'Zamanla',
-    scheduling: 'Zamanlanıyor...',
-    send: 'Gönder →',
-    postAndSchedule: 'Gönder & Zamanla',
+    send: 'Şimdi Gönder →',
     sending: 'Gönderiliyor...',
     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}',
+    scheduleTitle: 'Zamanlama (boş bırakırsan hemen gönderilir)',
+    preview: 'Önizleme',
+    addMedia: 'Görsel / video URL\'si ekle',
+    mediaUrlPlaceholder: 'Görsel veya video URL\'sini yapıştır, Enter\'a bas…',
+    igImageRequired: 'Instagram için görsel veya video URL\'si zorunludur.',
     noDestinations: 'Hiçbir platform yapılandırılmamış.',
     goToSettings: 'Ayarlara git →',
   },

+ 43 - 78
ui/src/stores/compose.ts

@@ -11,8 +11,6 @@ export interface Destination {
   color: string
   picture?: string
   selected: boolean
-  scheduledAt: string  // empty string = post immediately
-  imageUrl?: string    // instagram only
 }
 
 const CHAR_LIMITS: Record<string, number> = {
@@ -27,6 +25,8 @@ const STANDARD_PLATFORMS = ['twitter', 'mastodon', 'bluesky', 'linkedin', 'reddi
 
 export const useComposeStore = defineStore('compose', () => {
   const content = ref('')
+  const mediaUrl = ref('')
+  const scheduledAt = ref('')
   const destinations = ref<Destination[]>([])
   const sending = ref(false)
   const lastResult = ref<Record<string, unknown> | null>(null)
@@ -35,43 +35,30 @@ export const useComposeStore = defineStore('compose', () => {
     return CHAR_LIMITS[platform] ?? 9999
   }
 
-  function charCount(): number {
-    return content.value.length
-  }
-
   function isOverLimit(platform: string): boolean {
     return content.value.length > charLimit(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)
-  )
+  // Most restrictive char limit among selected platforms that have a defined limit
+  const activeCharLimit = computed(() => {
+    const limits = selectedDestinations.value
+      .map((d) => CHAR_LIMITS[d.platform])
+      .filter((l): l is number => l !== undefined)
+    return limits.length ? Math.min(...limits) : null
+  })
 
   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: '',
-      })
+      next.push({ key: p, platform: p, label: meta.label, color: meta.color, selected: false })
     }
 
-    // Facebook pages
     for (const page of platformsStore.connectedPages) {
       next.push({
         key: `facebook:${page.id}`,
@@ -81,11 +68,9 @@ export const useComposeStore = defineStore('compose', () => {
         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}`,
@@ -95,8 +80,6 @@ export const useComposeStore = defineStore('compose', () => {
         color: PLATFORM_META.instagram.color,
         picture: account.avatar,
         selected: false,
-        scheduledAt: '',
-        imageUrl: '',
       })
     }
 
@@ -108,60 +91,42 @@ export const useComposeStore = defineStore('compose', () => {
     if (dest) dest.selected = !dest.selected
   }
 
+  function reset() {
+    content.value = ''
+    mediaUrl.value = ''
+    scheduledAt.value = ''
+    destinations.value.forEach((d) => { d.selected = false })
+    lastResult.value = null
+  }
+
   async function post() {
-    if (!content.value.trim() || !selectedDestinations.value.length) return
+    const selected = selectedDestinations.value
+    if (!content.value.trim() || !selected.length) return
     sending.value = true
     lastResult.value = null
 
     try {
-      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)
+      const destPayload = selected.map(({ platform, accountId }) => ({
+        platform,
+        ...(accountId && { accountId }),
+        ...(mediaUrl.value.trim() && { imageUrl: mediaUrl.value.trim() }),
+      }))
+
+      if (scheduledAt.value) {
+        await axios.post('/scheduler/schedule', {
+          content: content.value,
+          scheduledAt: scheduledAt.value,
+          destinations: destPayload,
+        })
+      } else {
+        await axios.post('/api/post', {
+          content: content.value,
+          destinations: destPayload,
+        })
       }
 
-      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 = ''
-      destinations.value.forEach((d) => {
-        d.selected = false
-        d.scheduledAt = ''
-        if (d.imageUrl !== undefined) d.imageUrl = ''
-      })
+      lastResult.value = { ok: true }
+      reset()
     } catch (err) {
       console.error('Compose post error:', err)
     } finally {
@@ -170,9 +135,9 @@ export const useComposeStore = defineStore('compose', () => {
   }
 
   return {
-    content, destinations, sending, lastResult,
-    selectedDestinations, hasImmediateDestinations, hasScheduledDestinations,
-    charLimit, charCount, isOverLimit,
-    initDestinations, toggleDestination, post,
+    content, mediaUrl, scheduledAt, destinations, sending, lastResult,
+    selectedDestinations, activeCharLimit,
+    charLimit, isOverLimit,
+    initDestinations, toggleDestination, reset, post,
   }
 })

+ 206 - 187
ui/src/views/Compose.vue

@@ -1,216 +1,199 @@
 <template>
-  <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
-    <div class="max-w-2xl mx-auto">
-      <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 class="flex h-screen overflow-hidden bg-gray-950 text-gray-100">
 
-      <!-- Content editor -->
-      <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
-        <textarea
-          v-model="composeStore.content"
-          :placeholder="$t('compose.placeholder')"
-          rows="5"
-          class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed"
-        ></textarea>
-      </div>
+    <!-- ── Left panel: editor ── -->
+    <div class="flex-1 flex flex-col min-w-0 overflow-y-auto p-6">
+
+      <div class="max-w-2xl w-full mx-auto flex flex-col gap-4">
 
-      <!-- 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>
+        <!-- Header -->
+        <div class="flex items-center justify-between">
+          <h1 class="text-xl 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>
 
-        <!-- 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"
-          >
-            <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' }"
+        <!-- Account selector -->
+        <div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
+          <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">{{ $t('compose.destinationsLabel') }}</p>
+
+          <div v-if="composeStore.destinations.length" class="flex flex-wrap gap-3">
+            <button
+              v-for="dest in composeStore.destinations"
+              :key="dest.key"
+              @click="toggle(dest.key)"
+              :title="dest.label"
+              class="relative focus:outline-none transition-all duration-150"
+              :class="dest.selected ? 'opacity-100' : 'opacity-40 hover:opacity-70 grayscale hover:grayscale-0'"
+            >
+              <!-- Avatar circle -->
+              <div
+                class="w-12 h-12 rounded-full overflow-hidden flex items-center justify-center text-white font-bold text-base ring-2 ring-offset-2 ring-offset-gray-950 transition-all"
+                :style="dest.selected ? { ringColor: dest.color } : {}"
+                :class="dest.selected ? 'ring-white' : 'ring-transparent'"
               >
-                <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-full h-full object-cover" />
+                <span v-else class="w-full h-full flex items-center justify-center font-bold text-sm" :style="{ backgroundColor: dest.color }">
+                  {{ dest.label[0] }}
+                </span>
+              </div>
 
-              <!-- Label + char count -->
+              <!-- Platform badge (only for page/account destinations) -->
               <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'"
+                v-if="dest.accountId"
+                class="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full flex items-center justify-center text-white font-bold border-2 border-gray-900"
+                style="font-size:8px"
+                :style="{ backgroundColor: dest.color }"
               >
-                {{ composeStore.charCount() }}/{{ composeStore.charLimit(dest.platform) }}
+                {{ dest.platform === 'facebook' ? 'f' : 'I' }}
               </span>
+            </button>
+          </div>
+
+          <p v-else class="text-sm text-gray-600">
+            {{ $t('compose.noDestinations') }}
+            <router-link to="/settings" class="text-blue-400 hover:text-blue-300 ml-1">{{ $t('compose.goToSettings') }}</router-link>
+          </p>
+        </div>
+
+        <!-- Textarea -->
+        <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden" :class="{ 'border-red-700': overLimit }">
+          <textarea
+            v-model="composeStore.content"
+            :placeholder="$t('compose.placeholder')"
+            rows="7"
+            class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed p-4"
+          ></textarea>
 
-              <!-- 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')"
+          <!-- Media preview -->
+          <div v-if="composeStore.mediaUrl.trim()" class="px-4 pb-3">
+            <div class="relative inline-block">
+              <img
+                :src="composeStore.mediaUrl"
+                class="rounded-lg max-h-48 max-w-full object-cover border border-gray-700"
+                @error="mediaError = true"
               />
+              <button
+                @click="composeStore.mediaUrl = ''; mediaError = false"
+                class="absolute -top-2 -right-2 w-5 h-5 bg-gray-700 hover:bg-gray-600 rounded-full flex items-center justify-center text-xs"
+              >✕</button>
             </div>
+            <p v-if="mediaError" class="text-xs text-red-400 mt-1">Could not load this image URL.</p>
           </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>
+          <!-- Media URL input (shown when toolbar button clicked) -->
+          <div v-if="showMediaInput && !composeStore.mediaUrl.trim()" class="px-4 pb-3">
+            <input
+              v-model="mediaInputValue"
+              @keydown.enter="applyMedia"
+              @blur="applyMedia"
+              type="url"
+              :placeholder="$t('compose.mediaUrlPlaceholder')"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-blue-500"
+              ref="mediaInputRef"
+            />
           </div>
-          <div class="divide-y divide-gray-800/60">
-            <div
-              v-for="dest in facebookDestinations"
-              :key="dest.key"
-              class="px-4 py-3"
+
+          <!-- Toolbar -->
+          <div class="flex items-center gap-2 px-4 py-2.5 border-t border-gray-800">
+            <button
+              @click="toggleMediaInput"
+              class="text-gray-500 hover:text-gray-300 transition-colors p-1 rounded"
+              :class="showMediaInput || composeStore.mediaUrl ? 'text-blue-400' : ''"
+              :title="$t('compose.addMedia')"
             >
-              <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>
+              <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                <path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
+              </svg>
+            </button>
 
-        <!-- 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>
+            <span class="ml-auto text-xs font-mono" :class="overLimit ? 'text-red-400' : charNearLimit ? 'text-amber-400' : 'text-gray-600'">
+              {{ composeStore.content.length }}<template v-if="composeStore.activeCharLimit">/{{ composeStore.activeCharLimit }}</template>
+            </span>
           </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>
+        </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>
+        <!-- Instagram warning -->
+        <div v-if="igSelectedWithoutMedia" class="flex items-center gap-2 bg-amber-900/30 border border-amber-700/50 rounded-xl px-4 py-2.5 text-xs text-amber-300">
+          <svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
+          {{ $t('compose.igImageRequired') }}
+        </div>
+
+        <!-- Schedule + Post -->
+        <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 flex items-center gap-3 flex-wrap">
+          <div class="flex items-center gap-2 flex-1 min-w-0">
+            <svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
+            </svg>
+            <input
+              v-model="composeStore.scheduledAt"
+              type="datetime-local"
+              class="flex-1 bg-transparent text-sm text-gray-300 focus:outline-none min-w-0"
+              :title="$t('compose.scheduleTitle')"
+            />
+            <button
+              v-if="composeStore.scheduledAt"
+              @click="composeStore.scheduledAt = ''"
+              class="text-gray-600 hover:text-gray-400 text-xs flex-shrink-0"
+            >✕</button>
           </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>
+          <button
+            @click="handlePost"
+            :disabled="composeStore.sending || !canPost"
+            class="px-5 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 flex-shrink-0"
+            :class="composeStore.scheduledAt ? 'bg-amber-600 hover:bg-amber-700' : 'bg-blue-600 hover:bg-blue-700'"
+          >
+            {{ composeStore.sending ? $t('compose.sending') : postButtonLabel }}
+          </button>
         </div>
-      </div>
 
-      <!-- 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>
+        <!-- Success message -->
+        <div v-if="composeStore.lastResult" class="bg-green-900/30 border border-green-700/60 rounded-xl px-4 py-3 text-sm text-green-300">
+          {{ $t('compose.successMessage') }}
+        </div>
 
-      <!-- Action button -->
-      <div class="flex justify-end">
-        <button
-          @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') : postButtonLabel }}
-        </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">
-        {{ $t('compose.successMessage') }}
+    <!-- ── Right panel: preview ── -->
+    <div class="w-80 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col overflow-hidden">
+      <div class="px-4 py-3 border-b border-gray-800 flex-shrink-0">
+        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest">{{ $t('compose.preview') }}</p>
+      </div>
+      <div class="flex-1 overflow-y-auto p-4">
+        <PostPreview
+          :selectedDestinations="composeStore.selectedDestinations"
+          :activeKey="activePreviewKey"
+          :content="composeStore.content"
+          :mediaUrl="composeStore.mediaUrl"
+          @update:activeKey="activePreviewKey = $event"
+        />
       </div>
     </div>
+
   </div>
 </template>
 
 <script setup lang="ts">
-import { computed, onMounted } from 'vue'
+import { ref, computed, watch, nextTick, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import { useComposeStore } from '../stores/compose'
 import { usePlatformsStore } from '../stores/platforms'
+import PostPreview from '../components/compose/PostPreview.vue'
 
 const { t } = useI18n()
 const composeStore = useComposeStore()
 const platformsStore = usePlatformsStore()
 const router = useRouter()
 
+const showMediaInput = ref(false)
+const mediaInputValue = ref('')
+const mediaInputRef = ref<HTMLInputElement | null>(null)
+const mediaError = ref(false)
+const activePreviewKey = ref('')
+
 onMounted(async () => {
   await Promise.all([
     platformsStore.fetchStatuses(),
@@ -219,33 +202,69 @@ onMounted(async () => {
   composeStore.initDestinations()
 })
 
-const standardDestinations = computed(() =>
-  composeStore.destinations.filter((d) => !d.accountId)
+// Keep activePreviewKey pointed at a selected destination
+watch(
+  () => composeStore.selectedDestinations,
+  (selected) => {
+    if (!selected.find((d) => d.key === activePreviewKey.value)) {
+      activePreviewKey.value = selected[0]?.key ?? ''
+    }
+  },
+  { deep: true }
 )
-const facebookDestinations = computed(() =>
-  composeStore.destinations.filter((d) => d.platform === 'facebook' && d.accountId)
+
+function toggle(key: string) {
+  composeStore.toggleDestination(key)
+  // Set preview to the newly selected destination
+  const dest = composeStore.destinations.find((d) => d.key === key)
+  if (dest?.selected) activePreviewKey.value = key
+}
+
+async function toggleMediaInput() {
+  if (composeStore.mediaUrl.trim()) {
+    composeStore.mediaUrl = ''
+    mediaError.value = false
+    return
+  }
+  showMediaInput.value = !showMediaInput.value
+  if (showMediaInput.value) {
+    await nextTick()
+    mediaInputRef.value?.focus()
+  }
+}
+
+function applyMedia() {
+  if (mediaInputValue.value.trim()) {
+    composeStore.mediaUrl = mediaInputValue.value.trim()
+    mediaInputValue.value = ''
+    showMediaInput.value = false
+    mediaError.value = false
+  }
+}
+
+const igSelectedWithoutMedia = computed(() =>
+  composeStore.selectedDestinations.some((d) => d.platform === 'instagram') &&
+  !composeStore.mediaUrl.trim()
 )
-const instagramDestinations = computed(() =>
-  composeStore.destinations.filter((d) => d.platform === 'instagram' && d.accountId)
+
+const overLimit = computed(() =>
+  !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit
 )
 
-// Instagram accounts that are selected but missing an imageUrl
-const igWithoutImage = computed(() =>
-  instagramDestinations.value.filter((d) => d.selected && !d.imageUrl?.trim())
+const charNearLimit = computed(() =>
+  !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit * 0.9
 )
 
 const canPost = computed(() =>
   !!composeStore.content.trim() &&
   composeStore.selectedDestinations.length > 0 &&
-  igWithoutImage.value.length === 0
+  !overLimit.value &&
+  !igSelectedWithoutMedia.value
 )
 
-const postButtonLabel = computed(() => {
-  const { hasImmediateDestinations, hasScheduledDestinations } = composeStore
-  if (hasImmediateDestinations && hasScheduledDestinations) return t('compose.postAndSchedule')
-  if (hasScheduledDestinations) return `⏰ ${t('compose.schedule')}`
-  return t('compose.send')
-})
+const postButtonLabel = computed(() =>
+  composeStore.scheduledAt ? `⏰ ${t('compose.schedule')}` : t('compose.send')
+)
 
 async function handlePost() {
   await composeStore.post()