Bladeren bron

Save & Edit Draft Posts

Benjamin Harris 1 maand geleden
bovenliggende
commit
ae4f74a590
7 gewijzigde bestanden met toevoegingen van 371 en 52 verwijderingen
  1. 7 0
      .claude/settings.local.json
  2. 54 1
      services/gateway/server.js
  3. 10 1
      ui/src/locales/en.ts
  4. 10 1
      ui/src/locales/tr.ts
  5. 54 2
      ui/src/stores/compose.ts
  6. 32 0
      ui/src/views/Compose.vue
  7. 204 47
      ui/src/views/Scheduler.vue

+ 7 - 0
.claude/settings.local.json

@@ -0,0 +1,7 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(dir \"d:\\\\GIT_REPO\\\\social-media-manager\\\\services\\\\gateway\" /s /b)"
+    ]
+  }
+}

+ 54 - 1
services/gateway/server.js

@@ -6,6 +6,7 @@ const fs = require('fs');
 const path = require('path');
 const crypto = require('crypto');
 const { pipeline } = require('stream/promises');
+const { ObjectId } = require('mongodb');
 const { getDb } = require('./utils/MongoDBConnector');
 const RabbitMQProducer = require('./utils/RabbitMQProducer');
 
@@ -26,7 +27,7 @@ const APP_BASE_URL = process.env.APP_BASE_URL || 'http://localhost:8081';
 
 app.addHook('onSend', async (request, reply) => {
   reply.header('Access-Control-Allow-Origin', '*');
-  reply.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS');
+  reply.header('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
   reply.header('Access-Control-Allow-Headers', 'Content-Type');
 });
 
@@ -130,6 +131,58 @@ app.delete('/media/:filename', async (request, reply) => {
   return { success: true };
 });
 
+// ─── Drafts ──────────────────────────────────────────────────────────────────
+
+app.post('/drafts', async (request, reply) => {
+  const { content = '', mediaUrl = '', scheduledAt = '', destinations = [] } = request.body || {};
+  const db = await getDb();
+  const now = new Date();
+  const result = await db.collection('drafts').insertOne({
+    content, mediaUrl, scheduledAt, destinations, createdAt: now, updatedAt: now,
+  });
+  const draft = await db.collection('drafts').findOne({ _id: result.insertedId });
+  return reply.code(201).send(draft);
+});
+
+app.get('/drafts', async () => {
+  const db = await getDb();
+  const drafts = await db.collection('drafts').find({}).sort({ updatedAt: -1 }).toArray();
+  return { drafts };
+});
+
+app.get('/drafts/:id', async (request, reply) => {
+  const { id } = request.params;
+  let oid;
+  try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
+  const db = await getDb();
+  const draft = await db.collection('drafts').findOne({ _id: oid });
+  if (!draft) return reply.code(404).send({ error: 'Draft not found' });
+  return draft;
+});
+
+app.put('/drafts/:id', async (request, reply) => {
+  const { id } = request.params;
+  let oid;
+  try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
+  const { content = '', mediaUrl = '', scheduledAt = '', destinations = [] } = request.body || {};
+  const db = await getDb();
+  const result = await db.collection('drafts').updateOne(
+    { _id: oid },
+    { $set: { content, mediaUrl, scheduledAt, destinations, updatedAt: new Date() } }
+  );
+  if (!result.matchedCount) return reply.code(404).send({ error: 'Draft not found' });
+  return { success: true };
+});
+
+app.delete('/drafts/:id', async (request, reply) => {
+  const { id } = request.params;
+  let oid;
+  try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid draft ID' }); }
+  const db = await getDb();
+  await db.collection('drafts').deleteOne({ _id: oid });
+  return { success: true };
+});
+
 // ─── Platform service URLs ────────────────────────────────────────────────────
 
 const PLATFORM_SERVICES = {

+ 10 - 1
ui/src/locales/en.ts

@@ -59,11 +59,15 @@ export default {
     igImageRequired: 'Instagram requires an image or video.',
     noDestinations: 'No platforms configured.',
     goToSettings: 'Go to Settings →',
+    saveDraft: 'Save Draft',
+    updateDraft: 'Update Draft',
+    savingDraft: 'Saving…',
+    draftSaved: 'Draft saved.',
   },
 
   scheduler: {
     title: 'Scheduler',
-    newSchedule: '+ New Schedule',
+    newSchedule: '+ New Post',
     noJobs: 'No scheduled posts.',
     statuses: {
       pending: 'Pending',
@@ -72,6 +76,11 @@ export default {
       cancelled: 'Cancelled',
     },
     cancel: 'Cancel',
+    scheduledTab: 'Scheduled',
+    draftsTab: 'Drafts',
+    noDrafts: 'No saved drafts.',
+    editDraft: 'Edit',
+    deleteDraft: 'Delete',
   },
 
   settings: {

+ 10 - 1
ui/src/locales/tr.ts

@@ -59,11 +59,15 @@ export default {
     igImageRequired: 'Instagram için görsel veya video zorunludur.',
     noDestinations: 'Hiçbir platform yapılandırılmamış.',
     goToSettings: 'Ayarlara git →',
+    saveDraft: 'Taslak Kaydet',
+    updateDraft: 'Taslağı Güncelle',
+    savingDraft: 'Kaydediliyor…',
+    draftSaved: 'Taslak kaydedildi.',
   },
 
   scheduler: {
     title: 'Zamanlama',
-    newSchedule: '+ Yeni Zamanla',
+    newSchedule: '+ Yeni Gönderi',
     noJobs: 'Zamanlanmış gönderi yok.',
     statuses: {
       pending: 'Bekleyen',
@@ -72,6 +76,11 @@ export default {
       cancelled: 'İptal',
     },
     cancel: 'İptal',
+    scheduledTab: 'Zamanlanmış',
+    draftsTab: 'Taslaklar',
+    noDrafts: 'Kayıtlı taslak yok.',
+    editDraft: 'Düzenle',
+    deleteDraft: 'Sil',
   },
 
   settings: {

+ 54 - 2
ui/src/stores/compose.ts

@@ -23,12 +23,24 @@ const CHAR_LIMITS: Record<string, number> = {
 
 const STANDARD_PLATFORMS = ['twitter', 'mastodon', 'bluesky', 'linkedin', 'reddit', 'youtube']
 
+export interface Draft {
+  _id: string
+  content: string
+  mediaUrl: string
+  scheduledAt: string
+  destinations: Destination[]
+  createdAt: string
+  updatedAt: string
+}
+
 export const useComposeStore = defineStore('compose', () => {
   const content = ref('')
   const mediaUrl = ref('')
   const scheduledAt = ref('')
   const destinations = ref<Destination[]>([])
   const sending = ref(false)
+  const savingDraft = ref(false)
+  const draftId = ref<string | null>(null)
   const lastResult = ref<Record<string, unknown> | null>(null)
 
   function charLimit(platform: string): number {
@@ -95,10 +107,50 @@ export const useComposeStore = defineStore('compose', () => {
     content.value = ''
     mediaUrl.value = ''
     scheduledAt.value = ''
+    draftId.value = null
     destinations.value.forEach((d) => { d.selected = false })
     lastResult.value = null
   }
 
+  function loadDraft(draft: Draft) {
+    draftId.value = String(draft._id)
+    content.value = draft.content || ''
+    mediaUrl.value = draft.mediaUrl || ''
+    scheduledAt.value = draft.scheduledAt || ''
+    if (draft.destinations?.length) {
+      destinations.value.forEach((d) => {
+        const saved = draft.destinations.find((s) => s.key === d.key)
+        d.selected = saved?.selected ?? false
+      })
+    }
+  }
+
+  async function saveDraft() {
+    savingDraft.value = true
+    try {
+      const payload = {
+        content: content.value,
+        mediaUrl: mediaUrl.value,
+        scheduledAt: scheduledAt.value,
+        destinations: destinations.value.map(({ key, platform, accountId, label, color, picture, selected }) => ({
+          key, platform, accountId, label, color, picture, selected,
+        })),
+      }
+      if (draftId.value) {
+        await axios.put(`/api/drafts/${draftId.value}`, payload)
+      } else {
+        const res = await axios.post('/api/drafts', payload)
+        draftId.value = String(res.data._id)
+      }
+      return true
+    } catch (err) {
+      console.error('Save draft error:', err)
+      return false
+    } finally {
+      savingDraft.value = false
+    }
+  }
+
   async function post() {
     const selected = selectedDestinations.value
     if (!content.value.trim() || !selected.length) return
@@ -135,9 +187,9 @@ export const useComposeStore = defineStore('compose', () => {
   }
 
   return {
-    content, mediaUrl, scheduledAt, destinations, sending, lastResult,
+    content, mediaUrl, scheduledAt, destinations, sending, savingDraft, draftId, lastResult,
     selectedDestinations, activeCharLimit,
     charLimit, isOverLimit,
-    initDestinations, toggleDestination, reset, post,
+    initDestinations, toggleDestination, reset, loadDraft, saveDraft, post,
   }
 })

+ 32 - 0
ui/src/views/Compose.vue

@@ -185,6 +185,13 @@
               class="text-gray-600 hover:text-gray-400 text-xs flex-shrink-0"
             >✕</button>
           </div>
+          <button
+            @click="handleSaveDraft"
+            :disabled="composeStore.savingDraft || !composeStore.content.trim()"
+            class="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 flex-shrink-0 bg-gray-700 hover:bg-gray-600 text-gray-200"
+          >
+            {{ composeStore.savingDraft ? $t('compose.savingDraft') : (composeStore.draftId ? $t('compose.updateDraft') : $t('compose.saveDraft')) }}
+          </button>
           <button
             @click="handlePost"
             :disabled="composeStore.sending || !canPost"
@@ -200,6 +207,11 @@
           {{ $t('compose.successMessage') }}
         </div>
 
+        <!-- Draft saved message -->
+        <div v-if="draftSavedBanner" class="bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-gray-300">
+          {{ $t('compose.draftSaved') }}
+        </div>
+
       </div>
     </div>
 
@@ -245,6 +257,7 @@ const uploading = ref(false)
 const uploadError = ref('')
 const mediaLoadError = ref(false)
 const activePreviewKey = ref('')
+const draftSavedBanner = ref(false)
 
 onMounted(async () => {
   await Promise.all([
@@ -258,6 +271,17 @@ onMounted(async () => {
     composeStore.mediaUrl = String(route.query.media)
     mediaLoadError.value = false
   }
+
+  // Load draft when arriving via ?draft=ID
+  if (route.query.draft) {
+    try {
+      const res = await axios.get(`/api/drafts/${route.query.draft}`)
+      composeStore.loadDraft(res.data)
+      mediaLoadError.value = false
+    } catch (err) {
+      console.error('Failed to load draft:', err)
+    }
+  }
 })
 
 // Keep activePreviewKey pointed at a selected destination
@@ -359,6 +383,14 @@ const postButtonLabel = computed(() =>
   composeStore.scheduledAt ? `⏰ ${t('compose.schedule')}` : t('compose.send')
 )
 
+async function handleSaveDraft() {
+  const ok = await composeStore.saveDraft()
+  if (ok) {
+    draftSavedBanner.value = true
+    setTimeout(() => { draftSavedBanner.value = false }, 2500)
+  }
+}
+
 async function handlePost() {
   await composeStore.post()
   if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)

+ 204 - 47
ui/src/views/Scheduler.vue

@@ -11,65 +11,159 @@
         </router-link>
       </div>
 
-      <div class="flex gap-2 mb-6">
+      <!-- Top-level mode tabs -->
+      <div class="flex gap-1 mb-5 bg-gray-900 border border-gray-800 rounded-xl p-1 w-fit">
         <button
-          v-for="s in statusOptions"
-          :key="s.value"
-          @click="activeStatus = s.value"
-          class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors border"
-          :class="activeStatus === s.value
-            ? 'bg-gray-700 border-gray-600 text-white'
-            : 'border-gray-800 text-gray-500 hover:border-gray-600'"
+          @click="mode = 'scheduled'"
+          class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors"
+          :class="mode === 'scheduled' ? 'bg-gray-700 text-white' : 'text-gray-500 hover:text-gray-300'"
         >
-          {{ s.label }}
+          {{ $t('scheduler.scheduledTab') }}
+        </button>
+        <button
+          @click="mode = 'drafts'"
+          class="px-4 py-1.5 rounded-lg text-sm font-medium transition-colors"
+          :class="mode === 'drafts' ? 'bg-gray-700 text-white' : 'text-gray-500 hover:text-gray-300'"
+        >
+          {{ $t('scheduler.draftsTab') }}
+          <span v-if="drafts.length" class="ml-1.5 text-xs bg-gray-600 px-1.5 py-0.5 rounded-full">{{ drafts.length }}</span>
         </button>
       </div>
 
-      <div v-if="loading" class="text-center text-gray-500 mt-20">
-        {{ $t('dashboard.loading') }}
-      </div>
+      <!-- ── Scheduled jobs panel ── -->
+      <template v-if="mode === 'scheduled'">
+        <div class="flex gap-2 mb-6">
+          <button
+            v-for="s in statusOptions"
+            :key="s.value"
+            @click="activeStatus = s.value"
+            class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors border"
+            :class="activeStatus === s.value
+              ? 'bg-gray-700 border-gray-600 text-white'
+              : 'border-gray-800 text-gray-500 hover:border-gray-600'"
+          >
+            {{ s.label }}
+          </button>
+        </div>
 
-      <div v-else-if="!jobs.length" class="text-center text-gray-500 mt-20">
-        <p class="text-4xl mb-4">📅</p>
-        <p>{{ $t('scheduler.noJobs') }}</p>
-      </div>
+        <div v-if="loading" class="text-center text-gray-500 mt-20">
+          {{ $t('dashboard.loading') }}
+        </div>
 
-      <div v-else class="space-y-3">
-        <div
-          v-for="job in jobs"
-          :key="job._id"
-          class="bg-gray-900 border border-gray-800 rounded-xl p-4"
-        >
-          <div class="flex items-start justify-between gap-4">
-            <div class="flex-1 min-w-0">
-              <p class="text-sm text-gray-200 line-clamp-2 mb-2">{{ job.postId }}</p>
-              <div class="flex flex-wrap gap-1 mb-2">
-                <span
-                  v-for="p in job.platforms"
-                  :key="p"
-                  class="text-xs px-2 py-0.5 rounded-full"
-                  :style="{ backgroundColor: platformColor(p) + '22', color: platformColor(p) }"
-                >
-                  {{ $t(`platforms.${p}`) }}
+        <div v-else-if="!jobs.length" class="text-center text-gray-500 mt-20">
+          <p class="text-4xl mb-4">📅</p>
+          <p>{{ $t('scheduler.noJobs') }}</p>
+        </div>
+
+        <div v-else class="space-y-3">
+          <div
+            v-for="job in jobs"
+            :key="job._id"
+            class="bg-gray-900 border border-gray-800 rounded-xl p-4"
+          >
+            <div class="flex items-start justify-between gap-4">
+              <div class="flex-1 min-w-0">
+                <p class="text-sm text-gray-200 line-clamp-2 mb-2">{{ job.postId }}</p>
+                <div class="flex flex-wrap gap-1 mb-2">
+                  <span
+                    v-for="p in job.platforms"
+                    :key="p"
+                    class="text-xs px-2 py-0.5 rounded-full"
+                    :style="{ backgroundColor: platformColor(p) + '22', color: platformColor(p) }"
+                  >
+                    {{ $t(`platforms.${p}`) }}
+                  </span>
+                </div>
+                <p class="text-xs text-gray-500">{{ formatDate(job.scheduledAt) }}</p>
+              </div>
+              <div class="flex items-center gap-2 flex-shrink-0">
+                <span class="text-xs px-2 py-1 rounded-full font-medium" :class="statusClass(job.status)">
+                  {{ $t(`scheduler.statuses.${job.status}`) }}
                 </span>
+                <button
+                  v-if="job.status === 'pending'"
+                  @click="cancelJob(job.bullJobId)"
+                  class="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded transition-colors"
+                >
+                  {{ $t('scheduler.cancel') }}
+                </button>
               </div>
-              <p class="text-xs text-gray-500">{{ formatDate(job.scheduledAt) }}</p>
             </div>
-            <div class="flex items-center gap-2 flex-shrink-0">
-              <span class="text-xs px-2 py-1 rounded-full font-medium" :class="statusClass(job.status)">
-                {{ $t(`scheduler.statuses.${job.status}`) }}
-              </span>
-              <button
-                v-if="job.status === 'pending'"
-                @click="cancelJob(job.bullJobId)"
-                class="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded transition-colors"
-              >
-                {{ $t('scheduler.cancel') }}
-              </button>
+          </div>
+        </div>
+      </template>
+
+      <!-- ── Drafts panel ── -->
+      <template v-else>
+        <div v-if="draftsLoading" class="text-center text-gray-500 mt-20">
+          {{ $t('dashboard.loading') }}
+        </div>
+
+        <div v-else-if="!drafts.length" class="text-center text-gray-500 mt-20">
+          <p class="text-4xl mb-4">📝</p>
+          <p>{{ $t('scheduler.noDrafts') }}</p>
+        </div>
+
+        <div v-else class="space-y-3">
+          <div
+            v-for="draft in drafts"
+            :key="draft._id"
+            class="bg-gray-900 border border-gray-800 rounded-xl p-4"
+          >
+            <div class="flex items-start justify-between gap-4">
+              <div class="flex-1 min-w-0">
+                <!-- Content preview -->
+                <p class="text-sm text-gray-200 line-clamp-2 mb-2">
+                  {{ draft.content || '(no content)' }}
+                </p>
+
+                <!-- Media indicator -->
+                <p v-if="draft.mediaUrl" class="text-xs text-blue-400 mb-2 truncate">
+                  <svg class="w-3 h-3 inline-block mr-1" 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.01" />
+                  </svg>
+                  {{ mediaName(draft.mediaUrl) }}
+                </p>
+
+                <!-- Destination tags -->
+                <div class="flex flex-wrap gap-1 mb-2">
+                  <span
+                    v-for="dest in selectedDests(draft)"
+                    :key="dest.key"
+                    class="text-xs px-2 py-0.5 rounded-full"
+                    :style="{ backgroundColor: dest.color + '22', color: dest.color }"
+                  >
+                    {{ dest.label }}
+                  </span>
+                </div>
+
+                <!-- Saved at -->
+                <p class="text-xs text-gray-600">{{ formatDate(draft.updatedAt) }}</p>
+              </div>
+
+              <!-- Actions -->
+              <div class="flex items-center gap-2 flex-shrink-0">
+                <router-link
+                  :to="`/compose?draft=${draft._id}`"
+                  class="text-xs px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 transition-colors"
+                >
+                  {{ $t('scheduler.editDraft') }}
+                </router-link>
+                <button
+                  @click="deleteDraft(draft._id)"
+                  class="text-xs px-2 py-1.5 bg-gray-700 hover:bg-red-700 rounded-lg text-gray-400 hover:text-white transition-colors"
+                  :title="$t('scheduler.deleteDraft')"
+                >
+                  <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>
+      </template>
+
     </div>
   </div>
 </template>
@@ -92,6 +186,29 @@ interface ScheduledJob {
   bullJobId: string
 }
 
+interface DraftDestination {
+  key: string
+  platform: string
+  accountId?: string
+  label: string
+  color: string
+  picture?: string
+  selected: boolean
+}
+
+interface Draft {
+  _id: string
+  content: string
+  mediaUrl: string
+  scheduledAt: string
+  destinations: DraftDestination[]
+  createdAt: string
+  updatedAt: string
+}
+
+const mode = ref<'scheduled' | 'drafts'>('scheduled')
+
+// ── Scheduled jobs ──
 const jobs = ref<ScheduledJob[]>([])
 const loading = ref(false)
 const activeStatus = ref('pending')
@@ -124,6 +241,40 @@ async function cancelJob(bullJobId: string) {
   }
 }
 
+// ── Drafts ──
+const drafts = ref<Draft[]>([])
+const draftsLoading = ref(false)
+
+async function fetchDrafts() {
+  draftsLoading.value = true
+  try {
+    const res = await axios.get('/api/drafts')
+    drafts.value = res.data.drafts || []
+  } catch (err) {
+    console.error(err)
+  } finally {
+    draftsLoading.value = false
+  }
+}
+
+async function deleteDraft(id: string) {
+  try {
+    await axios.delete(`/api/drafts/${id}`)
+    drafts.value = drafts.value.filter((d) => d._id !== id)
+  } catch (err) {
+    console.error(err)
+  }
+}
+
+function selectedDests(draft: Draft) {
+  return (draft.destinations || []).filter((d) => d.selected)
+}
+
+function mediaName(url: string) {
+  try { return decodeURIComponent(url.split('/').pop() ?? url) } catch { return url }
+}
+
+// ── Shared helpers ──
 function formatDate(d: string) {
   return dayjs(d).format('D MMM YYYY, HH:mm')
 }
@@ -143,5 +294,11 @@ function statusClass(status: string) {
 }
 
 watch(activeStatus, fetchJobs)
-onMounted(fetchJobs)
+watch(mode, (m) => {
+  if (m === 'drafts') fetchDrafts()
+})
+onMounted(() => {
+  fetchJobs()
+  fetchDrafts()
+})
 </script>