Compose.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938
  1. <template>
  2. <div class="flex h-screen overflow-hidden bg-gray-950 text-gray-100">
  3. <!-- ── Left panel: editor ── -->
  4. <div class="flex-1 flex flex-col min-w-0 overflow-y-auto p-6">
  5. <div class="max-w-2xl w-full mx-auto flex flex-col gap-4">
  6. <!-- Header -->
  7. <div class="flex items-center justify-between">
  8. <h1 class="text-xl font-bold">{{ $t('compose.title') }}</h1>
  9. <router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-300 transition-colors">
  10. {{ $t('compose.cancel') }}
  11. </router-link>
  12. </div>
  13. <!-- Account selector -->
  14. <div class="bg-gray-900 border border-gray-800 rounded-xl p-4">
  15. <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">{{ $t('compose.destinationsLabel') }}</p>
  16. <div v-if="composeStore.destinations.length" class="flex flex-wrap gap-3">
  17. <button
  18. v-for="dest in composeStore.destinations"
  19. :key="dest.key"
  20. @click="toggle(dest.key)"
  21. :title="dest.label"
  22. class="relative focus:outline-none transition-all duration-150"
  23. :class="dest.selected ? 'opacity-100' : 'opacity-40 hover:opacity-70 grayscale hover:grayscale-0'"
  24. >
  25. <!-- Avatar circle -->
  26. <div
  27. class="w-12 h-12 rounded-full overflow-hidden flex items-center justify-center text-white font-bold text-base ring-2 ring-offset-2 ring-offset-gray-950 transition-all"
  28. :style="dest.selected ? { ringColor: dest.color } : {}"
  29. :class="dest.selected ? 'ring-white' : 'ring-transparent'"
  30. >
  31. <img v-if="dest.picture" :src="dest.picture" class="w-full h-full object-cover" />
  32. <span v-else class="w-full h-full flex items-center justify-center font-bold text-sm" :style="{ backgroundColor: dest.color }">
  33. {{ dest.label[0] }}
  34. </span>
  35. </div>
  36. <!-- Platform badge (only for page/account destinations) -->
  37. <span
  38. v-if="dest.accountId"
  39. class="absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-full flex items-center justify-center text-white font-bold border-2 border-gray-900"
  40. style="font-size:8px"
  41. :style="{ backgroundColor: dest.color }"
  42. >
  43. {{ dest.platform === 'facebook' ? 'f' : 'I' }}
  44. </span>
  45. </button>
  46. </div>
  47. <p v-else class="text-sm text-gray-600">
  48. {{ $t('compose.noDestinations') }}
  49. <router-link to="/settings" class="text-blue-400 hover:text-blue-300 ml-1">{{ $t('compose.goToSettings') }}</router-link>
  50. </p>
  51. </div>
  52. <!-- Textarea -->
  53. <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden" :class="{ 'border-red-700': overLimit }">
  54. <textarea
  55. v-model="composeStore.content"
  56. :placeholder="$t('compose.placeholder')"
  57. rows="7"
  58. class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed p-4"
  59. ></textarea>
  60. <!-- Media: attached file preview -->
  61. <div v-if="composeStore.mediaUrl.trim()" class="px-4 pb-3">
  62. <div class="relative inline-block group">
  63. <!-- Image preview -->
  64. <img
  65. v-if="isImage(composeStore.mediaUrl)"
  66. :src="composeStore.mediaUrl"
  67. class="rounded-lg max-h-48 max-w-full object-cover border border-gray-700"
  68. @error="mediaLoadError = true"
  69. />
  70. <!-- Video preview -->
  71. <div
  72. v-else
  73. class="flex items-center gap-3 bg-gray-800 border border-gray-700 rounded-lg px-3 py-2.5"
  74. >
  75. <svg class="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  76. <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" />
  77. </svg>
  78. <span class="text-xs text-gray-300 truncate max-w-xs">{{ mediaFilename }}</span>
  79. </div>
  80. <button
  81. @click="removeMedia"
  82. 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"
  83. title="Remove"
  84. >✕</button>
  85. </div>
  86. <p v-if="mediaLoadError" class="text-xs text-red-400 mt-1">{{ $t('compose.mediaLoadError') }}</p>
  87. <!-- Caption generation button — only for images when AI is configured -->
  88. <div v-if="isImage(composeStore.mediaUrl) && aiConfigured && !mediaLoadError" class="mt-2">
  89. <button
  90. @click="generateCaption"
  91. :disabled="captionGenerating"
  92. class="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border transition-colors disabled:opacity-50"
  93. :class="captionGenerating
  94. ? 'border-violet-700/40 text-violet-400 bg-violet-900/20'
  95. : 'border-violet-700/60 text-violet-300 hover:bg-violet-900/30 hover:border-violet-600'"
  96. >
  97. <svg v-if="captionGenerating" class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
  98. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
  99. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
  100. </svg>
  101. {{ captionGenerating ? $t('compose.captionGenerating') : $t('compose.captionGenerate') }}
  102. </button>
  103. <p v-if="captionError" class="text-xs text-red-400 mt-1">{{ $t('compose.captionError') }}</p>
  104. </div>
  105. </div>
  106. <!-- Upload progress -->
  107. <div v-if="uploading" class="px-4 pb-3 flex items-center gap-2 text-sm text-gray-400">
  108. <svg class="w-4 h-4 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
  109. <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
  110. <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
  111. </svg>
  112. {{ $t('compose.uploading') }}
  113. </div>
  114. <!-- Upload error -->
  115. <div v-if="uploadError" class="px-4 pb-3 text-xs text-red-400">{{ uploadError }}</div>
  116. <!-- Paste-URL fallback input -->
  117. <div v-if="showUrlInput && !composeStore.mediaUrl.trim() && !uploading" class="px-4 pb-3">
  118. <input
  119. v-model="pasteUrlValue"
  120. @keydown.enter="applyPastedUrl"
  121. @blur="applyPastedUrl"
  122. type="url"
  123. :placeholder="$t('compose.mediaUrlPlaceholder')"
  124. 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"
  125. ref="urlInputRef"
  126. />
  127. </div>
  128. <!-- Hidden file input -->
  129. <input
  130. ref="fileInputRef"
  131. type="file"
  132. accept="image/jpeg,image/png,image/gif,image/webp,video/mp4,video/quicktime,video/x-msvideo"
  133. class="hidden"
  134. @change="handleFileChange"
  135. />
  136. <!-- Toolbar -->
  137. <div class="flex items-center gap-2 px-4 py-2.5 border-t border-gray-800">
  138. <!-- Upload file button -->
  139. <button
  140. @click="fileInputRef?.click()"
  141. :disabled="uploading"
  142. 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"
  143. :class="composeStore.mediaUrl ? 'text-blue-400' : ''"
  144. :title="$t('compose.uploadFile')"
  145. >
  146. <svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  147. <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" />
  148. </svg>
  149. {{ $t('compose.addMedia') }}
  150. </button>
  151. <!-- Paste URL toggle -->
  152. <button
  153. v-if="!composeStore.mediaUrl && !uploading"
  154. @click="toggleUrlInput"
  155. class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
  156. :class="showUrlInput ? 'text-blue-400' : ''"
  157. >
  158. {{ showUrlInput ? $t('compose.cancelUrl') : $t('compose.pasteUrl') }}
  159. </button>
  160. <!-- AI Generate toggle -->
  161. <button
  162. @click="toggleAiPanel"
  163. class="flex items-center gap-1 text-xs px-2 py-1 rounded transition-colors"
  164. :class="aiPanelOpen ? 'text-violet-400 bg-violet-900/30' : 'text-gray-500 hover:text-gray-300 hover:bg-gray-800'"
  165. >
  166. <span>✨</span>
  167. <span>{{ $t('compose.aiButton') }}</span>
  168. </button>
  169. <span class="ml-auto text-xs font-mono" :class="overLimit ? 'text-red-400' : charNearLimit ? 'text-amber-400' : 'text-gray-600'">
  170. {{ composeStore.content.length }}<template v-if="composeStore.activeCharLimit">/{{ composeStore.activeCharLimit }}</template>
  171. </span>
  172. </div>
  173. <!-- AI Panel -->
  174. <div v-if="aiPanelOpen" class="border-t border-violet-900/40 bg-violet-950/20 px-4 py-3 space-y-3">
  175. <!-- Not configured warning -->
  176. <p v-if="!aiConfigured" class="text-xs text-amber-400 flex items-center gap-1.5">
  177. <span>⚠</span>{{ $t('compose.aiNotConfigured') }}
  178. </p>
  179. <template v-else>
  180. <!-- Context badge -->
  181. <p class="text-xs text-gray-500">
  182. <span v-if="aiContextAccount">✨ {{ $t('compose.aiContextFrom', { account: aiContextAccount }) }}</span>
  183. <span v-else>{{ $t('compose.aiNoContext') }}</span>
  184. </p>
  185. <!-- Topic input -->
  186. <input
  187. v-model="aiTopic"
  188. type="text"
  189. :placeholder="$t('compose.aiTopicPlaceholder')"
  190. :disabled="generating"
  191. class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-violet-500 disabled:opacity-50"
  192. />
  193. <!-- Goal + Tone + Generate -->
  194. <div class="flex items-center gap-2 flex-wrap">
  195. <select
  196. v-model="aiGoal"
  197. :disabled="generating"
  198. class="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-300 focus:outline-none focus:border-violet-500 disabled:opacity-50"
  199. >
  200. <option value="">{{ $t('compose.aiGoal') }}</option>
  201. <option value="promote">{{ $t('compose.aiGoals.promote') }}</option>
  202. <option value="engage">{{ $t('compose.aiGoals.engage') }}</option>
  203. <option value="inform">{{ $t('compose.aiGoals.inform') }}</option>
  204. <option value="entertain">{{ $t('compose.aiGoals.entertain') }}</option>
  205. <option value="announce">{{ $t('compose.aiGoals.announce') }}</option>
  206. </select>
  207. <select
  208. v-model="aiToneOverride"
  209. :disabled="generating"
  210. class="bg-gray-800 border border-gray-700 rounded-lg px-2.5 py-1.5 text-xs text-gray-300 focus:outline-none focus:border-violet-500 disabled:opacity-50"
  211. >
  212. <option value="">{{ $t('compose.aiToneDefault') }}</option>
  213. <option value="professional">Professional</option>
  214. <option value="casual">Casual</option>
  215. <option value="friendly">Friendly</option>
  216. <option value="formal">Formal</option>
  217. <option value="humorous">Humorous</option>
  218. <option value="inspiring">Inspiring</option>
  219. <option value="educational">Educational</option>
  220. </select>
  221. <div class="ml-auto flex items-center gap-2">
  222. <p v-if="aiError" class="text-xs text-red-400">{{ $t('compose.aiError') }}</p>
  223. <!-- Stop button (during generation) -->
  224. <button
  225. v-if="generating"
  226. @click="stopGeneration"
  227. class="px-3 py-1.5 text-xs font-medium bg-red-700 hover:bg-red-600 rounded-lg transition-colors"
  228. >
  229. {{ $t('compose.aiStop') }}
  230. </button>
  231. <!-- Generate button -->
  232. <button
  233. v-else
  234. @click="generatePost"
  235. :disabled="!aiTopic.trim()"
  236. class="px-3 py-1.5 text-xs font-medium bg-violet-600 hover:bg-violet-700 disabled:opacity-40 rounded-lg transition-colors flex items-center gap-1"
  237. >
  238. <span v-if="generating">{{ $t('compose.aiGenerating') }}</span>
  239. <span v-else>✨ {{ $t('compose.aiGenerate') }}</span>
  240. </button>
  241. </div>
  242. </div>
  243. <!-- Competitor context checkbox (only when summaries exist) -->
  244. <div v-if="hasCompetitorSummaries" class="flex items-center gap-2 text-xs text-gray-400">
  245. <input
  246. id="useCompetitorCtx"
  247. v-model="useCompetitorContext"
  248. type="checkbox"
  249. class="accent-violet-500"
  250. />
  251. <label for="useCompetitorCtx" class="cursor-pointer select-none">
  252. 🔍 {{ $t('compose.aiUseCompetitors') }}
  253. </label>
  254. <span class="text-gray-600">— {{ $t('compose.aiUseCompetitorsHint', { names: competitorNames }) }}</span>
  255. </div>
  256. </template>
  257. </div>
  258. </div>
  259. <!-- Hashtag suggestions -->
  260. <div
  261. v-if="suggestedHashtags.length || hashtagsLoading"
  262. class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3"
  263. >
  264. <div class="flex items-center gap-2 mb-2">
  265. <span class="text-xs text-gray-500">{{ $t('compose.hashtagSuggestions') }}</span>
  266. <span v-if="hashtagsLoading" class="text-xs text-gray-600 animate-pulse">{{ $t('compose.hashtagsLoading') }}</span>
  267. <button
  268. v-else
  269. @click="suggestHashtags()"
  270. class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
  271. :title="$t('compose.hashtagsRefresh')"
  272. >↻</button>
  273. </div>
  274. <div class="flex flex-wrap gap-1.5">
  275. <button
  276. v-for="tag in suggestedHashtags"
  277. :key="tag"
  278. @click="insertHashtag(tag)"
  279. :disabled="contentHasTag(tag)"
  280. class="text-xs px-2.5 py-0.5 rounded-full border transition-colors"
  281. :class="contentHasTag(tag)
  282. ? 'border-gray-700 text-gray-600 cursor-default'
  283. : 'border-violet-700/60 text-violet-300 hover:bg-violet-900/30 hover:border-violet-600'"
  284. >{{ tag }}</button>
  285. </div>
  286. </div>
  287. <!-- Hashtag Groups -->
  288. <div
  289. v-if="hashtagStore.groups.length"
  290. class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3"
  291. >
  292. <p class="text-xs text-gray-500 mb-2">{{ $t('compose.hashtagGroups') }}</p>
  293. <div class="flex flex-wrap gap-1.5">
  294. <button
  295. v-for="group in hashtagStore.groups"
  296. :key="group._id"
  297. @click="insertHashtagGroup(group.hashtags)"
  298. class="text-xs px-2.5 py-1 rounded-lg border border-emerald-700/60 text-emerald-300 hover:bg-emerald-900/30 transition-colors"
  299. :title="group.hashtags.join(' ')"
  300. >
  301. # {{ group.name }}
  302. <span class="text-emerald-600 ml-1">{{ group.hashtags.length }}</span>
  303. </button>
  304. </div>
  305. </div>
  306. <!-- First Comment -->
  307. <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
  308. <button
  309. @click="firstCommentOpen = !firstCommentOpen"
  310. class="w-full flex items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 hover:text-gray-100 hover:bg-gray-800/50 transition-colors"
  311. >
  312. <div class="flex items-center gap-2">
  313. <svg class="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  314. <path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
  315. </svg>
  316. <span>{{ $t('compose.firstCommentToggle') }}</span>
  317. <span v-if="composeStore.firstComment.trim()" class="w-2 h-2 rounded-full bg-blue-400 inline-block" />
  318. </div>
  319. <svg class="w-4 h-4 text-gray-600 transition-transform" :class="firstCommentOpen ? 'rotate-180' : ''" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  320. <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
  321. </svg>
  322. </button>
  323. <div v-if="firstCommentOpen" class="border-t border-gray-800 p-4 space-y-2">
  324. <textarea
  325. v-model="composeStore.firstComment"
  326. :placeholder="$t('compose.firstCommentPlaceholder')"
  327. rows="3"
  328. class="w-full bg-gray-800 border border-gray-700 rounded-lg text-gray-100 placeholder-gray-600 resize-none focus:outline-none focus:border-blue-500 text-sm leading-relaxed p-3"
  329. ></textarea>
  330. <p class="text-xs text-gray-500">{{ $t('compose.firstCommentHint') }}</p>
  331. </div>
  332. </div>
  333. <!-- Instagram warning -->
  334. <div v-if="igSelectedWithoutMedia" class="flex items-center gap-2 bg-amber-900/30 border border-amber-700/50 rounded-xl px-4 py-2.5 text-xs text-amber-300">
  335. <svg class="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>
  336. {{ $t('compose.igImageRequired') }}
  337. </div>
  338. <!-- Schedule + Post -->
  339. <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
  340. <!-- Row 1: datetime + timezone -->
  341. <div class="flex items-center gap-2">
  342. <svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  343. <path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
  344. </svg>
  345. <input
  346. v-model="composeStore.scheduledAt"
  347. type="datetime-local"
  348. class="flex-1 bg-transparent text-sm text-gray-300 focus:outline-none min-w-0"
  349. :title="$t('compose.scheduleTitle')"
  350. />
  351. <!-- Timezone selector -->
  352. <select
  353. v-model="scheduleTimezone"
  354. :title="$t('compose.timezoneLabel')"
  355. class="text-xs bg-gray-800 border border-gray-700 rounded-md px-2 py-1 text-gray-400 focus:outline-none focus:border-amber-500 flex-shrink-0 max-w-[130px]"
  356. >
  357. <option v-for="tz in COMMON_TIMEZONES" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
  358. </select>
  359. <span class="text-xs text-gray-600 flex-shrink-0 hidden sm:block">{{ timezoneAbbr }}</span>
  360. <button
  361. v-if="composeStore.scheduledAt"
  362. @click="composeStore.scheduledAt = ''"
  363. class="text-gray-600 hover:text-gray-400 text-xs flex-shrink-0"
  364. >✕</button>
  365. </div>
  366. <!-- Suggested times strip -->
  367. <div v-if="suggestionsLoading || suggestions.length" class="flex items-start gap-2 flex-wrap">
  368. <span class="text-xs text-gray-500 shrink-0 mt-0.5">{{ $t('compose.suggestedTimes') }}</span>
  369. <span v-if="suggestionsLoading" class="text-xs text-gray-600 animate-pulse">{{ $t('compose.suggestionsLoading') }}</span>
  370. <template v-else>
  371. <button
  372. v-for="s in suggestions"
  373. :key="s.utc"
  374. @click="applySuggestion(s)"
  375. class="text-xs px-2.5 py-0.5 rounded-full border border-amber-700/60 text-amber-300 hover:bg-amber-900/30 transition-colors"
  376. :class="{ 'opacity-50 ring-1 ring-amber-500': composeStore.scheduledAt === utcToNaiveDatetimeString(s.utc, scheduleTimezone) }"
  377. >{{ formatSuggestionChip(s) }}</button>
  378. <span class="text-xs text-gray-600 self-center">— {{ $t(suggestionsSource === 'history' ? 'compose.suggestionsFromHistory' : 'compose.suggestionsFromDefaults') }}</span>
  379. </template>
  380. </div>
  381. <!-- Row 2: actions -->
  382. <div class="flex items-center justify-end gap-2">
  383. <button
  384. @click="handleSaveDraft"
  385. :disabled="composeStore.savingDraft || !composeStore.content.trim()"
  386. class="px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 bg-gray-700 hover:bg-gray-600 text-gray-200"
  387. >
  388. {{ composeStore.savingDraft ? $t('compose.savingDraft') : (composeStore.draftId ? $t('compose.updateDraft') : $t('compose.saveDraft')) }}
  389. </button>
  390. <button
  391. @click="handlePost"
  392. :disabled="composeStore.sending || !canPost"
  393. class="px-5 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40"
  394. :class="composeStore.scheduledAt ? 'bg-amber-600 hover:bg-amber-700' : 'bg-blue-600 hover:bg-blue-700'"
  395. >
  396. {{ composeStore.sending ? $t('compose.sending') : postButtonLabel }}
  397. </button>
  398. </div>
  399. </div>
  400. <!-- Success message -->
  401. <div v-if="composeStore.lastResult" class="bg-green-900/30 border border-green-700/60 rounded-xl px-4 py-3 text-sm text-green-300">
  402. {{ $t('compose.successMessage') }}
  403. </div>
  404. <!-- Draft saved message -->
  405. <div v-if="draftSavedBanner" class="bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-gray-300">
  406. {{ $t('compose.draftSaved') }}
  407. </div>
  408. </div>
  409. </div>
  410. <!-- ── Right panel: preview ── -->
  411. <div class="w-80 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col overflow-hidden">
  412. <div class="px-4 py-3 border-b border-gray-800 flex-shrink-0">
  413. <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest">{{ $t('compose.preview') }}</p>
  414. </div>
  415. <div class="flex-1 overflow-y-auto p-4">
  416. <PostPreview
  417. :selectedDestinations="composeStore.selectedDestinations"
  418. :activeKey="activePreviewKey"
  419. :content="composeStore.content"
  420. :mediaUrl="composeStore.mediaUrl"
  421. @update:activeKey="activePreviewKey = $event"
  422. />
  423. </div>
  424. </div>
  425. </div>
  426. </template>
  427. <script setup lang="ts">
  428. import { ref, computed, watch, nextTick, onMounted } from 'vue'
  429. import { useRouter, useRoute } from 'vue-router'
  430. import { useI18n } from 'vue-i18n'
  431. import axios from 'axios'
  432. import { useComposeStore } from '../stores/compose'
  433. import { usePlatformsStore } from '../stores/platforms'
  434. import { useAiStore } from '../stores/ai'
  435. import { useHashtagStore } from '../stores/hashtags'
  436. import { useCompetitorStore } from '../stores/competitors'
  437. import PostPreview from '../components/compose/PostPreview.vue'
  438. import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr, utcToNaiveDatetimeString } from '../utils/timezone'
  439. const { t } = useI18n()
  440. const composeStore = useComposeStore()
  441. const platformsStore = usePlatformsStore()
  442. const aiStore = useAiStore()
  443. const hashtagStore = useHashtagStore()
  444. const competitorStore = useCompetitorStore()
  445. const router = useRouter()
  446. const route = useRoute()
  447. const fileInputRef = ref<HTMLInputElement | null>(null)
  448. const urlInputRef = ref<HTMLInputElement | null>(null)
  449. const pasteUrlValue = ref('')
  450. const showUrlInput = ref(false)
  451. const uploading = ref(false)
  452. const uploadError = ref('')
  453. const mediaLoadError = ref(false)
  454. const activePreviewKey = ref('')
  455. const draftSavedBanner = ref(false)
  456. const firstCommentOpen = ref(false)
  457. onMounted(async () => {
  458. await Promise.all([
  459. platformsStore.fetchStatuses(),
  460. platformsStore.fetchMetaConnections(),
  461. aiStore.fetchConfig(),
  462. hashtagStore.fetchGroups(),
  463. competitorStore.fetchCompetitors(),
  464. ])
  465. composeStore.initDestinations()
  466. // Pre-fill media URL when arriving from the Media Library ("Use in Post")
  467. if (route.query.media) {
  468. composeStore.mediaUrl = String(route.query.media)
  469. mediaLoadError.value = false
  470. }
  471. // Load draft when arriving via ?draft=ID
  472. if (route.query.draft) {
  473. try {
  474. const res = await axios.get(`/api/drafts/${route.query.draft}`)
  475. composeStore.loadDraft(res.data)
  476. mediaLoadError.value = false
  477. } catch (err) {
  478. console.error('Failed to load draft:', err)
  479. }
  480. }
  481. })
  482. // Keep activePreviewKey pointed at a selected destination
  483. watch(
  484. () => composeStore.selectedDestinations,
  485. (selected) => {
  486. if (!selected.find((d) => d.key === activePreviewKey.value)) {
  487. activePreviewKey.value = selected[0]?.key ?? ''
  488. }
  489. },
  490. { deep: true }
  491. )
  492. function toggle(key: string) {
  493. composeStore.toggleDestination(key)
  494. const dest = composeStore.destinations.find((d) => d.key === key)
  495. if (dest?.selected) activePreviewKey.value = key
  496. }
  497. async function handleFileChange(event: Event) {
  498. const file = (event.target as HTMLInputElement).files?.[0]
  499. if (!file) return
  500. uploading.value = true
  501. uploadError.value = ''
  502. mediaLoadError.value = false
  503. try {
  504. const form = new FormData()
  505. form.append('file', file)
  506. const res = await axios.post('/api/upload', form, {
  507. headers: { 'Content-Type': 'multipart/form-data' },
  508. })
  509. composeStore.mediaUrl = res.data.url
  510. } catch (err: any) {
  511. uploadError.value = err.response?.data?.error ?? t('compose.uploadFailed')
  512. } finally {
  513. uploading.value = false
  514. // Reset file input so the same file can be re-selected if needed
  515. if (fileInputRef.value) fileInputRef.value.value = ''
  516. }
  517. }
  518. async function toggleUrlInput() {
  519. showUrlInput.value = !showUrlInput.value
  520. uploadError.value = ''
  521. if (showUrlInput.value) {
  522. await nextTick()
  523. urlInputRef.value?.focus()
  524. }
  525. }
  526. function applyPastedUrl() {
  527. const url = pasteUrlValue.value.trim()
  528. if (url) {
  529. composeStore.mediaUrl = url
  530. pasteUrlValue.value = ''
  531. showUrlInput.value = false
  532. mediaLoadError.value = false
  533. }
  534. }
  535. function removeMedia() {
  536. composeStore.mediaUrl = ''
  537. mediaLoadError.value = false
  538. uploadError.value = ''
  539. showUrlInput.value = false
  540. }
  541. // ─── Schedule Timezone ────────────────────────────────────────────────────────
  542. const scheduleTimezone = ref(getBrowserTimezone())
  543. const timezoneAbbr = computed(() => getTimezoneAbbr(scheduleTimezone.value))
  544. // ─── Schedule Suggestions ────────────────────────────────────────────────────
  545. interface Suggestion { utc: string; dayOfWeek: number; hour: number; label: string }
  546. const suggestions = ref<Suggestion[]>([])
  547. const suggestionsSource = ref<'history' | 'default' | ''>('')
  548. const suggestionsLoading = ref(false)
  549. async function loadSuggestions() {
  550. const first = composeStore.selectedDestinations[0]
  551. if (!first) { suggestions.value = []; return }
  552. suggestionsLoading.value = true
  553. try {
  554. const params: Record<string, string> = { platform: first.platform }
  555. if (first.accountId) params.accountId = first.accountId
  556. const res = await axios.get('/api/schedule/suggestions', { params })
  557. suggestions.value = res.data.suggestions ?? []
  558. suggestionsSource.value = res.data.source ?? ''
  559. } catch {
  560. suggestions.value = []
  561. } finally {
  562. suggestionsLoading.value = false
  563. }
  564. }
  565. function applySuggestion(s: Suggestion) {
  566. composeStore.scheduledAt = utcToNaiveDatetimeString(s.utc, scheduleTimezone.value)
  567. }
  568. function formatSuggestionChip(s: Suggestion): string {
  569. return new Intl.DateTimeFormat(undefined, {
  570. weekday: 'short', month: 'short', day: 'numeric',
  571. hour: 'numeric', minute: '2-digit',
  572. timeZone: scheduleTimezone.value,
  573. hour12: true,
  574. }).format(new Date(s.utc))
  575. }
  576. // Reload suggestions when the selected platform changes (debounced to avoid
  577. // multiple requests when selecting several destinations quickly)
  578. let suggestionTimer: ReturnType<typeof setTimeout> | null = null
  579. watch(
  580. () => composeStore.selectedDestinations[0]?.key,
  581. () => {
  582. if (suggestionTimer) clearTimeout(suggestionTimer)
  583. suggestionTimer = setTimeout(loadSuggestions, 300)
  584. }
  585. )
  586. // Auto-populate timezone from the first selected destination's profile.
  587. watch(
  588. () => composeStore.selectedDestinations[0]?.key,
  589. async (key: string | undefined) => {
  590. if (!key) return
  591. try {
  592. const cached = profileCache[key]
  593. const profile = cached ?? (await axios.get(`/api/profiles/${encodeURIComponent(key)}`)).data
  594. if (!cached) profileCache[key] = profile
  595. if (profile?.timezone) scheduleTimezone.value = profile.timezone
  596. } catch { /* leave current timezone */ }
  597. },
  598. { immediate: true }
  599. )
  600. function isImage(url: string) {
  601. return /\.(jpe?g|png|gif|webp)(\?.*)?$/i.test(url)
  602. }
  603. const mediaFilename = computed(() => {
  604. try { return decodeURIComponent(composeStore.mediaUrl.split('/').pop() ?? '') } catch { return composeStore.mediaUrl }
  605. })
  606. const igSelectedWithoutMedia = computed(() =>
  607. composeStore.selectedDestinations.some((d) => d.platform === 'instagram') &&
  608. !composeStore.mediaUrl.trim()
  609. )
  610. const overLimit = computed(() =>
  611. !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit
  612. )
  613. const charNearLimit = computed(() =>
  614. !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit * 0.9
  615. )
  616. const canPost = computed(() =>
  617. !!composeStore.content.trim() &&
  618. composeStore.selectedDestinations.length > 0 &&
  619. !overLimit.value &&
  620. !igSelectedWithoutMedia.value
  621. )
  622. const postButtonLabel = computed(() =>
  623. composeStore.scheduledAt ? `⏰ ${t('compose.schedule')}` : t('compose.send')
  624. )
  625. // ─── AI Generation ────────────────────────────────────────────────────────────
  626. const aiPanelOpen = ref(false)
  627. const aiTopic = ref('')
  628. const aiGoal = ref('')
  629. const aiToneOverride = ref('')
  630. const generating = ref(false)
  631. const aiError = ref(false)
  632. const aiContextAccount = ref('')
  633. const abortController = ref<AbortController | null>(null)
  634. const useCompetitorContext = ref(false)
  635. const hasCompetitorSummaries = computed(() =>
  636. competitorStore.competitors.some((c) => c.aiSummary?.trim())
  637. )
  638. const competitorNames = computed(() =>
  639. competitorStore.competitors.filter((c) => c.aiSummary?.trim()).map((c) => c.name).join(', ')
  640. )
  641. const aiConfigured = computed(() => aiStore.config.enabled && !!aiStore.config.endpoint)
  642. function toggleAiPanel() {
  643. aiPanelOpen.value = !aiPanelOpen.value
  644. if (aiPanelOpen.value) loadAiContext()
  645. }
  646. // Profile cache keyed by destination key
  647. const profileCache: Record<string, Record<string, string>> = {}
  648. async function loadAiContext() {
  649. const firstDest = composeStore.selectedDestinations[0]
  650. if (!firstDest) { aiContextAccount.value = ''; return }
  651. aiContextAccount.value = firstDest.label
  652. if (!profileCache[firstDest.key]) {
  653. try {
  654. const res = await axios.get(`/api/profiles/${encodeURIComponent(firstDest.key)}`)
  655. profileCache[firstDest.key] = res.data
  656. } catch {
  657. profileCache[firstDest.key] = {}
  658. }
  659. }
  660. }
  661. function buildSystemPrompt(profile: Record<string, string>): string {
  662. const platforms = composeStore.selectedDestinations.map((d) => d.platform).join(', ')
  663. const charLimit = composeStore.activeCharLimit ? `${composeStore.activeCharLimit} characters` : 'no strict limit'
  664. const tone = aiToneOverride.value || profile.toneOfVoice || 'professional'
  665. const lines = [
  666. 'You are a social media content writer. Write engaging, on-brand post content.',
  667. '',
  668. 'BRAND CONTEXT:',
  669. ]
  670. if (profile.businessName) lines.push(`Business: ${profile.businessName}`)
  671. if (profile.description) lines.push(`Description: ${profile.description}`)
  672. if (profile.industry) lines.push(`Industry: ${profile.industry}`)
  673. if (profile.targetAudience) lines.push(`Target audience: ${profile.targetAudience}`)
  674. if (profile.keywords) lines.push(`Keywords: ${profile.keywords}`)
  675. if (profile.hashtags) lines.push(`Preferred hashtags: ${profile.hashtags}`)
  676. if (profile.postingGuidelines) lines.push(`Guidelines: ${profile.postingGuidelines}`)
  677. lines.push('', 'PLATFORM RULES:')
  678. lines.push(`Platform(s): ${platforms || 'general'}`)
  679. lines.push(`Character limit: ${charLimit}`)
  680. lines.push(`Tone of voice: ${tone}`)
  681. if (aiGoal.value) lines.push(`Goal: ${aiGoal.value}`)
  682. lines.push('', 'OUTPUT RULES:')
  683. lines.push('- Write ONLY the post content, nothing else.')
  684. lines.push('- No preamble, no explanation, no quotation marks around the post.')
  685. lines.push('- Include relevant hashtags if appropriate.')
  686. lines.push('- Stay within the character limit.')
  687. return lines.join('\n')
  688. }
  689. async function generatePost() {
  690. aiError.value = false
  691. const firstDest = composeStore.selectedDestinations[0]
  692. const profile = firstDest ? (profileCache[firstDest.key] || {}) : {}
  693. const system = buildSystemPrompt(profile)
  694. const prompt = aiTopic.value.trim()
  695. abortController.value = new AbortController()
  696. generating.value = true
  697. composeStore.content = ''
  698. try {
  699. const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal, useCompetitorContext.value)
  700. for await (const token of gen) {
  701. composeStore.content += token
  702. }
  703. } catch (err: any) {
  704. if (err.name !== 'AbortError') {
  705. aiError.value = true
  706. console.error('AI generation error:', err)
  707. }
  708. } finally {
  709. generating.value = false
  710. abortController.value = null
  711. }
  712. }
  713. function stopGeneration() {
  714. abortController.value?.abort()
  715. }
  716. // ─── Image Caption (Vision) ───────────────────────────────────────────────────
  717. const captionGenerating = ref(false)
  718. const captionError = ref(false)
  719. async function generateCaption() {
  720. captionError.value = false
  721. captionGenerating.value = true
  722. try {
  723. const caption = await aiStore.generateCaption(composeStore.mediaUrl)
  724. const sep = composeStore.content.trim() ? '\n\n' : ''
  725. composeStore.content = composeStore.content.trim() + sep + caption
  726. } catch {
  727. captionError.value = true
  728. } finally {
  729. captionGenerating.value = false
  730. }
  731. }
  732. // ─── Hashtag Suggestions ──────────────────────────────────────────────────────
  733. const suggestedHashtags = ref<string[]>([])
  734. const hashtagsLoading = ref(false)
  735. let hashtagDebounceTimer: ReturnType<typeof setTimeout> | null = null
  736. // Stop words to filter out in keyword-extraction fallback
  737. const STOP_WORDS = new Set([
  738. 'the','and','for','are','but','not','you','all','can','her','was','one','our',
  739. 'out','about','have','from','they','this','that','with','will','been','were',
  740. 'when','what','your','more','also','than','then','into','its','just','like',
  741. 'some','their','there','these','those','which','would','could','should','after',
  742. 'very','well','here','where','does','each','both','such','even','most','said',
  743. 'over','only','same','much','before','through','while','under','first','last',
  744. ])
  745. function extractKeywordHashtags(text: string): string[] {
  746. return [
  747. ...new Set(
  748. text
  749. .replace(/[^a-zA-Z\s]/g, ' ')
  750. .toLowerCase()
  751. .split(/\s+/)
  752. .filter((w) => w.length >= 4 && !STOP_WORDS.has(w))
  753. ),
  754. ]
  755. .slice(0, 8)
  756. .map((w) => `#${w}`)
  757. }
  758. function parseHashtagsFromResponse(text: string): string[] {
  759. const tags = (text.match(/#[a-zA-Z]\w*/g) || [])
  760. .map((t) => t.toLowerCase())
  761. return [...new Set(tags)].slice(0, 10)
  762. }
  763. async function suggestHashtags() {
  764. const content = composeStore.content.trim()
  765. if (!content || content.length < 30) { suggestedHashtags.value = []; return }
  766. hashtagsLoading.value = true
  767. try {
  768. if (aiConfigured.value) {
  769. const firstDest = composeStore.selectedDestinations[0]
  770. const profile = firstDest ? (profileCache[firstDest.key] || {}) : {}
  771. const platforms = composeStore.selectedDestinations.map((d: { platform: string }) => d.platform).join(', ')
  772. const system = 'You are a social media hashtag expert. Return ONLY hashtags, no explanation or extra text.'
  773. const prompt = [
  774. `Suggest 8 relevant hashtags for the following social media post.`,
  775. platforms ? `Platform: ${platforms}` : '',
  776. profile.industry ? `Industry: ${profile.industry}` : '',
  777. profile.keywords ? `Keywords: ${profile.keywords}` : '',
  778. ``,
  779. `Post content:`,
  780. content,
  781. ``,
  782. `Return exactly 8 hashtags as a space-separated list. Example: #marketing #growth #tips`,
  783. ].filter(Boolean).join('\n')
  784. const text = await aiStore.generate(prompt, system)
  785. const parsed = parseHashtagsFromResponse(text)
  786. suggestedHashtags.value = parsed.length ? parsed : extractKeywordHashtags(content)
  787. } else {
  788. suggestedHashtags.value = extractKeywordHashtags(content)
  789. }
  790. } catch {
  791. suggestedHashtags.value = extractKeywordHashtags(content)
  792. } finally {
  793. hashtagsLoading.value = false
  794. }
  795. }
  796. function contentHasTag(tag: string): boolean {
  797. return composeStore.content.toLowerCase().includes(tag.toLowerCase())
  798. }
  799. function insertHashtag(tag: string) {
  800. if (contentHasTag(tag)) return
  801. const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
  802. composeStore.content += `${sep}${tag}`
  803. }
  804. function insertHashtagGroup(hashtags: string[]) {
  805. const toInsert = hashtags.filter((t) => !contentHasTag(t))
  806. if (!toInsert.length) return
  807. const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
  808. composeStore.content += `${sep}${toInsert.join(' ')}`
  809. }
  810. // Debounced watcher — triggers suggestion after 1.5 s of no typing
  811. watch(
  812. () => composeStore.content,
  813. (val: string) => {
  814. if (generating.value) return // skip while AI is actively writing
  815. if (hashtagDebounceTimer) clearTimeout(hashtagDebounceTimer)
  816. if (val.trim().length < 30) { suggestedHashtags.value = []; return }
  817. hashtagDebounceTimer = setTimeout(() => suggestHashtags(), 1500)
  818. }
  819. )
  820. async function handleSaveDraft() {
  821. const ok = await composeStore.saveDraft()
  822. if (ok) {
  823. draftSavedBanner.value = true
  824. setTimeout(() => { draftSavedBanner.value = false }, 2500)
  825. }
  826. }
  827. async function handlePost() {
  828. await composeStore.post(scheduleTimezone.value)
  829. if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
  830. }
  831. </script>