Benjamin Harris 1 місяць тому
батько
коміт
7814080188

+ 3 - 0
docker-compose.yml

@@ -6,6 +6,7 @@ services:
       - "8081:80"
       - "8081:80"
     volumes:
     volumes:
       - ./nginx.conf:/etc/nginx/nginx.conf:ro
       - ./nginx.conf:/etc/nginx/nginx.conf:ro
+      - media_uploads:/media:ro
     networks:
     networks:
       - socialMediaManagerNetwork
       - socialMediaManagerNetwork
     depends_on:
     depends_on:
@@ -66,6 +67,7 @@ services:
       - ./services/utils:/services/gateway/utils
       - ./services/utils:/services/gateway/utils
       - ./services/gateway:/services/gateway
       - ./services/gateway:/services/gateway
       - gateway_modules:/services/gateway/node_modules
       - gateway_modules:/services/gateway/node_modules
+      - media_uploads:/uploads
     env_file: .env
     env_file: .env
     networks:
     networks:
       - socialMediaManagerNetwork
       - socialMediaManagerNetwork
@@ -231,6 +233,7 @@ networks:
 volumes:
 volumes:
   mongodb-data:
   mongodb-data:
   redis-data:
   redis-data:
+  media_uploads:
   gateway_modules:
   gateway_modules:
   socket_modules:
   socket_modules:
   formatter_modules:
   formatter_modules:

+ 10 - 0
nginx.conf

