Răsfoiți Sursa

Improve Dashboard/Feed to show all connected accounts

Benjamin Harris 1 lună în urmă
părinte
comite
0fa1d3b7d1

+ 106 - 51
ui/src/components/feed/FeedItem.vue

@@ -1,67 +1,113 @@
 <template>
-  <article class="bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-gray-700 transition-colors">
-    <div class="flex items-start gap-3 mb-3">
-      <img
-        v-if="item.author.avatar"
-        :src="item.author.avatar"
-        :alt="item.author.name"
-        class="w-10 h-10 rounded-full object-cover flex-shrink-0"
-      />
-      <div v-else class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center flex-shrink-0 text-lg">
-        {{ item.author.name?.[0]?.toUpperCase() || '?' }}
+  <article
+    class="bg-gray-900 rounded-xl overflow-hidden border border-gray-800/80 hover:border-gray-700 transition-colors"
+    :style="{ borderLeftColor: platformColor, borderLeftWidth: '3px' }"
+  >
+    <!-- Image-first layout for Instagram (and any post with media) -->
+    <template v-if="isImageFirst && item.media[0]">
+      <div class="relative">
+        <img
+          :src="item.media[0].thumbnail || item.media[0].url"
+          :alt="item.media[0].alt || ''"
+          class="w-full object-cover max-h-44"
+        />
+        <!-- Platform badge over image -->
+        <span
+          class="absolute top-2 right-2 text-xs font-semibold px-1.5 py-0.5 rounded"
+          :style="{ backgroundColor: platformColor, color: '#fff' }"
+        >
+          {{ $t(`platforms.${item.platform}`) }}
+        </span>
+        <!-- Multiple images indicator -->
+        <span
+          v-if="item.media.length > 1"
+          class="absolute top-2 left-2 text-xs bg-black/60 text-white px-1.5 py-0.5 rounded"
+        >
+          +{{ item.media.length - 1 }}
+        </span>
       </div>
-      <div class="flex-1 min-w-0">
-        <div class="flex items-center gap-2 flex-wrap">
-          <span class="font-medium text-sm text-white truncate">{{ item.author.name }}</span>
-          <span class="text-gray-500 text-xs truncate">@{{ item.author.username }}</span>
-          <span
-            class="ml-auto text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
-            :style="{ backgroundColor: platformColor + '22', color: platformColor }"
-          >
-            {{ $t(`platforms.${item.platform}`) }}
-          </span>
+    </template>
+
+    <div class="p-3">
+      <!-- Author row -->
+      <div class="flex items-center gap-2 mb-2">
+        <img
+          v-if="item.author.avatar"
+          :src="item.author.avatar"
+          :alt="item.author.name"
+          class="w-7 h-7 rounded-full object-cover flex-shrink-0"
+        />
+        <div
+          v-else
+          class="w-7 h-7 rounded-full bg-gray-700 flex items-center justify-center flex-shrink-0 text-xs font-medium"
+        >
+          {{ item.author.name?.[0]?.toUpperCase() || '?' }}
+        </div>
+
+        <div class="flex-1 min-w-0">
+          <span class="text-xs font-semibold text-white truncate block leading-tight">{{ item.author.name || item.author.username }}</span>
+          <span class="text-xs text-gray-500 leading-tight">{{ timeAgo }}</span>
         </div>
-        <p class="text-xs text-gray-500 mt-0.5">{{ timeAgo }}</p>
+
+        <!-- Platform badge (text-only posts) -->
+        <span
+          v-if="!isImageFirst"
+          class="text-xs font-medium px-1.5 py-0.5 rounded flex-shrink-0"
+          :style="{ backgroundColor: platformColor + '25', color: platformColor }"
+        >
+          {{ $t(`platforms.${item.platform}`) }}
+        </span>
       </div>
-    </div>
 
-    <p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap break-words mb-3">{{ item.content }}</p>
+      <!-- Content -->
+      <p
+        v-if="item.content"
+        class="text-xs text-gray-300 leading-relaxed mb-2"
+        :class="expanded ? '' : 'line-clamp-3'"
+      >{{ item.content }}</p>
+      <button
+        v-if="item.content && item.content.length > 180 && !expanded"
+        @click="expanded = true"
+        class="text-xs text-gray-500 hover:text-gray-300 mb-2 -mt-1"
+      >more</button>
 
