Settings.vue 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857
  1. <template>
  2. <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
  3. <div class="max-w-2xl mx-auto space-y-8">
  4. <div>
  5. <h1 class="text-2xl font-bold mb-1">{{ $t('settings.title') }}</h1>
  6. </div>
  7. <!-- ═══════════════════════════════════════════════════════════════════
  8. FACEBOOK & INSTAGRAM — OAuth connection card
  9. ════════════════════════════════════════════════════════════════════ -->
  10. <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
  11. <!-- Header -->
  12. <div class="p-5 border-b border-gray-800 flex items-center gap-3">
  13. <div class="flex gap-1.5">
  14. <span class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold" style="background:#1877F2">f</span>
  15. <span class="w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-bold" style="background:#E1306C">I</span>
  16. </div>
  17. <div>
  18. <p class="font-semibold">{{ $t('settings.meta.sectionTitle') }}</p>
  19. <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.meta.sectionSubtitle') }}</p>
  20. </div>
  21. </div>
  22. <!-- OAuth error banner -->
  23. <div v-if="oauthError" class="mx-5 mt-4 bg-red-900/40 border border-red-700 rounded-lg p-3 text-sm text-red-300 flex items-start gap-2">
  24. <span class="shrink-0">⚠</span>
  25. <span><strong>{{ $t('settings.meta.errorTitle') }}:</strong> {{ oauthError }}</span>
  26. </div>
  27. <!-- Step 1: App credentials -->
  28. <div class="p-5 border-b border-gray-800/60">
  29. <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Step 1 — Facebook Developer App</p>
  30. <div v-if="metaAppConfigured" class="flex items-center justify-between">
  31. <div class="flex items-center gap-2 text-sm text-green-400">
  32. <span>✓</span>
  33. <span>{{ $t('settings.meta.appConfigured') }}</span>
  34. <span class="text-gray-600 font-mono text-xs">({{ platformsStore.metaCredentials.appId }})</span>
  35. </div>
  36. <button @click="editingApp = !editingApp" class="text-xs px-2.5 py-1 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-md text-gray-400 hover:text-gray-200 transition-colors">
  37. Edit
  38. </button>
  39. </div>
  40. <div v-if="!metaAppConfigured || editingApp" class="space-y-3 mt-2">
  41. <div>
  42. <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.meta.appIdLabel') }}</label>
  43. <input
  44. v-model="appId"
  45. type="text"
  46. :placeholder="$t('settings.meta.appIdPlaceholder')"
  47. 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-blue-500"
  48. />
  49. </div>
  50. <div>
  51. <label class="block text-xs text-gray-400 mb-1">{{ $t('settings.meta.appSecretLabel') }}</label>
  52. <input
  53. v-model="appSecret"
  54. type="password"
  55. :placeholder="metaAppConfigured ? platformsStore.metaCredentials.appSecretHint : $t('settings.meta.appSecretPlaceholder')"
  56. 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-blue-500"
  57. />
  58. </div>
  59. <div class="flex items-center justify-between">
  60. <p class="text-xs text-gray-600">
  61. {{ $t('settings.meta.getAppHelp') }}
  62. <a href="https://developers.facebook.com/apps/" target="_blank" rel="noopener" class="text-blue-400 hover:text-blue-300 underline">
  63. {{ $t('settings.meta.devPortal') }}
  64. </a>
  65. </p>
  66. <button
  67. @click="saveApp"
  68. :disabled="!appId || !appSecret || platformsStore.metaLoading"
  69. class="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
  70. >
  71. {{ platformsStore.metaLoading ? $t('settings.meta.saving') : $t('settings.meta.saveApp') }}
  72. </button>
  73. </div>
  74. </div>
  75. </div>
  76. <!-- Step 2: OAuth connect -->
  77. <div class="p-5" :class="{ 'opacity-40 pointer-events-none': !metaAppConfigured }">
  78. <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Step 2 — Connect Accounts</p>
  79. <!-- Already connected — show summary + manage -->
  80. <div v-if="fbConnected || igConnected" class="space-y-3">
  81. <div v-if="fbPages.length" class="space-y-1.5">
  82. <p class="text-xs text-gray-500">{{ $t('settings.meta.connectedPages') }}</p>
  83. <div v-for="page in fbPages" :key="page.id" class="flex items-center gap-2 bg-gray-800/60 rounded-lg px-3 py-2">
  84. <img v-if="page.picture" :src="page.picture" class="w-6 h-6 rounded-full" />
  85. <span v-else class="w-6 h-6 rounded-full bg-blue-700 flex items-center justify-center text-xs font-bold">f</span>
  86. <span class="text-sm">{{ page.name }}</span>
  87. <span class="ml-auto w-2 h-2 rounded-full bg-green-400"></span>
  88. </div>
  89. </div>
  90. <div v-if="igAccounts.length" class="space-y-1.5">
  91. <p class="text-xs text-gray-500">{{ $t('settings.meta.connectedAccounts') }}</p>
  92. <div v-for="account in igAccounts" :key="account.id" class="flex items-center gap-2 bg-gray-800/60 rounded-lg px-3 py-2">
  93. <img v-if="account.avatar" :src="account.avatar" class="w-6 h-6 rounded-full" />
  94. <span v-else class="w-6 h-6 rounded-full bg-pink-700 flex items-center justify-center text-xs font-bold">I</span>
  95. <span class="text-sm">@{{ account.username }}</span>
  96. <span class="ml-auto w-2 h-2 rounded-full bg-green-400"></span>
  97. </div>
  98. </div>
  99. <!-- Token expiry warning banner -->
  100. <div
  101. v-if="platformsStore.hasExpiryWarning"
  102. class="rounded-lg bg-yellow-900/30 border border-yellow-700/50 p-3 space-y-2"
  103. >
  104. <p class="text-xs font-semibold text-yellow-400">{{ $t('settings.meta.expiryWarningTitle') }}</p>
  105. <p
  106. v-for="account in platformsStore.expiringAccounts"
  107. :key="account.id"
  108. class="text-xs text-yellow-300"
  109. >
  110. {{ $tc('settings.meta.expiryWarningBody', account.daysLeft ?? 0, { username: '@' + account.username, days: account.daysLeft }) }}
  111. </p>
  112. <p class="text-xs text-gray-500">{{ $t('settings.meta.expiryAutoNote') }}</p>
  113. <div class="flex gap-2 pt-1">
  114. <button
  115. @click="handleTokenRefresh"
  116. :disabled="tokenRefreshing"
  117. class="px-3 py-1.5 bg-yellow-700 hover:bg-yellow-600 disabled:opacity-40 rounded-md text-xs font-medium transition-colors"
  118. >
  119. {{ tokenRefreshing ? $t('settings.meta.expiryRefreshing') : tokenRefreshDone ? $t('settings.meta.expiryRefreshDone') : $t('settings.meta.expiryRefreshToken') }}
  120. </button>
  121. <button
  122. @click="platformsStore.dismissTokenWarning()"
  123. class="px-3 py-1.5 text-gray-400 hover:text-gray-300 text-xs font-medium transition-colors"
  124. >
  125. {{ $t('settings.meta.expiryDismiss') }}
  126. </button>
  127. </div>
  128. </div>
  129. <div class="flex gap-2 pt-2">
  130. <button
  131. @click="platformsStore.startMetaOAuth()"
  132. :disabled="platformsStore.metaLoading"
  133. class="px-4 py-2 bg-gray-700 hover:bg-gray-600 border border-gray-600 disabled:opacity-40 rounded-lg text-xs font-medium transition-colors"
  134. >
  135. {{ $t('settings.meta.reconnect') }}
  136. </button>
  137. <button
  138. @click="confirmDisconnect"
  139. :disabled="platformsStore.metaLoading"
  140. class="px-4 py-2 text-red-400 hover:text-red-300 bg-red-900/20 hover:bg-red-900/40 border border-red-900/50 disabled:opacity-40 rounded-lg text-xs font-medium transition-colors"
  141. >
  142. {{ $t('settings.meta.disconnect') }}
  143. </button>
  144. </div>
  145. </div>
  146. <!-- Not yet connected -->
  147. <div v-else>
  148. <button
  149. @click="platformsStore.startMetaOAuth()"
  150. :disabled="!metaAppConfigured || platformsStore.metaLoading"
  151. class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 rounded-xl text-sm font-medium transition-colors flex items-center justify-center gap-2"
  152. >
  153. <span v-if="platformsStore.metaLoading">{{ $t('settings.meta.connecting') }}</span>
  154. <span v-else>{{ $t('settings.meta.connectButton') }}</span>
  155. </button>
  156. </div>
  157. </div>
  158. </div>
  159. <!-- ═══════════════════════════════════════════════════════════════════
  160. PAGE/ACCOUNT PICKER — shown after OAuth callback
  161. ════════════════════════════════════════════════════════════════════ -->
  162. <div
  163. v-if="showDiscovery"
  164. class="bg-gray-900 border border-blue-700 rounded-2xl overflow-hidden"
  165. >
  166. <div class="p-5 border-b border-gray-800">
  167. <p class="font-semibold">{{ $t('settings.meta.discoveryTitle') }}</p>
  168. <p class="text-xs text-gray-500 mt-1">{{ $t('settings.meta.discoverySubtitle') }}</p>
  169. </div>
  170. <div class="p-5 space-y-5">
  171. <!-- Facebook Pages -->
  172. <div>
  173. <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ $t('settings.meta.pagesHeading') }}</p>
  174. <div v-if="discovery.pages.length === 0" class="text-sm text-gray-600">{{ $t('settings.meta.noPages') }}</div>
  175. <div v-else class="space-y-2">
  176. <label
  177. v-for="page in discovery.pages"
  178. :key="page.id"
  179. class="flex items-center gap-3 bg-gray-800 rounded-xl px-4 py-3 cursor-pointer hover:bg-gray-750 transition-colors"
  180. >
  181. <input type="checkbox" :value="page.id" v-model="selectedPageIds" class="w-4 h-4 accent-blue-500" />
  182. <img v-if="page.picture" :src="page.picture" class="w-8 h-8 rounded-full" />
  183. <span v-else class="w-8 h-8 rounded-full bg-blue-700 flex items-center justify-center text-xs font-bold shrink-0">f</span>
  184. <span class="text-sm font-medium">{{ page.name }}</span>
  185. <span class="ml-auto text-xs text-gray-600 font-mono">{{ page.id }}</span>
  186. </label>
  187. </div>
  188. </div>
  189. <!-- Instagram Business Accounts -->
  190. <div>
  191. <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">{{ $t('settings.meta.igHeading') }}</p>
  192. <div v-if="discovery.igAccounts.length === 0" class="text-sm text-gray-600">{{ $t('settings.meta.noIgAccounts') }}</div>
  193. <div v-else class="space-y-2">
  194. <label
  195. v-for="account in discovery.igAccounts"
  196. :key="account.id"
  197. class="flex items-center gap-3 bg-gray-800 rounded-xl px-4 py-3 cursor-pointer hover:bg-gray-750 transition-colors"
  198. >
  199. <input type="checkbox" :value="account.id" v-model="selectedIgAccountIds" class="w-4 h-4 accent-pink-500" />
  200. <img v-if="account.avatar" :src="account.avatar" class="w-8 h-8 rounded-full" />
  201. <span v-else class="w-8 h-8 rounded-full bg-pink-700 flex items-center justify-center text-xs font-bold shrink-0">I</span>
  202. <div>
  203. <p class="text-sm font-medium">@{{ account.username }}</p>
  204. <p class="text-xs text-gray-600">{{ $t('settings.meta.igLinkedTo') }} {{ pageNameForId(account.pageId) }}</p>
  205. </div>
  206. <span class="ml-auto text-xs text-gray-600 font-mono">{{ account.id }}</span>
  207. </label>
  208. </div>
  209. </div>
  210. <!-- Confirm -->
  211. <div class="flex items-center justify-between pt-2">
  212. <p v-if="selectionError" class="text-xs text-red-400">{{ selectionError }}</p>
  213. <span v-else />
  214. <button
  215. @click="confirmSelection"
  216. :disabled="platformsStore.metaLoading"
  217. class="px-5 py-2 bg-green-600 hover:bg-green-700 disabled:opacity-40 rounded-xl text-sm font-semibold transition-colors"
  218. >
  219. {{ platformsStore.metaLoading ? $t('settings.meta.confirmingSelection') : $t('settings.meta.confirmSelection') }}
  220. </button>
  221. </div>
  222. </div>
  223. </div>
  224. <!-- ═══════════════════════════════════════════════════════════════════
  225. OTHER PLATFORMS — env-file based
  226. ════════════════════════════════════════════════════════════════════ -->
  227. <div>
  228. <p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">Other Platforms</p>
  229. <div class="space-y-3">
  230. <div
  231. v-for="(meta, key) in otherPlatforms"
  232. :key="key"
  233. class="bg-gray-900 border rounded-xl p-4 transition-colors"
  234. :class="isConnected(key) ? 'border-gray-700' : 'border-gray-800'"
  235. >
  236. <div class="flex items-center justify-between">
  237. <div class="flex items-center gap-3">
  238. <span
  239. class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
  240. :style="{ backgroundColor: meta.color }"
  241. >
  242. {{ meta.label[0] }}
  243. </span>
  244. <div>
  245. <p class="font-medium text-sm">{{ $t(`platforms.${key}`) }}</p>
  246. <p v-if="getStatus(key)?.username" class="text-xs text-gray-400">
  247. @{{ getStatus(key)?.username }}
  248. </p>
  249. <p v-else-if="getStatus(key)?.error" class="text-xs text-red-400">
  250. {{ getStatus(key)?.error }}
  251. </p>
  252. <p v-else class="text-xs text-gray-600">{{ $t('settings.notConnected') }}</p>
  253. </div>
  254. </div>
  255. <div class="flex items-center gap-2">
  256. <span class="w-2 h-2 rounded-full" :class="isConnected(key) ? 'bg-green-400' : 'bg-gray-600'"></span>
  257. <span class="text-xs" :class="isConnected(key) ? 'text-green-400' : 'text-gray-600'">
  258. {{ isConnected(key) ? $t('settings.connected') : $t('settings.notConnected') }}
  259. </span>
  260. </div>
  261. </div>
  262. <div v-if="!isConnected(key)" class="mt-3 bg-gray-800 rounded-lg p-3 text-xs text-gray-400 font-mono">
  263. <span v-if="key === 'twitter'">TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET</span>
  264. <span v-else-if="key === 'mastodon'">MASTODON_INSTANCE_URL, MASTODON_ACCESS_TOKEN</span>
  265. <span v-else-if="key === 'bluesky'">BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD</span>
  266. <span v-else-if="key === 'linkedin'">LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET</span>
  267. <span v-else-if="key === 'reddit'">REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD</span>
  268. <span v-else>— {{ $t('settings.envHint') }} —</span>
  269. </div>
  270. </div>
  271. </div>
  272. </div>
  273. <!-- ═══════════════════════════════════════════════════════════════════
  274. ACCOUNT PROFILES
  275. ════════════════════════════════════════════════════════════════════ -->
  276. <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
  277. <!-- Header -->
  278. <div class="p-5 border-b border-gray-800">
  279. <p class="font-semibold">{{ $t('settings.profiles.sectionTitle') }}</p>
  280. <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.profiles.sectionSubtitle') }}</p>
  281. </div>
  282. <!-- No accounts -->
  283. <div v-if="!allConnectedAccounts.length" class="px-5 py-6 text-sm text-gray-600 text-center">
  284. {{ $t('settings.profiles.noAccounts') }}
  285. </div>
  286. <!-- Account rows -->
  287. <div v-else class="divide-y divide-gray-800">
  288. <div v-for="account in allConnectedAccounts" :key="account.key">
  289. <!-- Account header row -->
  290. <button
  291. @click="toggleProfile(account.key)"
  292. class="w-full flex items-center gap-3 px-5 py-3.5 hover:bg-gray-800/50 transition-colors text-left"
  293. >
  294. <!-- Avatar -->
  295. <div class="flex-shrink-0">
  296. <img
  297. v-if="account.avatar"
  298. :src="account.avatar"
  299. class="w-8 h-8 rounded-full object-cover"
  300. />
  301. <span
  302. v-else
  303. class="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold"
  304. :style="{ backgroundColor: account.color }"
  305. >
  306. {{ account.label[0] }}
  307. </span>
  308. </div>
  309. <div class="flex-1 min-w-0">
  310. <p class="text-sm font-medium truncate">{{ account.label }}</p>
  311. <p class="text-xs text-gray-600">{{ $t(`platforms.${account.platform}`) }}</p>
  312. </div>
  313. <!-- Filled indicator -->
  314. <span
  315. v-if="profileFilled(account.key)"
  316. class="text-xs text-green-400 flex-shrink-0"
  317. >✓</span>
  318. <!-- Chevron -->
  319. <svg
  320. class="w-4 h-4 text-gray-500 flex-shrink-0 transition-transform"
  321. :class="expandedProfileKey === account.key ? 'rotate-180' : ''"
  322. fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
  323. >
  324. <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
  325. </svg>
  326. </button>
  327. <!-- Expanded profile form -->
  328. <div v-if="expandedProfileKey === account.key" class="px-5 pb-5 pt-1 space-y-4 bg-gray-950/40">
  329. <!-- Row 1: Business Name + Website -->
  330. <div class="grid grid-cols-2 gap-3">
  331. <div>
  332. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.businessName') }}</label>
  333. <input
  334. v-model="editingProfiles[account.key].businessName"
  335. type="text"
  336. :placeholder="$t('settings.profiles.businessNameHint')"
  337. 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-blue-500"
  338. />
  339. </div>
  340. <div>
  341. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.websiteUrl') }}</label>
  342. <input
  343. v-model="editingProfiles[account.key].websiteUrl"
  344. type="url"
  345. placeholder="https://"
  346. 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-blue-500"
  347. />
  348. </div>
  349. </div>
  350. <!-- Description -->
  351. <div>
  352. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.description') }}</label>
  353. <textarea
  354. v-model="editingProfiles[account.key].description"
  355. :placeholder="$t('settings.profiles.descriptionHint')"
  356. rows="2"
  357. 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-blue-500 resize-none"
  358. />
  359. </div>
  360. <!-- Row 2: Industry + Tone -->
  361. <div class="grid grid-cols-2 gap-3">
  362. <div>
  363. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.industry') }}</label>
  364. <input
  365. v-model="editingProfiles[account.key].industry"
  366. type="text"
  367. :placeholder="$t('settings.profiles.industryHint')"
  368. 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-blue-500"
  369. />
  370. </div>
  371. <div>
  372. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.toneOfVoice') }}</label>
  373. <select
  374. v-model="editingProfiles[account.key].toneOfVoice"
  375. class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-blue-500"
  376. >
  377. <option value="">{{ $t('settings.profiles.toneSelect') }}</option>
  378. <option v-for="tone in TONE_OPTIONS" :key="tone.value" :value="tone.value">{{ tone.label }}</option>
  379. </select>
  380. </div>
  381. </div>
  382. <!-- Timezone -->
  383. <div>
  384. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.timezone') }}</label>
  385. <select
  386. v-model="editingProfiles[account.key].timezone"
  387. class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-blue-500"
  388. >
  389. <option value="">{{ $t('settings.profiles.timezoneAuto') }}</option>
  390. <option v-for="tz in COMMON_TIMEZONES" :key="tz.value" :value="tz.value">{{ tz.label }}</option>
  391. </select>
  392. <p class="text-xs text-gray-600 mt-1">{{ $t('settings.profiles.timezoneHint') }}</p>
  393. </div>
  394. <!-- Target Audience -->
  395. <div>
  396. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.targetAudience') }}</label>
  397. <input
  398. v-model="editingProfiles[account.key].targetAudience"
  399. type="text"
  400. :placeholder="$t('settings.profiles.targetAudienceHint')"
  401. 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-blue-500"
  402. />
  403. </div>
  404. <!-- Row 3: Keywords + Hashtags -->
  405. <div class="grid grid-cols-2 gap-3">
  406. <div>
  407. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.keywords') }}</label>
  408. <input
  409. v-model="editingProfiles[account.key].keywords"
  410. type="text"
  411. :placeholder="$t('settings.profiles.keywordsHint')"
  412. 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-blue-500"
  413. />
  414. </div>
  415. <div>
  416. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.hashtags') }}</label>
  417. <input
  418. v-model="editingProfiles[account.key].hashtags"
  419. type="text"
  420. :placeholder="$t('settings.profiles.hashtagsHint')"
  421. 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-blue-500"
  422. />
  423. </div>
  424. </div>
  425. <!-- Posting Guidelines -->
  426. <div>
  427. <label class="block text-xs text-gray-500 mb-1">{{ $t('settings.profiles.postingGuidelines') }}</label>
  428. <textarea
  429. v-model="editingProfiles[account.key].postingGuidelines"
  430. :placeholder="$t('settings.profiles.postingGuidelinesHint')"
  431. rows="3"
  432. 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-blue-500 resize-none"
  433. />
  434. </div>
  435. <!-- Save button -->
  436. <div class="flex items-center justify-end gap-3">
  437. <span v-if="profileSavedKey === account.key" class="text-xs text-green-400">
  438. {{ $t('settings.profiles.saved') }}
  439. </span>
  440. <button
  441. @click="saveProfile(account.key)"
  442. :disabled="profileSaving === account.key"
  443. class="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
  444. >
  445. {{ profileSaving === account.key ? $t('settings.profiles.saving') : $t('settings.profiles.save') }}
  446. </button>
  447. </div>
  448. </div>
  449. </div>
  450. </div>
  451. </div>
  452. <!-- ═══════════════════════════════════════════════════════════════════
  453. AI INTEGRATION — Ollama configuration card
  454. ════════════════════════════════════════════════════════════════════ -->
  455. <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
  456. <!-- Header -->
  457. <div class="p-5 border-b border-gray-800 flex items-center gap-3">
  458. <div class="w-9 h-9 rounded-full bg-violet-700 flex items-center justify-center text-white text-sm font-bold shrink-0">AI</div>
  459. <div>
  460. <p class="font-semibold">{{ $t('ai.sectionTitle') }}</p>
  461. <p class="text-xs text-gray-500 mt-0.5">{{ $t('ai.sectionSubtitle') }}</p>
  462. </div>
  463. <!-- Connection status pill -->
  464. <div v-if="aiConnected !== null" class="ml-auto shrink-0">
  465. <span
  466. class="text-xs px-2 py-0.5 rounded-full font-medium"
  467. :class="aiConnected ? 'bg-green-900/50 text-green-400 border border-green-700' : 'bg-red-900/40 text-red-400 border border-red-800'"
  468. >
  469. {{ aiConnected ? $t('ai.connected') : $t('ai.connectionFailed') }}
  470. </span>
  471. </div>
  472. </div>
  473. <div class="p-5 space-y-4">
  474. <!-- Endpoint -->
  475. <div>
  476. <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.endpointLabel') }}</label>
  477. <div class="flex gap-2">
  478. <input
  479. v-model="aiEndpoint"
  480. type="text"
  481. :placeholder="$t('ai.endpointPlaceholder')"
  482. class="flex-1 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"
  483. />
  484. <button
  485. @click="testAiConnection"
  486. :disabled="aiStore.modelsLoading || !aiEndpoint"
  487. class="px-3 py-2 bg-gray-700 hover:bg-gray-600 disabled:opacity-40 border border-gray-600 rounded-lg text-xs font-medium transition-colors whitespace-nowrap"
  488. >
  489. {{ aiStore.modelsLoading ? $t('ai.testing') : $t('ai.testConnection') }}
  490. </button>
  491. </div>
  492. <p class="text-xs text-gray-600 mt-1">{{ $t('ai.endpointHint') }}</p>
  493. </div>
  494. <!-- Model selector -->
  495. <div>
  496. <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.modelLabel') }}</label>
  497. <select
  498. v-model="aiModel"
  499. :disabled="!aiModels.length"
  500. class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-violet-500 disabled:opacity-40"
  501. >
  502. <option value="">{{ $t('ai.modelPlaceholder') }}</option>
  503. <option v-for="m in aiModels" :key="m" :value="m">{{ m }}</option>
  504. </select>
  505. <p v-if="aiConnected === false" class="text-xs text-red-400 mt-1">{{ $t('ai.noModels') }}</p>
  506. <p v-else-if="aiModels.length" class="text-xs text-gray-600 mt-1">
  507. {{ $t('ai.modelsAvailable', aiModels.length) }}
  508. </p>
  509. </div>
  510. <!-- Vision model -->
  511. <div>
  512. <label class="block text-xs text-gray-500 mb-1">{{ $t('ai.visionModelLabel') }}</label>
  513. <input
  514. v-model="aiVisionModel"
  515. type="text"
  516. :placeholder="$t('ai.visionModelPlaceholder')"
  517. 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"
  518. />
  519. <p class="text-xs text-gray-600 mt-1">{{ $t('ai.visionModelHint') }}</p>
  520. </div>
  521. <!-- Save -->
  522. <div class="flex items-center justify-end gap-3">
  523. <span v-if="aiSaved" class="text-xs text-green-400">{{ $t('ai.saved') }}</span>
  524. <button
  525. @click="saveAiConfig"
  526. :disabled="aiStore.saving || !aiEndpoint"
  527. class="px-4 py-2 bg-violet-600 hover:bg-violet-700 disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
  528. >
  529. {{ aiStore.saving ? $t('ai.saving') : $t('ai.saveConfig') }}
  530. </button>
  531. </div>
  532. </div>
  533. </div>
  534. <!-- Refresh button -->
  535. <button
  536. @click="platformsStore.fetchStatuses()"
  537. class="w-full py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-sm transition-colors"
  538. >
  539. {{ $t('settings.refreshStatus') }}
  540. </button>
  541. </div>
  542. </div>
  543. </template>
  544. <script setup lang="ts">
  545. import { ref, computed, onMounted } from 'vue'
  546. import { useRoute } from 'vue-router'
  547. import { useI18n } from 'vue-i18n'
  548. import axios from 'axios'
  549. import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
  550. import { useAiStore } from '../stores/ai'
  551. import { COMMON_TIMEZONES } from '../utils/timezone'
  552. const { t } = useI18n()
  553. const route = useRoute()
  554. const platformsStore = usePlatformsStore()
  555. const aiStore = useAiStore()
  556. // ─── App credential form state ──────────────────────────────────────────────
  557. const appId = ref('')
  558. const appSecret = ref('')
  559. const editingApp = ref(false)
  560. const metaAppConfigured = computed(() => platformsStore.metaCredentials.configured)
  561. async function saveApp() {
  562. await platformsStore.saveMetaApp(appId.value, appSecret.value)
  563. if (!platformsStore.metaError) {
  564. editingApp.value = false
  565. appSecret.value = ''
  566. }
  567. }
  568. // ─── Connected platforms derived from statuses ───────────────────────────────
  569. const fbStatus = computed(() => platformsStore.getStatus('facebook'))
  570. const igStatus = computed(() => platformsStore.getStatus('instagram'))
  571. const fbConnected = computed(() => fbStatus.value?.connected ?? false)
  572. const igConnected = computed(() => igStatus.value?.connected ?? false)
  573. // Pull connected pages/accounts from the shared store
  574. const fbPages = computed(() => platformsStore.connectedPages)
  575. const igAccounts = computed(() => platformsStore.connectedIgAccounts)
  576. async function loadMetaConnections() {
  577. await platformsStore.fetchMetaConnections()
  578. }
  579. // ─── OAuth discovery ─────────────────────────────────────────────────────────
  580. const discovery = computed(() => platformsStore.metaDiscovery || { pages: [], igAccounts: [] })
  581. const showDiscovery = computed(() => !!(platformsStore.metaDiscovery && (discovery.value.pages.length > 0 || discovery.value.igAccounts.length > 0)))
  582. const selectedPageIds = ref<string[]>([])
  583. const selectedIgAccountIds = ref<string[]>([])
  584. const selectionError = ref('')
  585. function pageNameForId(pageId: string): string {
  586. return discovery.value.pages.find((p) => p.id === pageId)?.name || pageId
  587. }
  588. async function confirmSelection() {
  589. selectionError.value = ''
  590. if (!selectedPageIds.value.length && !selectedIgAccountIds.value.length) {
  591. selectionError.value = platformsStore.metaError || 'Select at least one Page or Instagram account.'
  592. return
  593. }
  594. await platformsStore.saveMetaSelection(selectedPageIds.value, selectedIgAccountIds.value)
  595. await loadMetaConnections()
  596. selectedPageIds.value = []
  597. selectedIgAccountIds.value = []
  598. }
  599. // ─── OAuth error from callback redirect ──────────────────────────────────────
  600. const oauthError = ref<string | null>(null)
  601. // ─── Other platforms (not Meta) ──────────────────────────────────────────────
  602. const otherPlatforms = computed(() => {
  603. const skip = new Set(['instagram', 'facebook'])
  604. return Object.fromEntries(Object.entries(PLATFORM_META).filter(([k]) => !skip.has(k)))
  605. })
  606. function isConnected(platform: string) {
  607. return platformsStore.isConnected(platform)
  608. }
  609. function getStatus(platform: string) {
  610. return platformsStore.getStatus(platform)
  611. }
  612. // ─── Disconnect ───────────────────────────────────────────────────────────────
  613. function confirmDisconnect() {
  614. if (window.confirm(platformsStore.metaCredentials?.appId ? 'This will disconnect all Facebook Pages and Instagram accounts. Continue?' : '')) {
  615. platformsStore.disconnectMeta().then(loadMetaConnections)
  616. }
  617. }
  618. // ─── Token auto-refresh ───────────────────────────────────────────────────────
  619. const tokenRefreshing = ref(false)
  620. const tokenRefreshDone = ref(false)
  621. async function handleTokenRefresh() {
  622. tokenRefreshing.value = true
  623. tokenRefreshDone.value = false
  624. try {
  625. await platformsStore.refreshMetaTokens()
  626. tokenRefreshDone.value = true
  627. setTimeout(() => { tokenRefreshDone.value = false }, 3000)
  628. } finally {
  629. tokenRefreshing.value = false
  630. }
  631. }
  632. // ─── Account Profiles ────────────────────────────────────────────────────────
  633. const TONE_OPTIONS = [
  634. { value: 'professional', label: 'Professional' },
  635. { value: 'casual', label: 'Casual' },
  636. { value: 'friendly', label: 'Friendly' },
  637. { value: 'formal', label: 'Formal' },
  638. { value: 'humorous', label: 'Humorous' },
  639. { value: 'inspiring', label: 'Inspiring' },
  640. { value: 'educational', label: 'Educational' },
  641. ]
  642. interface AccountProfile {
  643. businessName: string
  644. description: string
  645. websiteUrl: string
  646. industry: string
  647. targetAudience: string
  648. toneOfVoice: string
  649. keywords: string
  650. hashtags: string
  651. postingGuidelines: string
  652. timezone: string
  653. }
  654. interface ProfileAccount {
  655. key: string
  656. label: string
  657. platform: string
  658. color: string
  659. avatar: string | null
  660. }
  661. function emptyProfile(): AccountProfile {
  662. return { businessName: '', description: '', websiteUrl: '', industry: '', targetAudience: '', toneOfVoice: '', keywords: '', hashtags: '', postingGuidelines: '', timezone: '' }
  663. }
  664. const expandedProfileKey = ref<string | null>(null)
  665. const editingProfiles = ref<Record<string, AccountProfile>>({})
  666. const profileSaving = ref<string | null>(null)
  667. const profileSavedKey = ref<string | null>(null)
  668. const allConnectedAccounts = computed((): ProfileAccount[] => {
  669. const accounts: ProfileAccount[] = []
  670. for (const [platform, meta] of Object.entries(PLATFORM_META)) {
  671. if (platform === 'facebook' || platform === 'instagram') continue
  672. if (platformsStore.isConnected(platform)) {
  673. accounts.push({ key: platform, label: t(`platforms.${platform}`), platform, color: meta.color, avatar: null })
  674. }
  675. }
  676. for (const page of platformsStore.connectedPages) {
  677. accounts.push({ key: `facebook:${page.id}`, label: page.name, platform: 'facebook', color: PLATFORM_META.facebook.color, avatar: page.picture || null })
  678. }
  679. for (const account of platformsStore.connectedIgAccounts) {
  680. accounts.push({ key: `instagram:${account.id}`, label: `@${account.username}`, platform: 'instagram', color: PLATFORM_META.instagram.color, avatar: account.avatar || null })
  681. }
  682. return accounts
  683. })
  684. function profileFilled(key: string): boolean {
  685. const p = editingProfiles.value[key]
  686. return !!p && !!(p.businessName || p.description || p.industry)
  687. }
  688. async function toggleProfile(key: string) {
  689. if (expandedProfileKey.value === key) {
  690. expandedProfileKey.value = null
  691. return
  692. }
  693. expandedProfileKey.value = key
  694. if (!editingProfiles.value[key]) {
  695. try {
  696. const res = await axios.get(`/api/profiles/${encodeURIComponent(key)}`)
  697. const { _id, updatedAt, ...data } = res.data
  698. editingProfiles.value[key] = { ...emptyProfile(), ...data }
  699. } catch {
  700. editingProfiles.value[key] = emptyProfile()
  701. }
  702. }
  703. }
  704. async function saveProfile(key: string) {
  705. profileSaving.value = key
  706. try {
  707. await axios.put(`/api/profiles/${encodeURIComponent(key)}`, editingProfiles.value[key])
  708. profileSavedKey.value = key
  709. setTimeout(() => { if (profileSavedKey.value === key) profileSavedKey.value = null }, 2500)
  710. } catch (err) {
  711. console.error('Save profile error:', err)
  712. } finally {
  713. profileSaving.value = null
  714. }
  715. }
  716. // ─── AI Configuration ─────────────────────────────────────────────────────────
  717. const aiEndpoint = ref('')
  718. const aiModel = ref('')
  719. const aiVisionModel = ref('')
  720. const aiModels = computed(() => aiStore.models)
  721. const aiConnected = ref<boolean | null>(null)
  722. const aiSaved = ref(false)
  723. async function testAiConnection() {
  724. const ok = await aiStore.fetchModels(aiEndpoint.value)
  725. aiConnected.value = ok
  726. if (ok && !aiModel.value && aiStore.models.length) {
  727. aiModel.value = aiStore.models[0]
  728. }
  729. }
  730. async function saveAiConfig() {
  731. const ok = await aiStore.saveConfig({ endpoint: aiEndpoint.value, model: aiModel.value, visionModel: aiVisionModel.value })
  732. if (ok) {
  733. aiSaved.value = true
  734. setTimeout(() => { aiSaved.value = false }, 2500)
  735. }
  736. }
  737. // ─── On mount ────────────────────────────────────────────────────────────────
  738. onMounted(async () => {
  739. // Check for OAuth callback query params
  740. if (route.query.meta_discovery) {
  741. await platformsStore.fetchMetaDiscovery()
  742. // Clear query param from URL without navigation
  743. window.history.replaceState({}, '', '/settings')
  744. }
  745. if (route.query.meta_error) {
  746. oauthError.value = decodeURIComponent(String(route.query.meta_error))
  747. window.history.replaceState({}, '', '/settings')
  748. }
  749. await Promise.all([
  750. platformsStore.fetchStatuses(),
  751. platformsStore.fetchMetaCredentials(),
  752. loadMetaConnections(),
  753. platformsStore.fetchTokenExpiry(),
  754. aiStore.fetchConfig(),
  755. ])
  756. // Seed local form from fetched config
  757. aiEndpoint.value = aiStore.config.endpoint
  758. aiModel.value = aiStore.config.model
  759. aiVisionModel.value = aiStore.config.visionModel
  760. })
  761. </script>