@@ -4,6 +4,9 @@ http {
   server {
   server {
     listen 80;
     listen 80;
     server_name localhost;
     server_name localhost;
+
+    # Allow uploads up to 100 MB
+    client_max_body_size 100m;
     
     
     location / {
     location / {
         proxy_pass http://ui:5173;
         proxy_pass http://ui:5173;
@@ -29,6 +32,13 @@ http {
       proxy_pass  http://socket:8084/socket.io/;
       proxy_pass  http://socket:8084/socket.io/;
     }
     }
     
     
+    # Serve uploaded media files directly from the shared volume
+    location /media/ {
+      alias /media/;
+      add_header Cache-Control "public, max-age=31536000, immutable";
+      try_files $uri =404;
+    }
+
     location /api/ {
     location /api/ {
       rewrite ^/api/(.*) /$1 break;
       rewrite ^/api/(.*) /$1 break;
       proxy_pass http://gateway:8084;
       proxy_pass http://gateway:8084;

+ 1 - 0
services/gateway/package.json

@@ -8,6 +8,7 @@
         "test": "jest --forceExit"
         "test": "jest --forceExit"
     },
     },
     "dependencies": {
     "dependencies": {
+        "@fastify/multipart": "^8.3.0",
         "amqplib": "^0.10.3",
         "amqplib": "^0.10.3",
         "axios": "^1.6.7",
         "axios": "^1.6.7",
         "dotenv": "^16.3.1",
         "dotenv": "^16.3.1",

+ 88 - 0
services/gateway/server.js

@@ -1,9 +1,22 @@
 require('dotenv').config();
 require('dotenv').config();
 const app = require('fastify')({ logger: false });
 const app = require('fastify')({ logger: false });
+const multipart = require('@fastify/multipart');
 const axios = require('axios');
 const axios = require('axios');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { pipeline } = require('stream/promises');
 const { getDb } = require('./utils/MongoDBConnector');
 const { getDb } = require('./utils/MongoDBConnector');
 const RabbitMQProducer = require('./utils/RabbitMQProducer');
 const RabbitMQProducer = require('./utils/RabbitMQProducer');
 
 
+const UPLOAD_DIR = process.env.UPLOAD_DIR || '/uploads';
+const ALLOWED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.mp4', '.mov', '.avi']);
+const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
+
+fs.mkdirSync(UPLOAD_DIR, { recursive: true });
+
+app.register(multipart, { limits: { fileSize: MAX_FILE_SIZE } });
+
 const GRAPH_API = 'https://graph.facebook.com/v22.0';
 const GRAPH_API = 'https://graph.facebook.com/v22.0';
 
 
 // The public base URL of this app (used for OAuth redirect_uri)
 // The public base URL of this app (used for OAuth redirect_uri)
@@ -42,6 +55,81 @@ async function deleteCredentials(id) {
   await db.collection('platform_credentials').deleteOne({ _id: id });
   await db.collection('platform_credentials').deleteOne({ _id: id });
 }
 }
 
 
+// ─── Media Upload & Library ───────────────────────────────────────────────────
+
+app.post('/upload', async (request, reply) => {
+  const data = await request.file();
+  if (!data) return reply.code(400).send({ error: 'No file provided' });
+
+  const ext = path.extname(data.filename).toLowerCase();
+  if (!ALLOWED_EXTENSIONS.has(ext)) {
+    data.file.resume();
+    return reply.code(400).send({ error: `File type "${ext}" is not allowed. Allowed: jpg, jpeg, png, gif, webp, mp4, mov, avi` });
+  }
+
+  const filename = `${crypto.randomUUID()}${ext}`;
+  const filepath = path.join(UPLOAD_DIR, filename);
+
+  try {
+    await pipeline(data.file, fs.createWriteStream(filepath));
+  } catch (err) {
+    console.error('[Gateway] Upload write error:', err.message);
+    return reply.code(500).send({ error: 'Failed to save file' });
+  }
+
+  const stat = fs.statSync(filepath);
+  const record = {
+    filename,
+    originalName: data.filename,
+    url: `/media/${filename}`,
+    mimetype: data.mimetype,
+    size: stat.size,
+    uploadedAt: new Date(),
+  };
+
+  try {
+    const db = await getDb();
+    await db.collection('media_files').insertOne(record);
+  } catch (err) {
+    console.error('[Gateway] Media metadata save error:', err.message);
+  }
+
+  return { url: record.url, filename, originalName: data.filename, mimetype: data.mimetype, size: stat.size };
+});
+
+// List all uploaded media files, newest first
+app.get('/media-library', async () => {
+  const db = await getDb();
+  const files = await db.collection('media_files').find({}).sort({ uploadedAt: -1 }).toArray();
+  return { files };
+});
+
+// Delete a media file from disk and database
+app.delete('/media/:filename', async (request, reply) => {
+  const { filename } = request.params;
+
+  // Prevent path traversal
+  if (!filename || filename.includes('/') || filename.includes('..') || filename.includes('\0')) {
+    return reply.code(400).send({ error: 'Invalid filename' });
+  }
+
+  const filepath = path.join(UPLOAD_DIR, filename);
+  try {
+    fs.unlinkSync(filepath);
+  } catch (err) {
+    if (err.code !== 'ENOENT') {
+      console.error('[Gateway] Delete error:', err.message);
+      return reply.code(500).send({ error: 'Failed to delete file' });
+    }
+    // Already gone from disk — still clean up DB record
+  }
+
+  const db = await getDb();
+  await db.collection('media_files').deleteOne({ filename });
+
+  return { success: true };
+});
+
 // ─── Platform service URLs ────────────────────────────────────────────────────
 // ─── Platform service URLs ────────────────────────────────────────────────────
 
 
 const PLATFORM_SERVICES = {
 const PLATFORM_SERVICES = {

+ 1 - 0
ui/src/components/NavBar.vue

@@ -63,6 +63,7 @@ const showLangMenu = ref(false)
 const navLinks = [
 const navLinks = [
   { to: '/dashboard', label: 'nav.feed' },
   { to: '/dashboard', label: 'nav.feed' },
   { to: '/compose',   label: 'nav.compose' },
   { to: '/compose',   label: 'nav.compose' },
+  { to: '/media',     label: 'nav.media' },
   { to: '/scheduler', label: 'nav.scheduler' },
   { to: '/scheduler', label: 'nav.scheduler' },
   { to: '/settings',  label: 'nav.settings' },
   { to: '/settings',  label: 'nav.settings' },
 ]
 ]

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

@@ -2,10 +2,28 @@ export default {
   nav: {
   nav: {
     feed: 'Feed',
     feed: 'Feed',
     compose: 'New Post',
     compose: 'New Post',
+    media: 'Media',
     scheduler: 'Scheduler',
     scheduler: 'Scheduler',
     settings: 'Settings',
     settings: 'Settings',
   },
   },
 
 
+  media: {
+    title: 'Media Library',
+    fileCount: '{count} files',
+    upload: 'Upload',
+    uploading: 'Uploading…',
+    dropZoneTitle: 'Drop files here or click Upload',
+    dropZoneHint: 'Supports JPEG, PNG, GIF, WebP, MP4, MOV — up to 100 MB each',
+    useInPost: 'Use in Post',
+    copyUrl: 'Copy URL',
+    copied: 'Copied!',
+    deleteConfirmTitle: 'Delete this file?',
+    deleteConfirmHint: 'This cannot be undone. Any posts using this file will lose the media.',
+    cancel: 'Cancel',
+    delete: 'Delete',
+    deleting: 'Deleting…',
+  },
+
   dashboard: {
   dashboard: {
     platforms: 'Platforms',
     platforms: 'Platforms',
     tags: 'Tags',
     tags: 'Tags',
@@ -30,9 +48,15 @@ export default {
     successMessage: 'Post sent successfully.',
     successMessage: 'Post sent successfully.',
     scheduleTitle: 'Schedule post (leave empty to post now)',
     scheduleTitle: 'Schedule post (leave empty to post now)',
     preview: 'Preview',
     preview: 'Preview',
-    addMedia: 'Add image / video URL',
+    addMedia: 'Photo / Video',
+    uploadFile: 'Upload a photo or video from your device',
+    uploading: 'Uploading…',
+    uploadFailed: 'Upload failed. Please try again.',
+    pasteUrl: 'or paste a URL',
+    cancelUrl: 'cancel',
     mediaUrlPlaceholder: 'Paste image or video URL, then press Enter…',
     mediaUrlPlaceholder: 'Paste image or video URL, then press Enter…',
-    igImageRequired: 'Instagram requires an image or video URL.',
+    mediaLoadError: 'Could not load this URL — check it is publicly accessible.',
+    igImageRequired: 'Instagram requires an image or video.',
     noDestinations: 'No platforms configured.',
     noDestinations: 'No platforms configured.',
     goToSettings: 'Go to Settings →',
     goToSettings: 'Go to Settings →',
   },
   },

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

@@ -2,10 +2,28 @@ export default {
   nav: {
   nav: {
     feed: 'Akış',
     feed: 'Akış',
     compose: 'Yeni Gönderi',
     compose: 'Yeni Gönderi',
+    media: 'Medya',
     scheduler: 'Zamanlama',
     scheduler: 'Zamanlama',
     settings: 'Ayarlar',
     settings: 'Ayarlar',
   },
   },
 
 
+  media: {
+    title: 'Medya Kütüphanesi',
+    fileCount: '{count} dosya',
+    upload: 'Yükle',
+    uploading: 'Yükleniyor…',
+    dropZoneTitle: 'Dosyaları buraya bırak veya Yükle\'ye tıkla',
+    dropZoneHint: 'JPEG, PNG, GIF, WebP, MP4, MOV desteklenir — her biri en fazla 100 MB',
+    useInPost: 'Gönderide Kullan',
+    copyUrl: 'URL Kopyala',
+    copied: 'Kopyalandı!',
+    deleteConfirmTitle: 'Bu dosya silinsin mi?',
+    deleteConfirmHint: 'Bu işlem geri alınamaz. Bu dosyayı kullanan gönderiler medyasını kaybeder.',
+    cancel: 'İptal',
+    delete: 'Sil',
+    deleting: 'Siliniyor…',
+  },
+
   dashboard: {
   dashboard: {
     platforms: 'Platformlar',
     platforms: 'Platformlar',
     tags: 'Etiketler',
     tags: 'Etiketler',
@@ -30,9 +48,15 @@ export default {
     successMessage: 'Gönderi başarıyla gönderildi.',
     successMessage: 'Gönderi başarıyla gönderildi.',
     scheduleTitle: 'Zamanlama (boş bırakırsan hemen gönderilir)',
     scheduleTitle: 'Zamanlama (boş bırakırsan hemen gönderilir)',
     preview: 'Önizleme',
     preview: 'Önizleme',
-    addMedia: 'Görsel / video URL\'si ekle',
+    addMedia: 'Fotoğraf / Video',
+    uploadFile: 'Cihazından bir fotoğraf veya video yükle',
+    uploading: 'Yükleniyor…',
+    uploadFailed: 'Yükleme başarısız. Lütfen tekrar dene.',
+    pasteUrl: 'veya URL yapıştır',
+    cancelUrl: 'iptal',
     mediaUrlPlaceholder: 'Görsel veya video URL\'sini yapıştır, Enter\'a bas…',
     mediaUrlPlaceholder: 'Görsel veya video URL\'sini yapıştır, Enter\'a bas…',
-    igImageRequired: 'Instagram için görsel veya video URL\'si zorunludur.',
+    mediaLoadError: 'Bu URL yüklenemedi — herkese açık olduğunu kontrol et.',
+    igImageRequired: 'Instagram için görsel veya video zorunludur.',
     noDestinations: 'Hiçbir platform yapılandırılmamış.',
     noDestinations: 'Hiçbir platform yapılandırılmamış.',
     goToSettings: 'Ayarlara git →',
     goToSettings: 'Ayarlara git →',
   },
   },

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

@@ -22,6 +22,11 @@ const router = createRouter({
       name: 'scheduler',
       name: 'scheduler',
       component: () => import('../views/Scheduler.vue'),
       component: () => import('../views/Scheduler.vue'),
     },
     },
+    {
+      path: '/media',
+      name: 'media',
+      component: () => import('../views/Media.vue'),
+    },
     {
     {
       path: '/settings',
       path: '/settings',
       name: 'settings',
       name: 'settings',

+ 130 - 37
ui/src/views/Compose.vue

@@ -66,46 +66,93 @@
             class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed p-4"
             class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed p-4"
           ></textarea>
           ></textarea>
 
 
-          <!-- Media preview -->
+          <!-- Media: attached file preview -->
           <div v-if="composeStore.mediaUrl.trim()" class="px-4 pb-3">
           <div v-if="composeStore.mediaUrl.trim()" class="px-4 pb-3">
-            <div class="relative inline-block">
+            <div class="relative inline-block group">
+              <!-- Image preview -->
               <img
               <img
+                v-if="isImage(composeStore.mediaUrl)"
                 :src="composeStore.mediaUrl"
                 :src="composeStore.mediaUrl"
                 class="rounded-lg max-h-48 max-w-full object-cover border border-gray-700"
                 class="rounded-lg max-h-48 max-w-full object-cover border border-gray-700"
-                @error="mediaError = true"
+                @error="mediaLoadError = true"
               />
               />
+              <!-- Video preview -->
+              <div
+                v-else
+                class="flex items-center gap-3 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5"
+              >
+                <svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                  <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-300 truncate max-w-xs">{{ mediaFilename }}</span>
+              </div>
               <button
               <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"
+                @click="removeMedia"
+                class="absolute -top-2 -right-2 w-5 h-5 bg-gray-700 hover:bg-red-600 rounded-full flex items-center justify-center text-xs transition-colors"
+                title="Remove"
               >✕</button>
               >✕</button>
             </div>
             </div>
-            <p v-if="mediaError" class="text-xs text-red-400 mt-1">Could not load this image URL.</p>
+            <p v-if="mediaLoadError" class="text-xs text-red-400 mt-1">{{ $t('compose.mediaLoadError') }}</p>
+          </div>
+
+          <!-- Upload progress -->
+          <div v-if="uploading" class="px-4 pb-3 flex items-center gap-2 text-sm text-gray-400">
+            <svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
+              <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+              <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
+            </svg>
+            {{ $t('compose.uploading') }}
           </div>
           </div>
 
 
-          <!-- Media URL input (shown when toolbar button clicked) -->
-          <div v-if="showMediaInput && !composeStore.mediaUrl.trim()" class="px-4 pb-3">
+          <!-- Upload error -->
+          <div v-if="uploadError" class="px-4 pb-3 text-xs text-red-400">{{ uploadError }}</div>
+
+          <!-- Paste-URL fallback input -->
+          <div v-if="showUrlInput && !composeStore.mediaUrl.trim() && !uploading" class="px-4 pb-3">
             <input
             <input
-              v-model="mediaInputValue"
-              @keydown.enter="applyMedia"
-              @blur="applyMedia"
+              v-model="pasteUrlValue"
+              @keydown.enter="applyPastedUrl"
+              @blur="applyPastedUrl"
               type="url"
               type="url"
               :placeholder="$t('compose.mediaUrlPlaceholder')"
               :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"
               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"
+              ref="urlInputRef"
             />
             />
           </div>
           </div>
 
 
+          <!-- Hidden file input -->
+          <input
+            ref="fileInputRef"
+            type="file"
+            accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/x-msvideo"
+            class="hidden"
+            @change="handleFileChange"
+          />
+
           <!-- Toolbar -->
           <!-- Toolbar -->
           <div class="flex items-center gap-2 px-4 py-2.5 border-t border-gray-800">
           <div class="flex items-center gap-2 px-4 py-2.5 border-t border-gray-800">
+            <!-- Upload file button -->
             <button
             <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')"
+              @click="fileInputRef?.click()"
+              :disabled="uploading"
+              class="flex items-center gap-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors disabled:opacity-40 px-2 py-1 rounded hover:bg-gray-800"
+              :class="composeStore.mediaUrl ? 'text-blue-400' : ''"
+              :title="$t('compose.uploadFile')"
             >
             >
               <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
               <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" />
                 <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>
               </svg>
+              {{ $t('compose.addMedia') }}
+            </button>
+
+            <!-- Paste URL toggle -->
+            <button
+              v-if="!composeStore.mediaUrl && !uploading"
+              @click="toggleUrlInput"
+              class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
+              :class="showUrlInput ? 'text-blue-400' : ''"
+            >
+              {{ showUrlInput ? $t('compose.cancelUrl') : $t('compose.pasteUrl') }}
             </button>
             </button>
 
 
             <span class="ml-auto text-xs font-mono" :class="overLimit ? 'text-red-400' : charNearLimit ? 'text-amber-400' : 'text-gray-600'">
             <span class="ml-auto text-xs font-mono" :class="overLimit ? 'text-red-400' : charNearLimit ? 'text-amber-400' : 'text-gray-600'">
@@ -177,8 +224,9 @@
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import { ref, computed, watch, nextTick, onMounted } from 'vue'
 import { ref, computed, watch, nextTick, onMounted } from 'vue'
-import { useRouter } from 'vue-router'
+import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
+import axios from 'axios'
 import { useComposeStore } from '../stores/compose'
 import { useComposeStore } from '../stores/compose'
 import { usePlatformsStore } from '../stores/platforms'
 import { usePlatformsStore } from '../stores/platforms'
 import PostPreview from '../components/compose/PostPreview.vue'
 import PostPreview from '../components/compose/PostPreview.vue'
@@ -187,11 +235,15 @@ const { t } = useI18n()
 const composeStore = useComposeStore()
 const composeStore = useComposeStore()
 const platformsStore = usePlatformsStore()
 const platformsStore = usePlatformsStore()
 const router = useRouter()
 const router = useRouter()
-
-const showMediaInput = ref(false)
-const mediaInputValue = ref('')
-const mediaInputRef = ref<HTMLInputElement | null>(null)
-const mediaError = ref(false)
+const route = useRoute()
+
+const fileInputRef = ref<HTMLInputElement | null>(null)
+const urlInputRef = ref<HTMLInputElement | null>(null)
+const pasteUrlValue = ref('')
+const showUrlInput = ref(false)
+const uploading = ref(false)
+const uploadError = ref('')
+const mediaLoadError = ref(false)
 const activePreviewKey = ref('')
 const activePreviewKey = ref('')
 
 
 onMounted(async () => {
 onMounted(async () => {
@@ -200,6 +252,12 @@ onMounted(async () => {
     platformsStore.fetchMetaConnections(),
     platformsStore.fetchMetaConnections(),
   ])
   ])
   composeStore.initDestinations()
   composeStore.initDestinations()
+
+  // Pre-fill media URL when arriving from the Media Library ("Use in Post")
+  if (route.query.media) {
+    composeStore.mediaUrl = String(route.query.media)
+    mediaLoadError.value = false
+  }
 })
 })
 
 
 // Keep activePreviewKey pointed at a selected destination
 // Keep activePreviewKey pointed at a selected destination
@@ -215,33 +273,68 @@ watch(
 
 
 function toggle(key: string) {
 function toggle(key: string) {
   composeStore.toggleDestination(key)
   composeStore.toggleDestination(key)
-  // Set preview to the newly selected destination
   const dest = composeStore.destinations.find((d) => d.key === key)
   const dest = composeStore.destinations.find((d) => d.key === key)
   if (dest?.selected) activePreviewKey.value = key
   if (dest?.selected) activePreviewKey.value = key
 }
 }
 
 
-async function toggleMediaInput() {
-  if (composeStore.mediaUrl.trim()) {
-    composeStore.mediaUrl = ''
-    mediaError.value = false
-    return
+async function handleFileChange(event: Event) {
+  const file = (event.target as HTMLInputElement).files?.[0]
+  if (!file) return
+
+  uploading.value = true
+  uploadError.value = ''
+  mediaLoadError.value = false
+
+  try {
+    const form = new FormData()
+    form.append('file', file)
+    const res = await axios.post('/api/upload', form, {
+      headers: { 'Content-Type': 'multipart/form-data' },
+    })
+    composeStore.mediaUrl = res.data.url
+  } catch (err: any) {
+    uploadError.value = err.response?.data?.error ?? t('compose.uploadFailed')
+  } finally {
+    uploading.value = false
+    // Reset file input so the same file can be re-selected if needed
+    if (fileInputRef.value) fileInputRef.value.value = ''
   }
   }
-  showMediaInput.value = !showMediaInput.value
-  if (showMediaInput.value) {
+}
+
+async function toggleUrlInput() {
+  showUrlInput.value = !showUrlInput.value
+  uploadError.value = ''
+  if (showUrlInput.value) {
     await nextTick()
     await nextTick()
-    mediaInputRef.value?.focus()
+    urlInputRef.value?.focus()
   }
   }
 }
 }
 
 
-function applyMedia() {
-  if (mediaInputValue.value.trim()) {
-    composeStore.mediaUrl = mediaInputValue.value.trim()
-    mediaInputValue.value = ''
-    showMediaInput.value = false
-    mediaError.value = false
+function applyPastedUrl() {
+  const url = pasteUrlValue.value.trim()
+  if (url) {
+    composeStore.mediaUrl = url
+    pasteUrlValue.value = ''
+    showUrlInput.value = false
+    mediaLoadError.value = false
   }
   }
 }
 }
 
 
+function removeMedia() {
+  composeStore.mediaUrl = ''
+  mediaLoadError.value = false
+  uploadError.value = ''
+  showUrlInput.value = false
+}
+
+function isImage(url: string) {
+  return /\.(jpe?g|png|gif|webp)(\?.*)?$/i.test(url)
+}
+
+const mediaFilename = computed(() => {
+  try { return decodeURIComponent(composeStore.mediaUrl.split('/').pop() ?? '') } catch { return composeStore.mediaUrl }
+})
+
 const igSelectedWithoutMedia = computed(() =>
 const igSelectedWithoutMedia = computed(() =>
   composeStore.selectedDestinations.some((d) => d.platform === 'instagram') &&
   composeStore.selectedDestinations.some((d) => d.platform === 'instagram') &&
   !composeStore.mediaUrl.trim()
   !composeStore.mediaUrl.trim()

+ 288 - 0
ui/src/views/Media.vue

@@ -0,0 +1,288 @@
+<template>
+  <div class="flex flex-col h-screen overflow-hidden bg-gray-950 text-gray-100">
+
+    <!-- Header toolbar -->
+    <header class="flex items-center gap-4 px-6 py-3 bg-gray-900 border-b border-gray-800 flex-shrink-0">
+      <h1 class="text-base font-bold flex-1">{{ $t('media.title') }}</h1>
+
+      <span class="text-xs text-gray-500">{{ $t('media.fileCount', { count: files.length }) }}</span>
+
+      <!-- Upload button -->
+      <button
+        @click="fileInputRef?.click()"
+        :disabled="uploading"
+        class="flex items-center gap-2 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+      >
+        <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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
+        </svg>
+        {{ uploading ? $t('media.uploading') : $t('media.upload') }}
+      </button>
+
+      <!-- Hidden multi-file input -->
+      <input
+        ref="fileInputRef"
+        type="file"
+        accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/x-msvideo"
+        multiple
+        class="hidden"
+        @change="handleFiles"
+      />
+    </header>
+
+    <!-- Upload progress bar -->
+    <div v-if="uploading" class="flex-shrink-0 bg-blue-900/30 border-b border-blue-800/40 px-6 py-2 text-xs text-blue-300 flex items-center gap-3">
+      <svg class="w-3.5 h-3.5 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
+        <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+        <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
+      </svg>
+      {{ uploadStatus }}
+    </div>
+
+    <!-- Upload error -->
+    <div v-if="uploadError" class="flex-shrink-0 bg-red-900/30 border-b border-red-800/40 px-6 py-2 text-xs text-red-300 flex items-center justify-between">
+      <span>{{ uploadError }}</span>
+      <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>
+
+    <!-- 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>
+
+    <!-- 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"
+        >
+          <!-- 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 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>
+
+            <!-- Action buttons -->
+            <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.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>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- Delete confirmation modal -->
+    <div v-if="deleteTarget" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
+      <div class="bg-gray-900 border border-gray-700 rounded-2xl p-6 max-w-sm w-full shadow-2xl">
+        <h2 class="font-semibold text-white mb-2">{{ $t('media.deleteConfirmTitle') }}</h2>
+        <p class="text-sm text-gray-400 mb-1 truncate">{{ deleteTarget.originalName }}</p>
+        <p class="text-xs text-gray-600 mb-5">{{ $t('media.deleteConfirmHint') }}</p>
+        <div class="flex gap-3 justify-end">
+          <button
+            @click="deleteTarget = null"
+            class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors"
+          >{{ $t('media.cancel') }}</button>
+          <button
+            @click="doDelete"
+            :disabled="deleting"
+            class="px-4 py-2 bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg text-sm font-medium text-white transition-colors"
+          >{{ deleting ? $t('media.deleting') : $t('media.delete') }}</button>
+        </div>
+      </div>
+    </div>
+
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import axios from 'axios'
+
+interface MediaFile {
+  _id: string
+  filename: string
+  originalName: string
+  url: string
+  mimetype: string
+  size: number
+  uploadedAt: string
+}
+
+const router = useRouter()
+
+const files = ref<MediaFile[]>([])
+const loading = ref(true)
+const uploading = ref(false)
+const uploadStatus = ref('')
+const uploadError = ref('')
+const deleting = ref(false)
+const deleteTarget = ref<MediaFile | null>(null)
+const copied = ref('')
+const fileInputRef = ref<HTMLInputElement | null>(null)
+
+onMounted(fetchLibrary)
+
+async function fetchLibrary() {
+  loading.value = true
+  try {
+    const res = await axios.get('/api/media-library')
+    files.value = res.data.files
+  } catch {
+    // silently fail — empty grid shown
+  } finally {
+    loading.value = false
+  }
+}
+
+async function handleFiles(event: Event) {
+  const selected = Array.from((event.target as HTMLInputElement).files ?? [])
+  if (selected.length) await uploadFiles(selected)
+  if (fileInputRef.value) fileInputRef.value.value = ''
+}
+
+function handleDrop(event: DragEvent) {
+  const dropped = Array.from(event.dataTransfer?.files ?? [])
+  if (dropped.length) uploadFiles(dropped)
+}
+
+async function uploadFiles(fileList: File[]) {
+  uploading.value = true
+  uploadError.value = ''
+  const total = fileList.length
+
+  for (let i = 0; i < fileList.length; i++) {
+    const file = fileList[i]
+    uploadStatus.value = total > 1
+      ? `Uploading ${i + 1} of ${total}: ${file.name}`
+      : `Uploading ${file.name}…`
+
+    try {
+      const form = new FormData()
+      form.append('file', file)
+      const res = await axios.post('/api/upload', form, {
+        headers: { 'Content-Type': 'multipart/form-data' },
+      })
+      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'}`
+    }
+  }
+
+  uploading.value = false
+  uploadStatus.value = ''
+  // Refresh to get server-side records (with real _ids)
+  await fetchLibrary()
+}
+
+function confirmDelete(file: MediaFile) {
+  deleteTarget.value = file
+}
+
+async function doDelete() {
+  if (!deleteTarget.value) return
+  deleting.value = true
+  try {
+    await axios.delete(`/api/media/${deleteTarget.value.filename}`)
+    files.value = files.value.filter((f) => f.filename !== deleteTarget.value!.filename)
+    deleteTarget.value = null
+  } catch (err: any) {
+    uploadError.value = err.response?.data?.error ?? 'Delete failed'
+    deleteTarget.value = null
+  } finally {
+    deleting.value = false
+  }
+}
+
+async function copyUrl(url: string) {
+  try {
+    await navigator.clipboard.writeText(`${window.location.origin}${url}`)
+    copied.value = url
+    setTimeout(() => { copied.value = '' }, 2000)
+  } catch {
+    // fallback: select the text
+  }
+}
+
+function useInPost(url: string) {
+  router.push({ path: '/compose', query: { media: url } })
+}
+
+function isImage(mimetype: string) {
+  return mimetype.startsWith('image/')
+}
+
+function formatSize(bytes: number): string {
+  if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB`
+  if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)} KB`
+  return `${bytes} B`
+}
+</script>