-    <div v-if="item.media?.length" class="grid gap-2 mb-3" :class="item.media.length > 1 ? 'grid-cols-2' : 'grid-cols-1'">
-      <img
-        v-for="(m, i) in item.media.slice(0, 4)"
-        :key="i"
-        :src="m.thumbnail || m.url"
-        :alt="m.alt || ''"
-        class="rounded-lg w-full h-40 object-cover"
-      />
-    </div>
+      <!-- Inline image (non-Instagram text posts with media) -->
+      <div v-if="!isImageFirst && item.media?.[0]" class="mb-2">
+        <img
+          :src="item.media[0].thumbnail || item.media[0].url"
+          :alt="item.media[0].alt || ''"
+          class="rounded-lg w-full object-cover max-h-28"
+        />
+      </div>
 
-    <div v-if="item.platformTags?.length" class="flex flex-wrap gap-1 mb-3">
-      <span
-        v-for="tag in item.platformTags.slice(0, 5)"
-        :key="tag"
-        class="text-xs text-blue-400 hover:text-blue-300 cursor-pointer"
-      >#{{ tag }}</span>
-    </div>
+      <!-- Tags -->
+      <div v-if="item.platformTags?.length" class="flex flex-wrap gap-1 mb-2">
+        <span
+          v-for="tag in item.platformTags.slice(0, 4)"
+          :key="tag"
+          class="text-xs text-blue-400"
+        >#{{ tag }}</span>
+      </div>
 
-    <div class="flex items-center gap-5 text-xs text-gray-500">
-      <span v-if="item.metrics.likes">❤️ {{ formatNum(item.metrics.likes) }}</span>
-      <span v-if="item.metrics.comments">💬 {{ formatNum(item.metrics.comments) }}</span>
-      <span v-if="item.metrics.shares">🔁 {{ formatNum(item.metrics.shares) }}</span>
-      <a
-        v-if="item.url"
-        :href="item.url"
-        target="_blank"
-        rel="noopener noreferrer"
-        class="ml-auto hover:text-gray-300 transition-colors"
-      >{{ $t('feed.openOriginal') }}</a>
+      <!-- Metrics + link -->
+      <div class="flex items-center gap-3 text-xs text-gray-600">
+        <span v-if="item.metrics?.likes">❤ {{ formatNum(item.metrics.likes) }}</span>
+        <span v-if="item.metrics?.comments">💬 {{ formatNum(item.metrics.comments) }}</span>
+        <span v-if="item.metrics?.shares">↺ {{ formatNum(item.metrics.shares) }}</span>
+        <a
+          v-if="item.url"
+          :href="item.url"
+          target="_blank"
+          rel="noopener noreferrer"
+          class="ml-auto hover:text-gray-400 transition-colors"
+        >{{ $t('feed.openOriginal') }}</a>
+      </div>
     </div>
   </article>
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { ref, computed } from 'vue'
 import { useI18n } from 'vue-i18n'
 import dayjs from 'dayjs'
 import relativeTime from 'dayjs/plugin/relativeTime'
@@ -75,10 +121,19 @@ dayjs.extend(relativeTime)
 const { locale } = useI18n()
 const props = defineProps<{ item: FeedItem }>()
 
+const expanded = ref(false)
+
 const platformColor = computed(() => PLATFORM_META[props.item.platform]?.color ?? '#6b7280')
 const timeAgo = computed(() => dayjs(props.item.createdAt).locale(locale.value).fromNow())
 
+// Show image at the top for Instagram, or any post whose first media is a photo/video
+const isImageFirst = computed(() =>
+  props.item.platform === 'instagram' ||
+  (props.item.media?.[0]?.type === 'image' && props.item.platform === 'facebook')
+)
+
 function formatNum(n: number): string {
+  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
   if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
   return String(n)
 }

+ 1 - 1
ui/src/stores/feed.ts

