Compose.vue 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  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 content when arriving from Competitor Roadmap ("Draft this post")
  467. if (route.query.prefill) {
  468. composeStore.content = String(route.query.prefill)
  469. }
  470. // Pre-fill media URL when arriving from the Media Library ("Use in Post")
  471. if (route.query.media) {
  472. composeStore.mediaUrl = String(route.query.media)
  473. mediaLoadError.value = false
  474. }
  475. // Load draft when arriving via ?draft=ID
  476. if (route.query.draft) {
  477. try {
  478. const res = await axios.get(`/api/drafts/${route.query.draft}`)
  479. composeStore.loadDraft(res.data)
  480. mediaLoadError.value = false
  481. } catch (err) {
  482. console.error('Failed to load draft:', err)
  483. }
  484. }
  485. })
  486. // Keep activePreviewKey pointed at a selected destination
  487. watch(
  488. () => composeStore.selectedDestinations,
  489. (selected) => {
  490. if (!selected.find((d) => d.key === activePreviewKey.value)) {
  491. activePreviewKey.value = selected[0]?.key ?? ''
  492. }
  493. },
  494. { deep: true }
  495. )
  496. function toggle(key: string) {
  497. composeStore.toggleDestination(key)
  498. const dest = composeStore.destinations.find((d) => d.key === key)
  499. if (dest?.selected) activePreviewKey.value = key
  500. }
  501. async function handleFileChange(event: Event) {
  502. const file = (event.target as HTMLInputElement).files?.[0]
  503. if (!file) return
  504. uploading.value = true
  505. uploadError.value = ''
  506. mediaLoadError.value = false
  507. try {
  508. const form = new FormData()
  509. form.append('file', file)
  510. const res = await axios.post('/api/upload', form, {
  511. headers: { 'Content-Type': 'multipart/form-data' },
  512. })
  513. composeStore.mediaUrl = res.data.url
  514. } catch (err: any) {
  515. uploadError.value = err.response?.data?.error ?? t('compose.uploadFailed')
  516. } finally {
  517. uploading.value = false
  518. // Reset file input so the same file can be re-selected if needed
  519. if (fileInputRef.value) fileInputRef.value.value = ''
  520. }
  521. }
  522. async function toggleUrlInput() {
  523. showUrlInput.value = !showUrlInput.value
  524. uploadError.value = ''
  525. if (showUrlInput.value) {
  526. await nextTick()
  527. urlInputRef.value?.focus()
  528. }
  529. }
  530. function applyPastedUrl() {
  531. const url = pasteUrlValue.value.trim()
  532. if (url) {
  533. composeStore.mediaUrl = url
  534. pasteUrlValue.value = ''
  535. showUrlInput.value = false
  536. mediaLoadError.value = false
  537. }
  538. }
  539. function removeMedia() {
  540. composeStore.mediaUrl = ''
  541. mediaLoadError.value = false
  542. uploadError.value = ''
  543. showUrlInput.value = false
  544. }
  545. // ─── Schedule Timezone ────────────────────────────────────────────────────────
  546. const scheduleTimezone = ref(getBrowserTimezone())
  547. const timezoneAbbr = computed(() => getTimezoneAbbr(scheduleTimezone.value))
  548. // ─── Schedule Suggestions ────────────────────────────────────────────────────
  549. interface Suggestion { utc: string; dayOfWeek: number; hour: number; label: string }
  550. const suggestions = ref<Suggestion[]>([])
  551. const suggestionsSource = ref<'history' | 'default' | ''>('')
  552. const suggestionsLoading = ref(false)
  553. async function loadSuggestions() {
  554. const first = composeStore.selectedDestinations[0]
  555. if (!first) { suggestions.value = []; return }
  556. suggestionsLoading.value = true
  557. try {
  558. const params: Record<string, string> = { platform: first.platform }
  559. if (first.accountId) params.accountId = first.accountId
  560. const res = await axios.get('/api/schedule/suggestions', { params })
  561. suggestions.value = res.data.suggestions ?? []
  562. suggestionsSource.value = res.data.source ?? ''
  563. } catch {
  564. suggestions.value = []
  565. } finally {
  566. suggestionsLoading.value = false
  567. }
  568. }
  569. function applySuggestion(s: Suggestion) {
  570. composeStore.scheduledAt = utcToNaiveDatetimeString(s.utc, scheduleTimezone.value)
  571. }
  572. function formatSuggestionChip(s: Suggestion): string {
  573. return new Intl.DateTimeFormat(undefined, {
  574. weekday: 'short', month: 'short', day: 'numeric',
  575. hour: 'numeric', minute: '2-digit',
  576. timeZone: scheduleTimezone.value,
  577. hour12: true,
  578. }).format(new Date(s.utc))
  579. }
  580. // Reload suggestions when the selected platform changes (debounced to avoid
  581. // multiple requests when selecting several destinations quickly)
  582. let suggestionTimer: ReturnType<typeof setTimeout> | null = null
  583. watch(
  584. () => composeStore.selectedDestinations[0]?.key,
  585. () => {
  586. if (suggestionTimer) clearTimeout(suggestionTimer)
  587. suggestionTimer = setTimeout(loadSuggestions, 300)
  588. }
  589. )
  590. // Auto-populate timezone from the first selected destination's profile.
  591. watch(
  592. () => composeStore.selectedDestinations[0]?.key,
  593. async (key: string | undefined) => {
  594. if (!key) return
  595. try {
  596. const cached = profileCache[key]
  597. const profile = cached ?? (await axios.get(`/api/profiles/${encodeURIComponent(key)}`)).data
  598. if (!cached) profileCache[key] = profile
  599. if (profile?.timezone) scheduleTimezone.value = profile.timezone
  600. } catch { /* leave current timezone */ }
  601. },
  602. { immediate: true }
  603. )
  604. function isImage(url: string) {
  605. return /\.(jpe?g|png|gif|webp)(\?.*)?$/i.test(url)
  606. }
  607. const mediaFilename = computed(() => {
  608. try { return decodeURIComponent(composeStore.mediaUrl.split('/').pop() ?? '') } catch { return composeStore.mediaUrl }
  609. })
  610. const igSelectedWithoutMedia = computed(() =>
  611. composeStore.selectedDestinations.some((d) => d.platform === 'instagram') &&
  612. !composeStore.mediaUrl.trim()
  613. )
  614. const overLimit = computed(() =>
  615. !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit
  616. )
  617. const charNearLimit = computed(() =>
  618. !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit * 0.9
  619. )
  620. const canPost = computed(() =>
  621. !!composeStore.content.trim() &&
  622. composeStore.selectedDestinations.length > 0 &&
  623. !overLimit.value &&
  624. !igSelectedWithoutMedia.value
  625. )
  626. const postButtonLabel = computed(() =>
  627. composeStore.scheduledAt ? `⏰ ${t('compose.schedule')}` : t('compose.send')
  628. )
  629. // ─── AI Generation ────────────────────────────────────────────────────────────
  630. const aiPanelOpen = ref(false)
  631. const aiTopic = ref('')
  632. const aiGoal = ref('')
  633. const aiToneOverride = ref('')
  634. const generating = ref(false)
  635. const aiError = ref(false)
  636. const aiContextAccount = ref('')
  637. const abortController = ref<AbortController | null>(null)
  638. const useCompetitorContext = ref(false)
  639. const hasCompetitorSummaries = computed(() =>
  640. competitorStore.competitors.some((c) => c.aiSummary?.trim())
  641. )
  642. const competitorNames = computed(() =>
  643. competitorStore.competitors.filter((c) => c.aiSummary?.trim()).map((c) => c.name).join(', ')
  644. )
  645. const aiConfigured = computed(() => aiStore.config.enabled && !!aiStore.config.endpoint)
  646. function toggleAiPanel() {
  647. aiPanelOpen.value = !aiPanelOpen.value
  648. if (aiPanelOpen.value) loadAiContext()
  649. }
  650. // Profile cache keyed by destination key
  651. const profileCache: Record<string, Record<string, string>> = {}
  652. async function loadAiContext() {
  653. const firstDest = composeStore.selectedDestinations[0]
  654. if (!firstDest) { aiContextAccount.value = ''; return }
  655. aiContextAccount.value = firstDest.label
  656. if (!profileCache[firstDest.key]) {
  657. try {
  658. const res = await axios.get(`/api/profiles/${encodeURIComponent(firstDest.key)}`)
  659. profileCache[firstDest.key] = res.data
  660. } catch {
  661. profileCache[firstDest.key] = {}
  662. }
  663. }
  664. }
  665. function buildSystemPrompt(profile: Record<string, string>): string {
  666. const platforms = composeStore.selectedDestinations.map((d) => d.platform).join(', ')
  667. const charLimit = composeStore.activeCharLimit ? `${composeStore.activeCharLimit} characters` : 'no strict limit'
  668. const tone = aiToneOverride.value || profile.toneOfVoice || 'professional'
  669. const lines = [
  670. 'You are a social media content writer. Write engaging, on-brand post content.',
  671. '',
  672. 'BRAND CONTEXT:',
  673. ]
  674. if (profile.businessName) lines.push(`Business: ${profile.businessName}`)
  675. if (profile.description) lines.push(`Description: ${profile.description}`)
  676. if (profile.industry) lines.push(`Industry: ${profile.industry}`)
  677. if (profile.targetAudience) lines.push(`Target audience: ${profile.targetAudience}`)
  678. if (profile.keywords) lines.push(`Keywords: ${profile.keywords}`)
  679. if (profile.hashtags) lines.push(`Preferred hashtags: ${profile.hashtags}`)
  680. if (profile.postingGuidelines) lines.push(`Guidelines: ${profile.postingGuidelines}`)
  681. lines.push('', 'PLATFORM RULES:')
  682. lines.push(`Platform(s): ${platforms || 'general'}`)
  683. lines.push(`Character limit: ${charLimit}`)
  684. lines.push(`Tone of voice: ${tone}`)
  685. if (aiGoal.value) lines.push(`Goal: ${aiGoal.value}`)
  686. lines.push('', 'OUTPUT RULES:')
  687. lines.push('- Write ONLY the post content, nothing else.')
  688. lines.push('- No preamble, no explanation, no quotation marks around the post.')
  689. lines.push('- Include relevant hashtags if appropriate.')
  690. lines.push('- Stay within the character limit.')
  691. return lines.join('\n')
  692. }
  693. async function generatePost() {
  694. aiError.value = false
  695. const firstDest = composeStore.selectedDestinations[0]
  696. const profile = firstDest ? (profileCache[firstDest.key] || {}) : {}
  697. const system = buildSystemPrompt(profile)
  698. const prompt = aiTopic.value.trim()
  699. abortController.value = new AbortController()
  700. generating.value = true
  701. composeStore.content = ''
  702. try {
  703. const gen = aiStore.streamGenerate(prompt, system, undefined, abortController.value.signal, useCompetitorContext.value, composeStore.selectedDestinations)
  704. for await (const token of gen) {
  705. composeStore.content += token
  706. }
  707. } catch (err: any) {
  708. if (err.name !== 'AbortError') {
  709. aiError.value = true
  710. console.error('AI generation error:', err)
  711. }
  712. } finally {
  713. generating.value = false
  714. abortController.value = null
  715. }
  716. }
  717. function stopGeneration() {
  718. abortController.value?.abort()
  719. }
  720. // ─── Image Caption (Vision) ───────────────────────────────────────────────────
  721. const captionGenerating = ref(false)
  722. const captionError = ref(false)
  723. async function generateCaption() {
  724. captionError.value = false
  725. captionGenerating.value = true
  726. try {
  727. const caption = await aiStore.generateCaption(composeStore.mediaUrl)
  728. const sep = composeStore.content.trim() ? '\n\n' : ''
  729. composeStore.content = composeStore.content.trim() + sep + caption
  730. } catch {
  731. captionError.value = true
  732. } finally {
  733. captionGenerating.value = false
  734. }
  735. }
  736. // ─── Hashtag Suggestions ──────────────────────────────────────────────────────
  737. const suggestedHashtags = ref<string[]>([])
  738. const hashtagsLoading = ref(false)
  739. let hashtagDebounceTimer: ReturnType<typeof setTimeout> | null = null
  740. // Stop words to filter out in keyword-extraction fallback
  741. const STOP_WORDS = new Set([
  742. 'the','and','for','are','but','not','you','all','can','her','was','one','our',
  743. 'out','about','have','from','they','this','that','with','will','been','were',
  744. 'when','what','your','more','also','than','then','into','its','just','like',
  745. 'some','their','there','these','those','which','would','could','should','after',
  746. 'very','well','here','where','does','each','both','such','even','most','said',
  747. 'over','only','same','much','before','through','while','under','first','last',
  748. ])
  749. function extractKeywordHashtags(text: string): string[] {
  750. return [
  751. ...new Set(
  752. text
  753. .replace(/[^a-zA-Z\s]/g, ' ')
  754. .toLowerCase()
  755. .split(/\s+/)
  756. .filter((w) => w.length >= 4 && !STOP_WORDS.has(w))
  757. ),
  758. ]
  759. .slice(0, 8)
  760. .map((w) => `#${w}`)
  761. }
  762. function parseHashtagsFromResponse(text: string): string[] {
  763. const tags = (text.match(/#[a-zA-Z]\w*/g) || [])
  764. .map((t) => t.toLowerCase())
  765. return [...new Set(tags)].slice(0, 10)
  766. }
  767. async function suggestHashtags() {
  768. const content = composeStore.content.trim()
  769. if (!content || content.length < 30) { suggestedHashtags.value = []; return }
  770. hashtagsLoading.value = true
  771. try {
  772. if (aiConfigured.value) {
  773. const firstDest = composeStore.selectedDestinations[0]
  774. const profile = firstDest ? (profileCache[firstDest.key] || {}) : {}
  775. const platforms = composeStore.selectedDestinations.map((d: { platform: string }) => d.platform).join(', ')
  776. const system = 'You are a social media hashtag expert. Return ONLY hashtags, no explanation or extra text.'
  777. const prompt = [
  778. `Suggest 8 relevant hashtags for the following social media post.`,
  779. platforms ? `Platform: ${platforms}` : '',
  780. profile.industry ? `Industry: ${profile.industry}` : '',
  781. profile.keywords ? `Keywords: ${profile.keywords}` : '',
  782. ``,
  783. `Post content:`,
  784. content,
  785. ``,
  786. `Return exactly 8 hashtags as a space-separated list. Example: #marketing #growth #tips`,
  787. ].filter(Boolean).join('\n')
  788. const text = await aiStore.generate(prompt, system)
  789. const parsed = parseHashtagsFromResponse(text)
  790. suggestedHashtags.value = parsed.length ? parsed : extractKeywordHashtags(content)
  791. } else {
  792. suggestedHashtags.value = extractKeywordHashtags(content)
  793. }
  794. } catch {
  795. suggestedHashtags.value = extractKeywordHashtags(content)
  796. } finally {
  797. hashtagsLoading.value = false
  798. }
  799. }
  800. function contentHasTag(tag: string): boolean {
  801. return composeStore.content.toLowerCase().includes(tag.toLowerCase())
  802. }
  803. function insertHashtag(tag: string) {
  804. if (contentHasTag(tag)) return
  805. const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
  806. composeStore.content += `${sep}${tag}`
  807. }
  808. function insertHashtagGroup(hashtags: string[]) {
  809. const toInsert = hashtags.filter((t) => !contentHasTag(t))
  810. if (!toInsert.length) return
  811. const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
  812. composeStore.content += `${sep}${toInsert.join(' ')}`
  813. }
  814. // Debounced watcher — triggers suggestion after 1.5 s of no typing
  815. watch(
  816. () => composeStore.content,
  817. (val: string) => {
  818. if (generating.value) return // skip while AI is actively writing
  819. if (hashtagDebounceTimer) clearTimeout(hashtagDebounceTimer)
  820. if (val.trim().length < 30) { suggestedHashtags.value = []; return }
  821. hashtagDebounceTimer = setTimeout(() => suggestHashtags(), 1500)
  822. }
  823. )
  824. async function handleSaveDraft() {
  825. const ok = await composeStore.saveDraft()
  826. if (ok) {
  827. draftSavedBanner.value = true
  828. setTimeout(() => { draftSavedBanner.value = false }, 2500)
  829. }
  830. }
  831. async function handlePost() {
  832. await composeStore.post(scheduleTimezone.value)
  833. if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
  834. }
  835. </script>