Explorar el Código

Media Improvements

Benjamin Harris hace 1 mes
padre
commit
c3abdd453c
Se han modificado 4 ficheros con 444 adiciones y 90 borrados
  1. 71 4
      services/gateway/server.js
  2. 9 0
      ui/src/locales/en.ts
  3. 9 0
      ui/src/locales/tr.ts
  4. 355 86
      ui/src/views/Media.vue

+ 71 - 4
services/gateway/server.js

@@ -62,6 +62,7 @@ async function deleteCredentials(id) {
 // ─── Media Upload & Library ───────────────────────────────────────────────────
 
 app.post('/upload', async (request, reply) => {
+  const folder = request.query.folder || null;
   const data = await request.file();
   if (!data) return reply.code(400).send({ error: 'No file provided' });
 
@@ -88,6 +89,7 @@ app.post('/upload', async (request, reply) => {
     url: `/media/${filename}`,
     mimetype: data.mimetype,
     size: stat.size,
+    folder: folder || null,
     uploadedAt: new Date(),
   };
 
@@ -98,16 +100,81 @@ app.post('/upload', async (request, reply) => {
     app.log.error({ action: 'media_metadata_save', outcome: 'failure', err: err.message });
   }
 
-  return { url: record.url, filename, originalName: data.filename, mimetype: data.mimetype, size: stat.size };
+  return { url: record.url, filename, originalName: data.filename, mimetype: data.mimetype, size: stat.size, folder: record.folder };
 });
 
-// List all uploaded media files, newest first
-app.get('/media-library', async () => {
+// List uploaded media files, newest first; optionally filter by folder
+// folder=__none__ → unorganized (null/missing); folder=<name> → that folder; omit → all
+app.get('/media-library', async (request) => {
   const db = await getDb();
-  const files = await db.collection('media_files').find({}).sort({ uploadedAt: -1 }).toArray();
+  const { folder } = request.query;
+  const query = {};
+  if (folder === '__none__') {
+    query.$or = [{ folder: { $exists: false } }, { folder: null }, { folder: '' }];
+  } else if (folder) {
+    query.folder = folder;
+  }
+  const files = await db.collection('media_files').find(query).sort({ uploadedAt: -1 }).toArray();
   return { files };
 });
 
+// List custom folders with per-folder file counts
+app.get('/media-folders', async () => {
+  const db = await getDb();
+  const [folders, counts] = await Promise.all([
+    db.collection('media_folders').find({}).sort({ createdAt: 1 }).toArray(),
+    db.collection('media_files').aggregate([
+      { $group: { _id: { $ifNull: ['$folder', '__none__'] }, count: { $sum: 1 } } },
+    ]).toArray(),
+  ]);
+  const countMap = Object.fromEntries(counts.map((c) => [c._id, c.count]));
+  const total = counts.reduce((s, c) => s + c.count, 0);
+  return {
+    folders: folders.map((f) => ({ name: f.name, count: countMap[f.name] || 0 })),
+    totalCount: total,
+    unorganizedCount: countMap['__none__'] || 0,
+    folderCounts: countMap,
+  };
+});
+
+// Create a custom folder
+app.post('/media-folders', async (request, reply) => {
+  const { name } = request.body || {};
+  if (!name?.trim()) return reply.code(400).send({ error: 'Folder name is required' });
+  const trimmed = name.trim();
+  const db = await getDb();
+  if (await db.collection('media_folders').findOne({ name: trimmed })) {
+    return reply.code(409).send({ error: 'Folder already exists' });
+  }
+  await db.collection('media_folders').insertOne({ name: trimmed, createdAt: new Date() });
+  return { name: trimmed };
+});
+
+// Delete a custom folder; files in it become unorganized
+app.delete('/media-folders/:name', async (request, reply) => {
+  const name = decodeURIComponent(request.params.name);
+  const db = await getDb();
+  await db.collection('media_folders').deleteOne({ name });
+  await db.collection('media_files').updateMany({ folder: name }, { $set: { folder: null } });
+  return { success: true };
+});
+
+// Update a file's folder assignment
+app.patch('/media/:filename', async (request, reply) => {
+  const { filename } = request.params;
+  if (!filename || filename.includes('/') || filename.includes('..') || filename.includes('\0')) {
+    return reply.code(400).send({ error: 'Invalid filename' });
+  }
+  const { folder } = request.body || {};
+  const db = await getDb();
+  const result = await db.collection('media_files').updateOne(
+    { filename },
+    { $set: { folder: folder || null } },
+  );
+  if (!result.matchedCount) return reply.code(404).send({ error: 'File not found' });
+  return { success: true };
+});
+
 // Delete a media file from disk and database
 app.delete('/media/:filename', async (request, reply) => {
   const { filename } = request.params;

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

@@ -95,6 +95,15 @@ export default {
     cancel: 'Cancel',
     delete: 'Delete',
     deleting: 'Deleting…',
+
+    allFiles: 'All Files',
+    unorganized: 'Unorganized',
+    accounts: 'Accounts',
+    folders: 'Folders',
+    newFolder: 'New Folder',
+    folderNamePlaceholder: 'Folder name…',
+    moveToFolder: 'Move to folder',
+    removeFolderAssign: 'Remove from folder',
   },
 
   dashboard: {

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

@@ -95,6 +95,15 @@ export default {
     cancel: 'İptal',
     delete: 'Sil',
     deleting: 'Siliniyor…',
+
+    allFiles: 'Tüm Dosyalar',
+    unorganized: 'Düzenlenmemiş',
+    accounts: 'Hesaplar',
+    folders: 'Klasörler',
+    newFolder: 'Yeni Klasör',
+    folderNamePlaceholder: 'Klasör adı…',
+    moveToFolder: 'Klasöre taşı',
+    removeFolderAssign: 'Klasörden çıkar',
   },
 
   dashboard: {

+ 355 - 86
ui/src/views/Media.vue

@@ -45,97 +45,235 @@
       <button @click="uploadError = ''" class="text-red-400 hover:text-red-200">✕</button>
     </div>
 
-    <!-- Drag-and-drop zone (shown when no files or as overlay) -->
-    <div
-      v-if="!files.length && !loading"
-      class="flex-1 flex flex-col items-center justify-center gap-4 border-2 border-dashed border-gray-800 m-6 rounded-2xl cursor-pointer hover:border-blue-700 hover:bg-blue-950/10 transition-colors"
-      @click="fileInputRef?.click()"
-      @dragover.prevent
-      @drop.prevent="handleDrop"
-    >
-      <svg class="w-12 h-12 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
-        <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>
-      <div class="text-center">
-        <p class="text-gray-400 font-medium">{{ $t('media.dropZoneTitle') }}</p>
-        <p class="text-gray-600 text-sm mt-1">{{ $t('media.dropZoneHint') }}</p>
-      </div>
-    </div>
+    <!-- Body: sidebar + main -->
+    <div class="flex flex-1 overflow-hidden">
 
-    <!-- Loading skeleton -->
-    <div v-else-if="loading" class="flex-1 overflow-y-auto p-6">
-      <div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-2">
-        <div v-for="i in 12" :key="i" class="aspect-square rounded-xl bg-gray-800 animate-pulse" />
-      </div>
-    </div>
+      <!-- Folder sidebar -->
+      <aside class="w-52 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto">
 
-    <!-- Media grid -->
-    <div
-      v-else
-      class="flex-1 overflow-y-auto p-6"
-      @dragover.prevent
-      @drop.prevent="handleDrop"
-    >
-      <div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-2">
-        <div
-          v-for="file in files"
-          :key="file._id"
-          class="group relative aspect-square rounded-xl overflow-hidden bg-gray-900 border border-gray-800 hover:border-gray-600 transition-colors"
+        <!-- All Files -->
+        <button
+          @click="setFolder(null)"
+          :class="activeFolder === null ? 'bg-blue-700 text-white' : 'text-gray-300 hover:bg-gray-800'"
+          class="flex items-center justify-between px-4 py-2.5 text-sm font-medium transition-colors text-left"
         >
-          <!-- Image thumbnail -->
-          <img
-            v-if="isImage(file.mimetype)"
-            :src="file.url"
-            :alt="file.originalName"
-            class="w-full h-full object-cover"
-            loading="lazy"
-          />
-
-          <!-- Video placeholder -->
-          <div v-else class="w-full h-full flex flex-col items-center justify-center gap-2 bg-gray-800">
-            <svg class="w-8 h-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
-              <path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.069A1 1 0 0121 8.882v6.236a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
-            </svg>
-            <span class="text-xs text-gray-500 px-2 text-center truncate w-full">{{ file.originalName }}</span>
-          </div>
+          <span>{{ $t('media.allFiles') }}</span>
+          <span class="text-xs opacity-60">{{ totalCount }}</span>
+        </button>
+
+        <!-- Unorganized -->
+        <button
+          @click="setFolder('__none__')"
+          :class="activeFolder === '__none__' ? 'bg-blue-700 text-white' : 'text-gray-300 hover:bg-gray-800'"
+          class="flex items-center justify-between px-4 py-2 text-sm transition-colors text-left"
+        >
+          <span>{{ $t('media.unorganized') }}</span>
+          <span class="text-xs opacity-60">{{ unorganizedCount }}</span>
+        </button>
 
-          <!-- Hover overlay with actions -->
-          <div class="absolute inset-0 bg-black/70 opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-between p-2">
-            <!-- File info -->
-            <div class="truncate">
-              <p class="text-xs text-white font-medium truncate">{{ file.originalName }}</p>
-              <p class="text-xs text-gray-400">{{ formatSize(file.size) }}</p>
-            </div>
+        <!-- Accounts section -->
+        <div v-if="accountFolders.length" class="mt-3">
+          <p class="px-4 py-1 text-xs text-gray-600 font-semibold uppercase tracking-wider">{{ $t('media.accounts') }}</p>
+          <button
+            v-for="af in accountFolders"
+            :key="af.key"
+            @click="setFolder(af.key)"
+            :class="activeFolder === af.key ? 'bg-blue-700 text-white' : 'text-gray-300 hover:bg-gray-800'"
+            class="flex items-center justify-between px-4 py-2 text-sm transition-colors text-left w-full"
+          >
+            <span class="truncate">{{ af.label }}</span>
+            <span class="text-xs opacity-60 ml-1 flex-shrink-0">{{ folderCounts[af.key] || 0 }}</span>
+          </button>
+        </div>
+
+        <!-- Custom Folders section -->
+        <div class="mt-3 flex-1">
+          <p class="px-4 py-1 text-xs text-gray-600 font-semibold uppercase tracking-wider">{{ $t('media.folders') }}</p>
 
-            <!-- Action buttons -->
-            <div class="flex flex-col gap-1.5">
+          <button
+            v-for="folder in customFolders"
+            :key="folder.name"
+            @click="setFolder(folder.name)"
+            :class="activeFolder === folder.name ? 'bg-blue-700 text-white' : 'text-gray-300 hover:bg-gray-800'"
+            class="group flex items-center justify-between px-4 py-2 text-sm transition-colors text-left w-full"
+          >
+            <span class="truncate flex-1">{{ folder.name }}</span>
+            <span class="text-xs opacity-60 mr-1">{{ folder.count }}</span>
+            <span
+              @click.stop="deleteFolder(folder.name)"
+              class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 transition-opacity ml-1"
+              title="Delete folder"
+            >✕</span>
+          </button>
+
+          <!-- New folder inline input -->
+          <div class="px-3 py-2">
+            <div v-if="!showNewFolderInput">
               <button
-                @click="useInPost(file.url)"
-                class="w-full py-1 bg-blue-600 hover:bg-blue-500 rounded-md text-xs font-medium text-white transition-colors"
+                @click="showNewFolderInput = true"
+                class="w-full flex items-center gap-2 px-2 py-1.5 text-xs text-gray-500 hover:text-gray-300 hover:bg-gray-800 rounded-md transition-colors"
               >
-                {{ $t('media.useInPost') }}
+                <span class="text-base leading-none">+</span>
+                {{ $t('media.newFolder') }}
               </button>
-              <div class="flex gap-1">
-                <button
-                  @click="copyUrl(file.url)"
-                  class="flex-1 py-1 bg-gray-700 hover:bg-gray-600 rounded-md text-xs text-gray-200 transition-colors"
-                  :class="copied === file.url ? 'bg-green-700 hover:bg-green-700' : ''"
-                >
-                  {{ copied === file.url ? '✓ ' + $t('media.copied') : $t('media.copyUrl') }}
-                </button>
-                <button
-                  @click="confirmDelete(file)"
-                  class="px-2 py-1 bg-gray-700 hover:bg-red-700 rounded-md text-xs text-gray-300 hover:text-white transition-colors"
-                  title="Delete"
-                >
-                  <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
-                    <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
-                  </svg>
-                </button>
+            </div>
+            <div v-else class="flex gap-1">
+              <input
+                ref="newFolderInputRef"
+                v-model="newFolderName"
+                type="text"
+                :placeholder="$t('media.folderNamePlaceholder')"
+                class="flex-1 bg-gray-800 border border-gray-700 rounded-md px-2 py-1 text-xs text-gray-200 focus:outline-none focus:border-blue-600 min-w-0"
+                @keydown.enter="createFolder"
+                @keydown.escape="cancelNewFolder"
+              />
+              <button @click="createFolder" class="px-2 py-1 bg-blue-600 hover:bg-blue-700 rounded-md text-xs">✓</button>
+              <button @click="cancelNewFolder" class="px-1.5 py-1 text-gray-500 hover:text-gray-300 rounded-md text-xs">✕</button>
+            </div>
+          </div>
+        </div>
+      </aside>
+
+      <!-- Main content area -->
+      <div class="flex-1 flex flex-col overflow-hidden">
+
+        <!-- Drag-and-drop zone (shown when no files or as overlay) -->
+        <div
+          v-if="!files.length && !loading"
+          class="flex-1 flex flex-col items-center justify-center gap-4 border-2 border-dashed border-gray-800 m-6 rounded-2xl cursor-pointer hover:border-blue-700 hover:bg-blue-950/10 transition-colors"
+          @click="fileInputRef?.click()"
+          @dragover.prevent
+          @drop.prevent="handleDrop"
+        >
+          <svg class="w-12 h-12 text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+            <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-2V8a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
+          </svg>
+          <div class="text-center">
+            <p class="text-gray-400 font-medium">{{ $t('media.dropZoneTitle') }}</p>
+            <p class="text-gray-600 text-sm mt-1">{{ $t('media.dropZoneHint') }}</p>
+          </div>
+        </div>
+
+        <!-- Loading skeleton -->
+        <div v-else-if="loading" class="flex-1 overflow-y-auto p-6">
+          <div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-9 gap-2">
+            <div v-for="i in 12" :key="i" class="aspect-square rounded-xl bg-gray-800 animate-pulse" />
+          </div>
+        </div>
+
+        <!-- Media grid -->
+        <div
+          v-else
+          class="flex-1 overflow-y-auto p-6"
+          @dragover.prevent
+          @drop.prevent="handleDrop"
+        >
+          <div class="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-9 gap-2">
+            <div
+              v-for="file in files"
+              :key="file._id"
+              class="group relative aspect-square rounded-xl overflow-hidden bg-gray-900 border border-gray-800 hover:border-gray-600 transition-colors"
+            >
+              <!-- Image thumbnail -->
+              <img
+                v-if="isImage(file.mimetype)"
+                :src="file.url"
+                :alt="file.originalName"
+                class="w-full h-full object-cover"
+                loading="lazy"
+              />
+
+              <!-- Video placeholder -->
+              <div v-else class="w-full h-full flex flex-col items-center justify-center gap-2 bg-gray-800">
+                <svg class="w-8 h-8 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+                  <path stroke-linecap="round" stroke-linejoin="round" d="M15 10l4.553-2.069A1 1 0 0121 8.882v6.236a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
+                </svg>
+                <span class="text-xs text-gray-500 px-2 text-center truncate w-full">{{ file.originalName }}</span>
+              </div>
+
+              <!-- Hover overlay -->
+              <div
+                class="absolute inset-0 bg-black/80 transition-opacity flex flex-col justify-between p-2"
+                :class="movingFileId === file._id ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
+              >
+                <!-- Move-to-folder panel -->
+                <template v-if="movingFileId === file._id">
+                  <div class="flex flex-col h-full">
+                    <p class="text-xs text-gray-300 font-medium mb-1.5">{{ $t('media.moveToFolder') }}</p>
+                    <div class="flex-1 overflow-y-auto flex flex-col gap-1 min-h-0">
+                      <!-- Remove from folder -->
+                      <button
+                        v-if="file.folder"
+                        @click="moveFileTo(file, null)"
+                        class="w-full text-left px-2 py-1 rounded text-xs text-gray-400 hover:bg-gray-700 transition-colors"
+                      >
+                        {{ $t('media.removeFolderAssign') }}
+                      </button>
+                      <!-- Account folders -->
+                      <button
+                        v-for="af in accountFolders"
+                        :key="af.key"
+                        @click="moveFileTo(file, af.key)"
+                        :class="file.folder === af.key ? 'bg-blue-700 text-white' : 'text-gray-300 hover:bg-gray-700'"
+                        class="w-full text-left px-2 py-1 rounded text-xs transition-colors truncate"
+                      >{{ af.label }}</button>
+                      <!-- Custom folders -->
+                      <button
+                        v-for="cf in customFolders"
+                        :key="cf.name"
+                        @click="moveFileTo(file, cf.name)"
+                        :class="file.folder === cf.name ? 'bg-blue-700 text-white' : 'text-gray-300 hover:bg-gray-700'"
+                        class="w-full text-left px-2 py-1 rounded text-xs transition-colors truncate"
+                      >{{ cf.name }}</button>
+                    </div>
+                    <button
+                      @click="movingFileId = null"
+                      class="mt-1.5 w-full py-1 bg-gray-700 hover:bg-gray-600 rounded-md text-xs text-gray-300 transition-colors"
+                    >{{ $t('media.cancel') }}</button>
+                  </div>
+                </template>
+
+                <!-- Normal actions -->
+                <template v-else>
+                  <div class="truncate">
+                    <p class="text-xs text-white font-medium truncate">{{ file.originalName }}</p>
+                    <p class="text-xs text-gray-400">{{ formatSize(file.size) }}</p>
+                    <p v-if="file.folder" class="text-xs text-blue-400 truncate mt-0.5">{{ folderLabel(file.folder) }}</p>
+                  </div>
+
+                  <div class="flex flex-col gap-1.5">
+                    <button
+                      @click="useInPost(file.url)"
+                      class="w-full py-1 bg-blue-600 hover:bg-blue-500 rounded-md text-xs font-medium text-white transition-colors"
+                    >{{ $t('media.useInPost') }}</button>
+                    <div class="flex gap-1">
+                      <button
+                        @click="copyUrl(file.url)"
+                        class="flex-1 py-1 bg-gray-700 hover:bg-gray-600 rounded-md text-xs text-gray-200 transition-colors"
+                        :class="copied === file.url ? 'bg-green-700 hover:bg-green-700' : ''"
+                      >{{ copied === file.url ? '✓' : $t('media.copyUrl') }}</button>
+                      <button
+                        @click="movingFileId = file._id"
+                        class="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded-md text-xs text-gray-300 transition-colors"
+                        :title="$t('media.moveToFolder')"
+                      >📁</button>
+                      <button
+                        @click="confirmDelete(file)"
+                        class="px-2 py-1 bg-gray-700 hover:bg-red-700 rounded-md text-xs text-gray-300 hover:text-white transition-colors"
+                        title="Delete"
+                      >
+                        <svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                          <path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
+                        </svg>
+                      </button>
+                    </div>
+                  </div>
+                </template>
               </div>
             </div>
           </div>
         </div>
+
       </div>
     </div>
 
@@ -163,9 +301,10 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import axios from 'axios'
+import { usePlatformsStore } from '../stores/platforms'
 
 interface MediaFile {
   _id: string
@@ -175,9 +314,21 @@ interface MediaFile {
   mimetype: string
   size: number
   uploadedAt: string
+  folder?: string | null
+}
+
+interface CustomFolder {
+  name: string
+  count: number
+}
+
+interface AccountFolder {
+  key: string
+  label: string
 }
 
 const router = useRouter()
+const platformsStore = usePlatformsStore()
 
 const files = ref<MediaFile[]>([])
 const loading = ref(true)
@@ -189,12 +340,79 @@ const deleteTarget = ref<MediaFile | null>(null)
 const copied = ref('')
 const fileInputRef = ref<HTMLInputElement | null>(null)
 
-onMounted(fetchLibrary)
+const activeFolder = ref<string | null>(null)
+const customFolders = ref<CustomFolder[]>([])
+const totalCount = ref(0)
+const unorganizedCount = ref(0)
+const folderCounts = ref<Record<string, number>>({})
+
+const showNewFolderInput = ref(false)
+const newFolderName = ref('')
+const newFolderInputRef = ref<HTMLInputElement | null>(null)
+
+const movingFileId = ref<string | null>(null)
+
+const accountFolders = computed<AccountFolder[]>(() => {
+  const result: AccountFolder[] = []
+  const { connectedPages, connectedIgAccounts, connectedPinterestBoards } = platformsStore
+
+  for (const page of connectedPages) {
+    result.push({ key: `facebook:${page.id}`, label: `Facebook: ${page.name}` })
+  }
+  for (const acc of connectedIgAccounts) {
+    result.push({ key: `instagram:${acc.id}`, label: `Instagram: @${acc.username}` })
+  }
+  for (const board of connectedPinterestBoards) {
+    result.push({ key: `pinterest:${board.id}`, label: `Pinterest: ${board.name}` })
+  }
+
+  const standardPlatforms = [
+    { key: 'twitter', label: 'Twitter/X' },
+    { key: 'linkedin', label: 'LinkedIn' },
+    { key: 'mastodon', label: 'Mastodon' },
+    { key: 'bluesky', label: 'Bluesky' },
+    { key: 'reddit', label: 'Reddit' },
+    { key: 'youtube', label: 'YouTube' },
+  ]
+  for (const p of standardPlatforms) {
+    const status = platformsStore.statuses.find((s) => s.platform === p.key)
+    if (status?.connected) {
+      result.push({ key: p.key, label: p.label })
+    }
+  }
+
+  return result
+})
+
+function folderLabel(key: string): string {
+  const af = accountFolders.value.find((a) => a.key === key)
+  if (af) return af.label
+  return key
+}
+
+onMounted(async () => {
+  await platformsStore.fetchMetaConnections()
+  await Promise.all([fetchFolders(), fetchLibrary()])
+})
+
+async function fetchFolders() {
+  try {
+    const res = await axios.get('/api/media-folders')
+    customFolders.value = res.data.folders
+    totalCount.value = res.data.totalCount
+    unorganizedCount.value = res.data.unorganizedCount
+    folderCounts.value = res.data.folderCounts || {}
+  } catch {
+    // ignore
+  }
+}
 
 async function fetchLibrary() {
   loading.value = true
   try {
-    const res = await axios.get('/api/media-library')
+    const params: Record<string, string> = {}
+    if (activeFolder.value !== null) params.folder = activeFolder.value
+    const res = await axios.get('/api/media-library', { params })
     files.value = res.data.files
   } catch {
     // silently fail — empty grid shown
@@ -203,6 +421,53 @@ async function fetchLibrary() {
   }
 }
 
+function setFolder(key: string | null) {
+  activeFolder.value = key
+  movingFileId.value = null
+  fetchLibrary()
+}
+
+async function createFolder() {
+  const name = newFolderName.value.trim()
+  if (!name) return
+  try {
+    await axios.post('/api/media-folders', { name })
+    newFolderName.value = ''
+    showNewFolderInput.value = false
+    await fetchFolders()
+  } catch (err: any) {
+    uploadError.value = err.response?.data?.error ?? 'Could not create folder'
+  }
+}
+
+function cancelNewFolder() {
+  newFolderName.value = ''
+  showNewFolderInput.value = false
+}
+
+async function deleteFolder(name: string) {
+  try {
+    await axios.delete(`/api/media-folders/${encodeURIComponent(name)}`)
+    if (activeFolder.value === name) activeFolder.value = null
+    await Promise.all([fetchFolders(), fetchLibrary()])
+  } catch (err: any) {
+    uploadError.value = err.response?.data?.error ?? 'Could not delete folder'
+  }
+}
+
+async function moveFileTo(file: MediaFile, folder: string | null) {
+  try {
+    await axios.patch(`/api/media/${file.filename}`, { folder })
+    file.folder = folder
+    movingFileId.value = null
+    await fetchFolders()
+    if (activeFolder.value !== null) await fetchLibrary()
+  } catch (err: any) {
+    uploadError.value = err.response?.data?.error ?? 'Move failed'
+    movingFileId.value = null
+  }
+}
+
 async function handleFiles(event: Event) {
   const selected = Array.from((event.target as HTMLInputElement).files ?? [])
   if (selected.length) await uploadFiles(selected)
@@ -228,10 +493,14 @@ async function uploadFiles(fileList: File[]) {
     try {
       const form = new FormData()
       form.append('file', file)
-      const res = await axios.post('/api/upload', form, {
+      const params: Record<string, string> = {}
+      if (activeFolder.value && activeFolder.value !== '__none__') {
+        params.folder = activeFolder.value
+      }
+      await axios.post('/api/upload', form, {
         headers: { 'Content-Type': 'multipart/form-data' },
+        params,
       })
-      files.value.unshift({ ...res.data, _id: Date.now().toString(), originalName: file.name, uploadedAt: new Date().toISOString() })
     } catch (err: any) {
       uploadError.value = `${file.name}: ${err.response?.data?.error ?? 'Upload failed'}`
     }
@@ -239,8 +508,7 @@ async function uploadFiles(fileList: File[]) {
 
   uploading.value = false
   uploadStatus.value = ''
-  // Refresh to get server-side records (with real _ids)
-  await fetchLibrary()
+  await Promise.all([fetchFolders(), fetchLibrary()])
 }
 
 function confirmDelete(file: MediaFile) {
@@ -254,6 +522,7 @@ async function doDelete() {
     await axios.delete(`/api/media/${deleteTarget.value.filename}`)
     files.value = files.value.filter((f) => f.filename !== deleteTarget.value!.filename)
     deleteTarget.value = null
+    await fetchFolders()
   } catch (err: any) {
     uploadError.value = err.response?.data?.error ?? 'Delete failed'
     deleteTarget.value = null