@@ -20,7 +20,7 @@ export interface FeedItem {
 export const useFeedStore = defineStore('feed', () => {
   const items = ref<FeedItem[]>([])
   const loading = ref(false)
-  const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin']))
+  const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin', 'instagram', 'facebook']))
   const activeTag = ref<string | null>(null)
   const searchQuery = ref('')
 

+ 15 - 1
ui/src/stores/platforms.ts

@@ -58,6 +58,19 @@ export const usePlatformsStore = defineStore('platforms', () => {
   const metaLoading = ref(false)
   const metaError = ref<string | null>(null)
 
+  // Connected pages/accounts (fetched from gateway)
+  const connectedPages = ref<MetaPage[]>([])
+  const connectedIgAccounts = ref<MetaIgAccount[]>([])
+
+  async function fetchMetaConnections() {
+    try {
+      const res = await fetch('/api/credentials')
+      const data = await res.json()
+      connectedPages.value = data.facebook?.pages || []
+      connectedIgAccounts.value = data.instagram?.accounts || []
+    } catch (_) { /* ignore */ }
+  }
+
   // ─── Platform status ──────────────────────────────────────────────────────
 
   async function fetchStatuses() {
@@ -73,7 +86,7 @@ export const usePlatformsStore = defineStore('platforms', () => {
   }
 
   function getStatus(platform: string): PlatformStatus | undefined {
-    return statuses.value.find((s) => s.platform === platform)
+    return statuses.value.find((s: PlatformStatus) => s.platform === platform)
   }
 
   function isConnected(platform: string): boolean {
@@ -157,6 +170,7 @@ export const usePlatformsStore = defineStore('platforms', () => {
   return {
     statuses, loading, fetchStatuses, getStatus, isConnected,
     metaCredentials, metaDiscovery, metaLoading, metaError,
+    connectedPages, connectedIgAccounts, fetchMetaConnections,
     fetchMetaCredentials, saveMetaApp, startMetaOAuth,
     fetchMetaDiscovery, saveMetaSelection, disconnectMeta,
   }

+ 162 - 45
ui/src/views/Dashboard.vue

@@ -1,97 +1,213 @@
 <template>
   <div class="flex h-screen overflow-hidden bg-gray-950 text-gray-100">
 
-    <!-- Sidebar -->
-    <aside class="w-60 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col p-4 gap-6 overflow-y-auto">
-      <div>
-        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">
+    <!-- ── Sidebar ── -->
+    <aside class="w-52 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto">
+
+      <div class="p-4">
+        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-2">
           {{ $t('dashboard.platforms') }}
         </p>
-        <button
-          v-for="(meta, key) in PLATFORM_META"
-          :key="key"
-          @click="feedStore.togglePlatform(key)"
-          class="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors mb-1"
-          :class="feedStore.activePlatforms.has(key)
-            ? 'bg-gray-700 text-white'
-            : 'text-gray-500 hover:bg-gray-800 hover:text-gray-300'"
-        >
-          <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: meta.color }"></span>
-          <span class="truncate">{{ $t(`platforms.${key}`) }}</span>
-          <span v-if="platformsStore.isConnected(key)" class="ml-auto w-1.5 h-1.5 rounded-full bg-green-400"></span>
-        </button>
+
+        <template v-for="(meta, key) in PLATFORM_META" :key="key">
+          <button
+            @click="feedStore.togglePlatform(key)"
+            class="flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm transition-colors mb-0.5"
+            :class="feedStore.activePlatforms.has(key)
+              ? 'bg-gray-800 text-white'
+              : 'text-gray-500 hover:bg-gray-800/60 hover:text-gray-300'"
+          >
+            <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: meta.color }"></span>
+            <span class="truncate flex-1 text-left text-xs">{{ $t(`platforms.${key}`) }}</span>
+            <span
+              v-if="itemsByPlatform[key]?.length"
+              class="text-xs text-gray-500 flex-shrink-0"
+            >{{ itemsByPlatform[key].length }}</span>
+            <span v-if="platformsStore.isConnected(key)" class="w-1.5 h-1.5 rounded-full bg-green-400 flex-shrink-0"></span>
+          </button>
+
+          <!-- Facebook sub-pages -->
+          <template v-if="key === 'facebook' && platformsStore.connectedPages.length">
+            <div
+              v-for="page in platformsStore.connectedPages"
+              :key="page.id"
+              class="flex items-center gap-2 pl-6 pr-2 py-1 mb-0.5"
+            >
+              <img v-if="page.picture" :src="page.picture" class="w-3.5 h-3.5 rounded-full flex-shrink-0" />
+              <span
+                v-else
+                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"
+              >f</span>
+              <span class="text-xs text-gray-500 truncate">{{ page.name }}</span>
+            </div>
+          </template>
+
+          <!-- Instagram sub-accounts -->
+          <template v-if="key === 'instagram' && platformsStore.connectedIgAccounts.length">
+            <div
+              v-for="account in platformsStore.connectedIgAccounts"
+              :key="account.id"
+              class="flex items-center gap-2 pl-6 pr-2 py-1 mb-0.5"
+            >
+              <img v-if="account.avatar" :src="account.avatar" class="w-3.5 h-3.5 rounded-full flex-shrink-0" />
+              <span
+                v-else
+                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"
+              >I</span>
+              <span class="text-xs text-gray-500 truncate">@{{ account.username }}</span>
+            </div>
+          </template>
+        </template>
       </div>
 
-      <div>
-        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">
+      <div class="px-4 pb-4 border-t border-gray-800 pt-3 mt-auto">
+        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-2">
           {{ $t('dashboard.tags') }}
         </p>
         <button
           @click="feedStore.activeTag = null"
-          class="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm mb-1 transition-colors"
-          :class="!feedStore.activeTag ? 'bg-gray-700 text-white' : 'text-gray-500 hover:bg-gray-800'"
+          class="flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-xs mb-0.5 transition-colors"
+          :class="!feedStore.activeTag ? 'bg-gray-800 text-white' : 'text-gray-500 hover:bg-gray-800/60'"
         >
           {{ $t('dashboard.allTags') }}
         </button>
       </div>
     </aside>
 
-    <!-- Ana içerik -->
-    <main class="flex-1 flex flex-col overflow-hidden">
-      <header class="flex items-center gap-3 px-6 py-4 border-b border-gray-800 bg-gray-900">
+    <!-- ── Main ── -->
+    <main class="flex-1 flex flex-col overflow-hidden min-w-0">
+
+      <!-- Toolbar -->
+      <header class="flex items-center gap-3 px-4 py-2.5 border-b border-gray-800 bg-gray-900 flex-shrink-0">
         <input
           v-model="feedStore.searchQuery"
           type="text"
           :placeholder="$t('dashboard.searchPlaceholder')"
-          class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500"
+          class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500 min-w-0"
         />
         <button
           @click="handleRefresh"
           :disabled="feedStore.loading"
-          class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+          class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
         >
           {{ feedStore.loading ? $t('dashboard.refreshing') : $t('dashboard.refresh') }}
         </button>
         <router-link
           to="/compose"
-          class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg text-sm font-medium transition-colors"
+          class="px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
         >
           {{ $t('dashboard.newPost') }}
         </router-link>
       </header>
 
-      <div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
-        <div v-if="feedStore.loading && !feedStore.items.length" class="text-center text-gray-500 mt-20">
-          {{ $t('dashboard.loading') }}
-        </div>
+      <!-- Empty state (no items at all) -->
+      <div
+        v-if="feedStore.loading && !feedStore.items.length"
+        class="flex-1 flex items-center justify-center text-gray-500 text-sm"
+      >
+        {{ $t('dashboard.loading') }}
+      </div>
 
-        <div v-else-if="!feedStore.filteredItems.length" class="text-center text-gray-500 mt-20">
-          <p class="text-4xl mb-4">📭</p>
-          <p>{{ $t('dashboard.empty') }}</p>
-          <p class="text-sm mt-1">{{ $t('dashboard.emptyHint') }}</p>
-        </div>
+      <div
+        v-else-if="!activePlatformsWithItems.length"
+        class="flex-1 flex flex-col items-center justify-center text-gray-500"
+      >
+        <p class="text-3xl mb-3">📭</p>
+        <p class="text-sm">{{ $t('dashboard.empty') }}</p>
+        <p class="text-xs mt-1 text-gray-600">{{ $t('dashboard.emptyHint') }}</p>
+      </div>
 
-        <FeedItem
-          v-for="item in feedStore.filteredItems"
-          :key="`${item.platform}-${item.originalId}`"
-          :item="item"
-        />
+      <!-- Platform columns — horizontal scroll, each column scrolls vertically -->
+      <div v-else class="flex-1 overflow-x-auto overflow-y-hidden">
+        <div class="flex h-full gap-2 p-3" :style="{ minWidth: `${activePlatformsWithItems.length * 296}px` }">
+
+          <div
+            v-for="platform in activePlatformsWithItems"
+            :key="platform"
+            class="flex flex-col flex-1 min-w-[260px] max-w-xs bg-gray-900/60 rounded-xl overflow-hidden border border-gray-800/60"
+          >
+            <!-- Column header -->
+            <div
+              class="flex items-center gap-2 px-3 py-2 border-b border-gray-800 flex-shrink-0"
+              :style="{ borderBottomColor: PLATFORM_META[platform]?.color + '44' }"
+            >
+              <span
+                class="w-2.5 h-2.5 rounded-full flex-shrink-0"
+                :style="{ backgroundColor: PLATFORM_META[platform]?.color }"
+              ></span>
+              <span class="text-sm font-semibold flex-1">{{ $t(`platforms.${platform}`) }}</span>
+              <span class="text-xs text-gray-600">{{ itemsByPlatform[platform]?.length }}</span>
+            </div>
+
+            <!-- Sub-label for Facebook pages -->
+            <template v-if="platform === 'facebook' && platformsStore.connectedPages.length">
+              <div class="px-3 py-1 bg-gray-800/40 flex flex-wrap gap-x-2 border-b border-gray-800/40">
+                <span
+                  v-for="page in platformsStore.connectedPages"
+                  :key="page.id"
+                  class="text-xs text-gray-500 truncate"
+                >{{ page.name }}</span>
+              </div>
+            </template>
+
+            <!-- Sub-label for Instagram accounts -->
+            <template v-if="platform === 'instagram' && platformsStore.connectedIgAccounts.length">
+              <div class="px-3 py-1 bg-gray-800/40 flex flex-wrap gap-x-2 border-b border-gray-800/40">
+                <span
+                  v-for="account in platformsStore.connectedIgAccounts"
+                  :key="account.id"
+                  class="text-xs text-gray-500 truncate"
+                >@{{ account.username }}</span>
+              </div>
+            </template>
+
+            <!-- Posts — this div scrolls independently -->
+            <div class="flex-1 overflow-y-auto p-2 space-y-2">
+              <FeedItem
+                v-for="item in itemsByPlatform[platform]"
+                :key="`${item.platform}-${item.originalId}`"
+                :item="item"
+              />
+            </div>
+          </div>
+
+        </div>
       </div>
     </main>
   </div>
 </template>
 
 <script setup lang="ts">
-import { onMounted } from 'vue'
+import { computed, onMounted } from 'vue'
 import { io } from 'socket.io-client'
 import { useFeedStore } from '../stores/feed'
-import { usePlatformsStore } from '../stores/platforms'
-import { PLATFORM_META } from '../stores/platforms'
+import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
+import type { FeedItem as FeedItemType } from '../stores/feed'
 import FeedItem from '../components/feed/FeedItem.vue'
 
 const feedStore = useFeedStore()
 const platformsStore = usePlatformsStore()
 
+// Group filtered items by platform
+const itemsByPlatform = computed<Record<string, FeedItemType[]>>(() => {
+  const groups: Record<string, FeedItemType[]> = {}
+  for (const item of feedStore.filteredItems) {
+    if (!groups[item.platform]) groups[item.platform] = []
+    groups[item.platform].push(item)
+  }
+  return groups
+})
+
+// Only show columns for platforms that are active AND have at least one item,
+// preserving the order defined in PLATFORM_META
+const activePlatformsWithItems = computed(() =>
+  Object.keys(PLATFORM_META).filter(
+    (p) => feedStore.activePlatforms.has(p) && itemsByPlatform.value[p]?.length
+  )
+)
+
 async function handleRefresh() {
   await feedStore.refreshFeeds()
 }
@@ -100,12 +216,13 @@ onMounted(async () => {
   await Promise.all([
     feedStore.fetchFeeds(),
     platformsStore.fetchStatuses(),
+    platformsStore.fetchMetaConnections(),
   ])
 
   const socket = io()
   socket.on('feed.items', (data: { platform: string; items: unknown[] }) => {
     if (Array.isArray(data.items)) {
-      data.items.forEach((item) => feedStore.addItem(item as any))
+      data.items.forEach((item) => feedStore.addItem(item as FeedItemType))
     }
   })
 })

+ 4 - 9
ui/src/views/Settings.vue

@@ -301,17 +301,12 @@ const igStatus = computed(() => platformsStore.getStatus('instagram'))
 const fbConnected = computed(() => fbStatus.value?.connected ?? false)
 const igConnected = computed(() => igStatus.value?.connected ?? false)
 
-// These come from the gateway /api/credentials endpoint (richer than platform-status)
-const fbPages = ref<Array<{ id: string; name: string; picture?: string }>>([])
-const igAccounts = ref<Array<{ id: string; username: string; avatar?: string }>>([])
+// Pull connected pages/accounts from the shared store
+const fbPages = computed(() => platformsStore.connectedPages)
+const igAccounts = computed(() => platformsStore.connectedIgAccounts)
 
 async function loadMetaConnections() {
-  try {
-    const res = await fetch('/api/credentials')
-    const data = await res.json()
-    fbPages.value = data.facebook?.pages || []
-    igAccounts.value = data.instagram?.accounts || []
-  } catch (_) { /* ignore */ }
+  await platformsStore.fetchMetaConnections()
 }
 
 // ─── OAuth discovery ─────────────────────────────────────────────────────────