Compose.vue 44 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016
  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. <!-- Community research strip -->
  257. <div class="border-t border-violet-900/30 pt-2 space-y-2">
  258. <div class="flex items-center gap-2 flex-wrap">
  259. <button
  260. @click="researchAudience"
  261. :disabled="researchLoading || !aiContextAccount"
  262. class="flex items-center gap-1 text-xs px-2.5 py-1 bg-gray-800 hover:bg-gray-700 disabled:opacity-40 border border-gray-700 rounded-lg transition-colors text-gray-300"
  263. >
  264. <i class="fa-solid fa-magnifying-glass-chart text-[10px]" :class="{ 'animate-pulse': researchLoading }"></i>
  265. {{ researchLoading ? $t('compose.researching') : $t('compose.researchAudience') }}
  266. </button>
  267. <span v-if="researchBriefAt" class="text-xs text-gray-600">{{ researchBriefAgeLabel() }}</span>
  268. <button
  269. v-if="researchBrief"
  270. @click="researchBriefOpen = !researchBriefOpen"
  271. class="ml-auto text-xs text-gray-600 hover:text-gray-400"
  272. >{{ researchBriefOpen ? '▲' : '▼' }} {{ $t('compose.researchBriefLabel') }}</button>
  273. </div>
  274. <!-- Brief preview -->
  275. <div v-if="researchBrief && researchBriefOpen" class="bg-gray-800/60 rounded-lg p-2.5 text-xs text-gray-300 space-y-1.5">
  276. <div v-if="researchBrief.painPoints?.length">
  277. <span class="text-gray-500 font-medium">Pain points: </span>{{ researchBrief.painPoints.slice(0, 3).join(' · ') }}
  278. </div>
  279. <div v-if="researchBrief.contentAngles?.length">
  280. <span class="text-gray-500 font-medium">Content angles: </span>{{ researchBrief.contentAngles.slice(0, 3).join(' · ') }}
  281. </div>
  282. <div v-if="researchBrief.communityLanguage?.length">
  283. <span class="text-gray-500 font-medium">Community language: </span>{{ researchBrief.communityLanguage.slice(0, 5).join(', ') }}
  284. </div>
  285. </div>
  286. <!-- Use brief checkbox -->
  287. <div v-if="researchBrief" class="flex items-center gap-2 text-xs text-gray-400">
  288. <input id="useResearchCtx" v-model="useResearchBrief" type="checkbox" class="accent-violet-500" />
  289. <label for="useResearchCtx" class="cursor-pointer select-none">{{ $t('compose.useResearchBrief') }}</label>
  290. </div>
  291. </div>
  292. </template>
  293. </div>
  294. </div>
  295. <!-- Hashtag suggestions -->
  296. <div
  297. v-if="suggestedHashtags.length || hashtagsLoading"
  298. class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3"
  299. >
  300. <div class="flex items-center gap-2 mb-2">
  301. <span class="text-xs text-gray-500">{{ $t('compose.hashtagSuggestions') }}</span>
  302. <span v-if="hashtagsLoading" class="text-xs text-gray-600 animate-pulse">{{ $t('compose.hashtagsLoading') }}</span>
  303. <button
  304. v-else
  305. @click="suggestHashtags()"
  306. class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
  307. :title="$t('compose.hashtagsRefresh')"
  308. >↻</button>
  309. </div>
  310. <div class="flex flex-wrap gap-1.5">
  311. <button
  312. v-for="tag in suggestedHashtags"
  313. :key="tag"
  314. @click="insertHashtag(tag)"
  315. :disabled="contentHasTag(tag)"
  316. class="text-xs px-2.5 py-0.5 rounded-full border transition-colors"
  317. :class="contentHasTag(tag)
  318. ? 'border-gray-700 text-gray-600 cursor-default'
  319. : 'border-violet-700/60 text-violet-300 hover:bg-violet-900/30 hover:border-violet-600'"
  320. >{{ tag }}</button>
  321. </div>
  322. </div>
  323. <!-- Hashtag Groups -->
  324. <div
  325. v-if="hashtagStore.groups.length"
  326. class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3"
  327. >
  328. <p class="text-xs text-gray-500 mb-2">{{ $t('compose.hashtagGroups') }}</p>
  329. <div class="flex flex-wrap gap-1.5">
  330. <button
  331. v-for="group in hashtagStore.groups"
  332. :key="group._id"
  333. @click="insertHashtagGroup(group.hashtags)"
  334. 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"
  335. :title="group.hashtags.join(' ')"
  336. >
  337. # {{ group.name }}
  338. <span class="text-emerald-600 ml-1">{{ group.hashtags.length }}</span>
  339. </button>
  340. </div>
  341. </div>
  342. <!-- First Comment -->
  343. <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
  344. <button
  345. @click="firstCommentOpen = !firstCommentOpen"
  346. 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"
  347. >
  348. <div class="flex items-center gap-2">
  349. <svg class="w-4 h-4 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  350. <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" />
  351. </svg>
  352. <span>{{ $t('compose.firstCommentToggle') }}</span>
  353. <span v-if="composeStore.firstComment.trim()" class="w-2 h-2 rounded-full bg-blue-400 inline-block" />
  354. </div>
  355. <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">
  356. <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
  357. </svg>
  358. </button>
  359. <div v-if="firstCommentOpen" class="border-t border-gray-800 p-4 space-y-2">
  360. <textarea
  361. v-model="composeStore.firstComment"
  362. :placeholder="$t('compose.firstCommentPlaceholder')"
  363. rows="3"
  364. 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"
  365. ></textarea>
  366. <p class="text-xs text-gray-500">{{ $t('compose.firstCommentHint') }}</p>
  367. </div>
  368. </div>
  369. <!-- Instagram warning -->
  370. <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">
  371. <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>
  372. {{ $t('compose.igImageRequired') }}
  373. </div>
  374. <!-- Schedule + Post -->
  375. <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 space-y-3">
  376. <!-- Row 1: datetime + timezone -->
  377. <div class="flex items-center gap-2">
  378. <svg class="w-4 h-4 text-gray-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
  379. <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" />
  380. </svg>
  381. <input
  382. v-model="composeStore.scheduledAt"
  383. type="datetime-local"
  384. class="flex-1 bg-transparent text-sm text-gray-300 focus:outline-none min-w-0"
  385. :title="$t('compose.scheduleTitle')"
  386. />
  387. <!-- Timezone selector -->
  388. <select
  389. v-model="scheduleTimezone"
  390. :title="$t('compose.timezoneLabel')"
  391. 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]"
  392. >
  393. <option v-for="tz in COMMON_TIMEZONES" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
  394. </select>
  395. <span class="text-xs text-gray-600 flex-shrink-0 hidden sm:block">{{ timezoneAbbr }}</span>
  396. <button
  397. v-if="composeStore.scheduledAt"
  398. @click="composeStore.scheduledAt = ''"
  399. class="text-gray-600 hover:text-gray-400 text-xs flex-shrink-0"
  400. >✕</button>
  401. </div>
  402. <!-- Suggested times strip -->
  403. <div v-if="suggestionsLoading || suggestions.length" class="flex items-start gap-2 flex-wrap">
  404. <span class="text-xs text-gray-500 shrink-0 mt-0.5">{{ $t('compose.suggestedTimes') }}</span>
  405. <span v-if="suggestionsLoading" class="text-xs text-gray-600 animate-pulse">{{ $t('compose.suggestionsLoading') }}</span>
  406. <template v-else>
  407. <button
  408. v-for="s in suggestions"
  409. :key="s.utc"
  410. @click="applySuggestion(s)"
  411. 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"
  412. :class="{ 'opacity-50 ring-1 ring-amber-500': composeStore.scheduledAt === utcToNaiveDatetimeString(s.utc, scheduleTimezone) }"
  413. >{{ formatSuggestionChip(s) }}</button>
  414. <span class="text-xs text-gray-600 self-center">— {{ $t(suggestionsSource === 'history' ? 'compose.suggestionsFromHistory' : 'compose.suggestionsFromDefaults') }}</span>
  415. </template>
  416. </div>
  417. <!-- Row 2: actions -->
  418. <div class="flex items-center justify-end gap-2">
  419. <button
  420. @click="handleSaveDraft"
  421. :disabled="composeStore.savingDraft || !composeStore.content.trim()"
  422. 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"
  423. >
  424. {{ composeStore.savingDraft ? $t('compose.savingDraft') : (composeStore.draftId ? $t('compose.updateDraft') : $t('compose.saveDraft')) }}
  425. </button>
  426. <button
  427. @click="handlePost"
  428. :disabled="composeStore.sending || !canPost"
  429. class="px-5 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40"
  430. :class="composeStore.scheduledAt ? 'bg-amber-600 hover:bg-amber-700' : 'bg-blue-600 hover:bg-blue-700'"
  431. >
  432. {{ composeStore.sending ? $t('compose.sending') : postButtonLabel }}
  433. </button>
  434. </div>
  435. </div>
  436. <!-- Success message -->
  437. <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">
  438. {{ $t('compose.successMessage') }}
  439. </div>
  440. <!-- Draft saved message -->
  441. <div v-if="draftSavedBanner" class="bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-gray-300">
  442. {{ $t('compose.draftSaved') }}
  443. </div>
  444. </div>
  445. </div>
  446. <!-- ── Right panel: preview ── -->
  447. <div class="w-80 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col overflow-hidden">
  448. <div class="px-4 py-3 border-b border-gray-800 flex-shrink-0">
  449. <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest">{{ $t('compose.preview') }}</p>
  450. </div>
  451. <div class="flex-1 overflow-y-auto p-4">
  452. <PostPreview
  453. :selectedDestinations="composeStore.selectedDestinations"
  454. :activeKey="activePreviewKey"
  455. :content="composeStore.content"
  456. :mediaUrl="composeStore.mediaUrl"
  457. @update:activeKey="activePreviewKey = $event"
  458. />
  459. </div>
  460. </div>
  461. </div>
  462. </template>
  463. <script setup lang="ts">
  464. import { ref, computed, watch, nextTick, onMounted } from 'vue'
  465. import { useRouter, useRoute } from 'vue-router'
  466. import { useI18n } from 'vue-i18n'
  467. import axios from 'axios'
  468. import { useComposeStore } from '../stores/compose'
  469. import { usePlatformsStore } from '../stores/platforms'
  470. import { useAiStore } from '../stores/ai'
  471. import { useHashtagStore } from '../stores/hashtags'
  472. import { useCompetitorStore } from '../stores/competitors'
  473. import PostPreview from '../components/compose/PostPreview.vue'
  474. import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr, utcToNaiveDatetimeString } from '../utils/timezone'
  475. const { t } = useI18n()
  476. const composeStore = useComposeStore()
  477. const platformsStore = usePlatformsStore()
  478. const aiStore = useAiStore()
  479. const hashtagStore = useHashtagStore()
  480. const competitorStore = useCompetitorStore()
  481. const router = useRouter()
  482. const route = useRoute()
  483. const fileInputRef = ref<HTMLInputElement | null>(null)
  484. const urlInputRef = ref<HTMLInputElement | null>(null)
  485. const pasteUrlValue = ref('')
  486. const showUrlInput = ref(false)
  487. const uploading = ref(false)
  488. const uploadError = ref('')
  489. const mediaLoadError = ref(false)
  490. const activePreviewKey = ref('')
  491. const draftSavedBanner = ref(false)
  492. const firstCommentOpen = ref(false)
  493. onMounted(async () => {
  494. await Promise.all([
  495. platformsStore.fetchStatuses(),
  496. platformsStore.fetchMetaConnections(),
  497. aiStore.fetchConfig(),
  498. hashtagStore.fetchGroups(),
  499. competitorStore.fetchCompetitors(),
  500. ])
  501. composeStore.initDestinations()
  502. // Pre-fill content when arriving from Competitor Roadmap ("Draft this post")
  503. if (route.query.prefill) {
  504. composeStore.content = String(route.query.prefill)
  505. }
  506. // Pre-fill media URL when arriving from the Media Library ("Use in Post")
  507. if (route.query.media) {
  508. composeStore.mediaUrl = String(route.query.media)
  509. mediaLoadError.value = false
  510. }
  511. // Load draft when arriving via ?draft=ID
  512. if (route.query.draft) {
  513. try {
  514. const res = await axios.get(`/api/drafts/${route.query.draft}`)
  515. composeStore.loadDraft(res.data)
  516. mediaLoadError.value = false
  517. } catch (err) {
  518. console.error('Failed to load draft:', err)
  519. }
  520. }
  521. })
  522. // Keep activePreviewKey pointed at a selected destination
  523. watch(
  524. () => composeStore.selectedDestinations,
  525. (selected) => {
  526. if (!selected.find((d) => d.key === activePreviewKey.value)) {
  527. activePreviewKey.value = selected[0]?.key ?? ''
  528. }
  529. },
  530. { deep: true }
  531. )
  532. function toggle(key: string) {
  533. composeStore.toggleDestination(key)
  534. const dest = composeStore.destinations.find((d) => d.key === key)
  535. if (dest?.selected) activePreviewKey.value = key
  536. }
  537. async function handleFileChange(event: Event) {
  538. const file = (event.target as HTMLInputElement).files?.[0]
  539. if (!file) return
  540. uploading.value = true
  541. uploadError.value = ''
  542. mediaLoadError.value = false
  543. try {
  544. const form = new FormData()
  545. form.append('file', file)
  546. const res = await axios.post('/api/upload', form, {
  547. headers: { 'Content-Type': 'multipart/form-data' },
  548. })
  549. composeStore.mediaUrl = res.data.url
  550. } catch (err: any) {
  551. uploadError.value = err.response?.data?.error ?? t('compose.uploadFailed')
  552. } finally {
  553. uploading.value = false
  554. // Reset file input so the same file can be re-selected if needed
  555. if (fileInputRef.value) fileInputRef.value.value = ''
  556. }
  557. }
  558. async function toggleUrlInput() {
  559. showUrlInput.value = !showUrlInput.value
  560. uploadError.value = ''
  561. if (showUrlInput.value) {
  562. await nextTick()
  563. urlInputRef.value?.focus()
  564. }
  565. }
  566. function applyPastedUrl() {
  567. const url = pasteUrlValue.value.trim()
  568. if (url) {
  569. composeStore.mediaUrl = url
  570. pasteUrlValue.value = ''
  571. showUrlInput.value = false
  572. mediaLoadError.value = false
  573. }
  574. }
  575. function removeMedia() {
  576. composeStore.mediaUrl = ''
  577. mediaLoadError.value = false
  578. uploadError.value = ''
  579. showUrlInput.value = false
  580. }
  581. // ─── Schedule Timezone ────────────────────────────────────────────────────────
  582. const scheduleTimezone = ref(getBrowserTimezone())
  583. const timezoneAbbr = computed(() => getTimezoneAbbr(scheduleTimezone.value))
  584. // ─── Schedule Suggestions ────────────────────────────────────────────────────
  585. interface Suggestion { utc: string; dayOfWeek: number; hour: number; label: string }
  586. const suggestions = ref<Suggestion[]>([])
  587. const suggestionsSource = ref<'history' | 'default' | ''>('')
  588. const suggestionsLoading = ref(false)
  589. async function loadSuggestions() {
  590. const first = composeStore.selectedDestinations[0]
  591. if (!first) { suggestions.value = []; return }
  592. suggestionsLoading.value = true
  593. try {
  594. const params: Record<string, string> = { platform: first.platform }
  595. if (first.accountId) params.accountId = first.accountId
  596. const res = await axios.get('/api/schedule/suggestions', { params })
  597. suggestions.value = res.data.suggestions ?? []
  598. suggestionsSource.value = res.data.source ?? ''
  599. } catch {
  600. suggestions.value = []
  601. } finally {
  602. suggestionsLoading.value = false
  603. }
  604. }
  605. function applySuggestion(s: Suggestion) {
  606. composeStore.scheduledAt = utcToNaiveDatetimeString(s.utc, scheduleTimezone.value)
  607. }
  608. function formatSuggestionChip(s: Suggestion): string {
  609. return new Intl.DateTimeFormat(undefined, {
  610. weekday: 'short', month: 'short', day: 'numeric',
  611. hour: 'numeric', minute: '2-digit',
  612. timeZone: scheduleTimezone.value,
  613. hour12: true,
  614. }).format(new Date(s.utc))
  615. }
  616. // Reload suggestions when the selected platform changes (debounced to avoid
  617. // multiple requests when selecting several destinations quickly)
  618. let suggestionTimer: ReturnType<typeof setTimeout> | null = null
  619. watch(
  620. () => composeStore.selectedDestinations[0]?.key,
  621. () => {
  622. if (suggestionTimer) clearTimeout(suggestionTimer)
  623. suggestionTimer = setTimeout(loadSuggestions, 300)
  624. }
  625. )
  626. // Auto-populate timezone from the first selected destination's profile.
  627. watch(
  628. () => composeStore.selectedDestinations[0]?.key,
  629. async (key: string | undefined) => {
  630. if (!key) return
  631. try {
  632. const cached = profileCache[key]
  633. const profile = cached ?? (await axios.get(`/api/profiles/${encodeURIComponent(key)}`)).data
  634. if (!cached) profileCache[key] = profile
  635. if (profile?.timezone) scheduleTimezone.value = profile.timezone
  636. } catch { /* leave current timezone */ }
  637. },
  638. { immediate: true }
  639. )
  640. function isImage(url: string) {
  641. return /\.(jpe?g|png|gif|webp)(\?.*)?$/i.test(url)
  642. }
  643. const mediaFilename = computed(() => {
  644. try { return decodeURIComponent(composeStore.mediaUrl.split('/').pop() ?? '') } catch { return composeStore.mediaUrl }
  645. })
  646. const igSelectedWithoutMedia = computed(() =>
  647. composeStore.selectedDestinations.some((d) => d.platform === 'instagram') &&
  648. !composeStore.mediaUrl.trim()
  649. )
  650. const overLimit = computed(() =>
  651. !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit
  652. )
  653. const charNearLimit = computed(() =>
  654. !!composeStore.activeCharLimit && composeStore.content.length > composeStore.activeCharLimit * 0.9
  655. )
  656. const canPost = computed(() =>
  657. !!composeStore.content.trim() &&
  658. composeStore.selectedDestinations.length > 0 &&
  659. !overLimit.value &&
  660. !igSelectedWithoutMedia.value
  661. )
  662. const postButtonLabel = computed(() =>
  663. composeStore.scheduledAt ? `⏰ ${t('compose.schedule')}` : t('compose.send')
  664. )
  665. // ─── AI Generation ────────────────────────────────────────────────────────────
  666. const aiPanelOpen = ref(false)
  667. const aiTopic = ref('')
  668. const aiGoal = ref('')
  669. const aiToneOverride = ref('')
  670. const generating = ref(false)
  671. const aiError = ref(false)
  672. const aiContextAccount = ref('')
  673. const abortController = ref<AbortController | null>(null)
  674. const useCompetitorContext = ref(false)
  675. const useResearchBrief = ref(false)
  676. const researchLoading = ref(false)
  677. const researchBrief = ref<{ painPoints: string[]; trendingTopics: string[]; communityLanguage: string[]; contentAngles: string[] } | null>(null)
  678. const researchBriefAt = ref<Date | null>(null)
  679. const researchBriefOpen = ref(false)
  680. const hasCompetitorSummaries = computed(() =>
  681. competitorStore.competitors.some((c) => c.aiSummary?.trim())
  682. )
  683. const competitorNames = computed(() =>
  684. competitorStore.competitors.filter((c) => c.aiSummary?.trim()).map((c) => c.name).join(', ')
  685. )
  686. const aiConfigured = computed(() => aiStore.config.enabled && !!aiStore.config.endpoint)
  687. function toggleAiPanel() {
  688. aiPanelOpen.value = !aiPanelOpen.value
  689. if (aiPanelOpen.value) loadAiContext()
  690. }
  691. // Profile cache keyed by destination key
  692. const profileCache: Record<string, Record<string, string>> = {}
  693. async function loadAiContext() {
  694. const firstDest = composeStore.selectedDestinations[0]
  695. if (!firstDest) { aiContextAccount.value = ''; return }
  696. aiContextAccount.value = firstDest.label
  697. if (!profileCache[firstDest.key]) {
  698. try {
  699. const res = await axios.get(`/api/profiles/${encodeURIComponent(firstDest.key)}`)
  700. profileCache[firstDest.key] = res.data
  701. } catch {
  702. profileCache[firstDest.key] = {}
  703. }
  704. }
  705. }
  706. function buildSystemPrompt(profile: Record<string, string>): string {
  707. const platforms = composeStore.selectedDestinations.map((d) => d.platform).join(', ')
  708. const charLimit = composeStore.activeCharLimit ? `${composeStore.activeCharLimit} characters` : 'no strict limit'
  709. const tone = aiToneOverride.value || profile.toneOfVoice || 'professional'
  710. const lines = [
  711. 'You are a social media content writer. Write engaging, on-brand post content.',
  712. '',
  713. 'BRAND CONTEXT:',
  714. ]
  715. if (profile.businessName) lines.push(`Business: ${profile.businessName}`)
  716. if (profile.description) lines.push(`Description: ${profile.description}`)
  717. if (profile.industry) lines.push(`Industry: ${profile.industry}`)
  718. if (profile.targetAudience) lines.push(`Target audience: ${profile.targetAudience}`)
  719. if (profile.keywords) lines.push(`Keywords: ${profile.keywords}`)
  720. if (profile.hashtags) lines.push(`Preferred hashtags: ${profile.hashtags}`)
  721. if (profile.postingGuidelines) lines.push(`Guidelines: ${profile.postingGuidelines}`)
  722. lines.push('', 'PLATFORM RULES:')
  723. lines.push(`Platform(s): ${platforms || 'general'}`)
  724. lines.push(`Character limit: ${charLimit}`)
  725. lines.push(`Tone of voice: ${tone}`)
  726. if (aiGoal.value) lines.push(`Goal: ${aiGoal.value}`)
  727. lines.push('', 'OUTPUT RULES:')
  728. lines.push('- Write ONLY the post content, nothing else.')
  729. lines.push('- No preamble, no explanation, no quotation marks around the post.')
  730. lines.push('- Include relevant hashtags if appropriate.')
  731. lines.push('- Stay within the character limit.')
  732. return lines.join('\n')
  733. }
  734. async function researchAudience() {
  735. const firstDest = composeStore.selectedDestinations[0]
  736. researchLoading.value = true
  737. try {
  738. const res = await axios.post('/api/ai/research', { accountKey: firstDest?.key })
  739. researchBrief.value = res.data.brief
  740. researchBriefAt.value = new Date(res.data.updatedAt)
  741. researchBriefOpen.value = true
  742. useResearchBrief.value = true
  743. } catch (err: any) {
  744. console.error('Research failed:', err)
  745. } finally {
  746. researchLoading.value = false
  747. }
  748. }
  749. function researchBriefAgeLabel(): string {
  750. if (!researchBriefAt.value) return ''
  751. const mins = Math.round((Date.now() - researchBriefAt.value.getTime()) / 60000)
  752. if (mins < 1) return t('compose.researchJustNow')
  753. if (mins < 60) return t('compose.researchMinutesAgo', { n: mins })
  754. const hrs = Math.round(mins / 60)
  755. return t('compose.researchHoursAgo', { n: hrs })
  756. }
  757. async function generatePost() {
  758. aiError.value = false
  759. const firstDest = composeStore.selectedDestinations[0]
  760. const profile = firstDest ? (profileCache[firstDest.key] || {}) : {}
  761. const system = buildSystemPrompt(profile)
  762. const prompt = aiTopic.value.trim()
  763. abortController.value = new AbortController()
  764. generating.value = true
  765. composeStore.content = ''
  766. try {
  767. const gen = aiStore.streamGenerate(
  768. prompt, system, undefined, abortController.value.signal,
  769. useCompetitorContext.value, composeStore.selectedDestinations,
  770. useResearchBrief.value, firstDest?.key,
  771. )
  772. for await (const token of gen) {
  773. composeStore.content += token
  774. }
  775. } catch (err: any) {
  776. if (err.name !== 'AbortError') {
  777. aiError.value = true
  778. console.error('AI generation error:', err)
  779. }
  780. } finally {
  781. generating.value = false
  782. abortController.value = null
  783. }
  784. }
  785. function stopGeneration() {
  786. abortController.value?.abort()
  787. }
  788. // ─── Image Caption (Vision) ───────────────────────────────────────────────────
  789. const captionGenerating = ref(false)
  790. const captionError = ref(false)
  791. async function generateCaption() {
  792. captionError.value = false
  793. captionGenerating.value = true
  794. try {
  795. const caption = await aiStore.generateCaption(composeStore.mediaUrl)
  796. const sep = composeStore.content.trim() ? '\n\n' : ''
  797. composeStore.content = composeStore.content.trim() + sep + caption
  798. } catch {
  799. captionError.value = true
  800. } finally {
  801. captionGenerating.value = false
  802. }
  803. }
  804. // ─── Hashtag Suggestions ──────────────────────────────────────────────────────
  805. const suggestedHashtags = ref<string[]>([])
  806. const hashtagsLoading = ref(false)
  807. let hashtagDebounceTimer: ReturnType<typeof setTimeout> | null = null
  808. // Stop words to filter out in keyword-extraction fallback
  809. const STOP_WORDS = new Set([
  810. 'the','and','for','are','but','not','you','all','can','her','was','one','our',
  811. 'out','about','have','from','they','this','that','with','will','been','were',
  812. 'when','what','your','more','also','than','then','into','its','just','like',
  813. 'some','their','there','these','those','which','would','could','should','after',
  814. 'very','well','here','where','does','each','both','such','even','most','said',
  815. 'over','only','same','much','before','through','while','under','first','last',
  816. ])
  817. function extractKeywordHashtags(text: string): string[] {
  818. return [
  819. ...new Set(
  820. text
  821. .replace(/[^a-zA-Z\s]/g, ' ')
  822. .toLowerCase()
  823. .split(/\s+/)
  824. .filter((w) => w.length >= 4 && !STOP_WORDS.has(w))
  825. ),
  826. ]
  827. .slice(0, 8)
  828. .map((w) => `#${w}`)
  829. }
  830. function parseHashtagsFromResponse(text: string): string[] {
  831. const tags = (text.match(/#[a-zA-Z]\w*/g) || [])
  832. .map((t) => t.toLowerCase())
  833. return [...new Set(tags)].slice(0, 10)
  834. }
  835. async function suggestHashtags() {
  836. const content = composeStore.content.trim()
  837. if (!content || content.length < 30) { suggestedHashtags.value = []; return }
  838. hashtagsLoading.value = true
  839. try {
  840. if (aiConfigured.value) {
  841. const firstDest = composeStore.selectedDestinations[0]
  842. const profile = firstDest ? (profileCache[firstDest.key] || {}) : {}
  843. const platforms = composeStore.selectedDestinations.map((d: { platform: string }) => d.platform).join(', ')
  844. const system = 'You are a social media hashtag expert. Return ONLY hashtags, no explanation or extra text.'
  845. const prompt = [
  846. `Suggest 8 relevant hashtags for the following social media post.`,
  847. platforms ? `Platform: ${platforms}` : '',
  848. profile.industry ? `Industry: ${profile.industry}` : '',
  849. profile.keywords ? `Keywords: ${profile.keywords}` : '',
  850. ``,
  851. `Post content:`,
  852. content,
  853. ``,
  854. `Return exactly 8 hashtags as a space-separated list. Example: #marketing #growth #tips`,
  855. ].filter(Boolean).join('\n')
  856. const text = await aiStore.generate(prompt, system)
  857. const parsed = parseHashtagsFromResponse(text)
  858. suggestedHashtags.value = parsed.length ? parsed : extractKeywordHashtags(content)
  859. } else {
  860. suggestedHashtags.value = extractKeywordHashtags(content)
  861. }
  862. } catch {
  863. suggestedHashtags.value = extractKeywordHashtags(content)
  864. } finally {
  865. hashtagsLoading.value = false
  866. }
  867. }
  868. function contentHasTag(tag: string): boolean {
  869. return composeStore.content.toLowerCase().includes(tag.toLowerCase())
  870. }
  871. function insertHashtag(tag: string) {
  872. if (contentHasTag(tag)) return
  873. const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
  874. composeStore.content += `${sep}${tag}`
  875. }
  876. function insertHashtagGroup(hashtags: string[]) {
  877. const toInsert = hashtags.filter((t) => !contentHasTag(t))
  878. if (!toInsert.length) return
  879. const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
  880. composeStore.content += `${sep}${toInsert.join(' ')}`
  881. }
  882. // Debounced watcher — triggers suggestion after 1.5 s of no typing
  883. watch(
  884. () => composeStore.content,
  885. (val: string) => {
  886. if (generating.value) return // skip while AI is actively writing
  887. if (hashtagDebounceTimer) clearTimeout(hashtagDebounceTimer)
  888. if (val.trim().length < 30) { suggestedHashtags.value = []; return }
  889. hashtagDebounceTimer = setTimeout(() => suggestHashtags(), 1500)
  890. }
  891. )
  892. async function handleSaveDraft() {
  893. const ok = await composeStore.saveDraft()
  894. if (ok) {
  895. draftSavedBanner.value = true
  896. setTimeout(() => { draftSavedBanner.value = false }, 2500)
  897. }
  898. }
  899. async function handlePost() {
  900. await composeStore.post(scheduleTimezone.value)
  901. if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
  902. }
  903. </script>