Benjamin Harris 1 месяц назад
Родитель
Сommit
7e1adbb58d
6 измененных файлов с 751 добавлено и 0 удалено
  1. 216 0
      services/gateway/server.js
  2. 34 0
      ui/src/locales/en.ts
  3. 34 0
      ui/src/locales/tr.ts
  4. 106 0
      ui/src/stores/hashtags.ts
  5. 30 0
      ui/src/views/Compose.vue
  6. 331 0
      ui/src/views/Settings.vue

+ 216 - 0
services/gateway/server.js

@@ -1937,4 +1937,220 @@ app.get('/analytics/posts', async (request) => {
   return { posts: normalised, total: schedTotal + immTotal };
 });
 
+// ─── Hashtag Groups ───────────────────────────────────────────────────────────
+
+app.get('/hashtag-groups', async () => {
+  const db = await getDb();
+  const groups = await db.collection('hashtag_groups').find({}).sort({ name: 1 }).toArray();
+  return { groups };
+});
+
+app.post('/hashtag-groups', async (request, reply) => {
+  const { name, hashtags } = request.body || {};
+  if (!name?.trim()) return reply.code(400).send({ error: 'name is required' });
+  const tags = (hashtags || []).map((t) => (t.startsWith('#') ? t : `#${t}`).toLowerCase()).filter(Boolean);
+  const db = await getDb();
+  const result = await db.collection('hashtag_groups').insertOne({
+    name: name.trim(),
+    hashtags: [...new Set(tags)],
+    createdAt: new Date(),
+    updatedAt: new Date(),
+  });
+  return { success: true, _id: result.insertedId };
+});
+
+app.put('/hashtag-groups/:id', async (request, reply) => {
+  const { id } = request.params;
+  const { name, hashtags } = request.body || {};
+  const update = { updatedAt: new Date() };
+  if (name?.trim()) update.name = name.trim();
+  if (hashtags) {
+    const tags = hashtags.map((t) => (t.startsWith('#') ? t : `#${t}`).toLowerCase()).filter(Boolean);
+    update.hashtags = [...new Set(tags)];
+  }
+  const db = await getDb();
+  let oid;
+  try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid id' }); }
+  await db.collection('hashtag_groups').updateOne({ _id: oid }, { $set: update });
+  return { success: true };
+});
+
+app.delete('/hashtag-groups/:id', async (request, reply) => {
+  const { id } = request.params;
+  const db = await getDb();
+  let oid;
+  try { oid = new ObjectId(id); } catch { return reply.code(400).send({ error: 'Invalid id' }); }
+  await db.collection('hashtag_groups').deleteOne({ _id: oid });
+  return { success: true };
+});
+
+// ─── Hashtag Stats & Scraper ──────────────────────────────────────────────────
+
+const HASHTAG_RE = /#([a-zA-Z]\w*)/g;
+
+function extractHashtags(text) {
+  if (!text) return [];
+  const tags = [];
+  let m;
+  HASHTAG_RE.lastIndex = 0;
+  while ((m = HASHTAG_RE.exec(text)) !== null) tags.push(`#${m[1].toLowerCase()}`);
+  return tags;
+}
+
+function gradeHashtag(count, avgEngagement) {
+  if (count >= 5 && avgEngagement >= 10) return 'A';
+  if (count >= 3 && avgEngagement >= 3)  return 'B';
+  if (count >= 2)                         return 'C';
+  return 'D';
+}
+
+app.post('/hashtags/scrape', async () => {
+  const db = await getDb();
+
+  // Collect hashtag → { count, totalEngagement, platforms }
+  const tagMap = {};
+
+  function touch(tag, platform, engagement) {
+    if (!tagMap[tag]) tagMap[tag] = { count: 0, totalEngagement: 0, platforms: new Set() };
+    tagMap[tag].count++;
+    tagMap[tag].totalEngagement += engagement;
+    tagMap[tag].platforms.add(platform);
+  }
+
+  // Scan published posts
+  const posts = await db.collection('posts').find({}, { projection: { content: 1, destinations: 1, platformResults: 1 } }).toArray();
+  const postMetrics = await db.collection('post_metrics').find({}).toArray();
+
+  // Build engagement lookup keyed by content fingerprint (first 100 chars)
+  const metricsByContent = {};
+  for (const m of postMetrics) {
+    if (m.content) {
+      const key = m.content.slice(0, 100).toLowerCase().trim();
+      metricsByContent[key] = (metricsByContent[key] || 0) + m.metrics.engagementTotal;
+    }
+  }
+
+  for (const post of posts) {
+    const tags = extractHashtags(post.content || '');
+    const engagement = post.content
+      ? (metricsByContent[post.content.slice(0, 100).toLowerCase().trim()] || 0)
+      : 0;
+    const platforms = (post.destinations || []).map((d) => d.platform);
+    for (const tag of tags) {
+      for (const platform of platforms.length ? platforms : ['unknown']) {
+        touch(tag, platform, engagement / Math.max(tags.length, 1));
+      }
+    }
+  }
+
+  // Also scan feed items
+  const feeds = await db.collection('feeds').find({}, { projection: { content: 1, platform: 1, metrics: 1 } }).toArray();
+  for (const item of feeds) {
+    const tags = extractHashtags(item.content || '');
+    const engagement = (item.metrics?.likes || 0) + (item.metrics?.comments || 0) + (item.metrics?.shares || 0);
+    for (const tag of tags) {
+      touch(tag, item.platform || 'unknown', engagement / Math.max(tags.length, 1));
+    }
+  }
+
+  // Upsert hashtag_stats
+  let scraped = 0;
+  for (const [tag, data] of Object.entries(tagMap)) {
+    const avgEngagement = data.count > 0 ? data.totalEngagement / data.count : 0;
+    await db.collection('hashtag_stats').updateOne(
+      { _id: tag },
+      {
+        $set: {
+          count: data.count,
+          avgEngagement: Math.round(avgEngagement * 10) / 10,
+          grade: gradeHashtag(data.count, avgEngagement),
+          platforms: [...data.platforms],
+          lastScraped: new Date(),
+        },
+      },
+      { upsert: true }
+    );
+    scraped++;
+  }
+
+  app.log.info({ action: 'hashtag_scrape', outcome: 'success', scraped });
+  return { success: true, scraped };
+});
+
+app.get('/hashtags/stats', async (request) => {
+  const sortBy = request.query.sort || 'count';
+  const db = await getDb();
+  const sortField = sortBy === 'engagement' ? 'avgEngagement' : 'count';
+  const stats = await db.collection('hashtag_stats')
+    .find({})
+    .sort({ [sortField]: -1 })
+    .limit(200)
+    .toArray();
+  return { stats };
+});
+
+app.post('/hashtags/ai-suggest', async (request, reply) => {
+  const { accountKey, topTags = [], count = 20 } = request.body || {};
+
+  let profileCtx = '';
+  if (accountKey) {
+    try {
+      const db = await getDb();
+      const profile = await db.collection('account_profiles').findOne({ _id: accountKey });
+      if (profile) {
+        const parts = [];
+        if (profile.businessName)   parts.push(`Business: ${profile.businessName}`);
+        if (profile.description)    parts.push(`Description: ${profile.description}`);
+        if (profile.industry)       parts.push(`Industry: ${profile.industry}`);
+        if (profile.targetAudience) parts.push(`Target audience: ${profile.targetAudience}`);
+        if (profile.keywords)       parts.push(`Existing keywords: ${profile.keywords}`);
+        if (profile.hashtags)       parts.push(`Current hashtags: ${profile.hashtags}`);
+        profileCtx = parts.join('\n');
+      }
+    } catch (_) {}
+  }
+
+  const topTagList = topTags.slice(0, 15).map((t) => t._id || t).join(', ');
+
+  const system = 'You are a social media hashtag strategist. Return ONLY hashtags, space-separated, no explanations.';
+  const prompt = [
+    `Suggest ${count} high-performing hashtags for a social media account.`,
+    profileCtx ? `\nACCOUNT CONTEXT:\n${profileCtx}` : '',
+    topTagList ? `\nCURRENT TOP HASHTAGS (by usage):\n${topTagList}` : '',
+    `\nReturn exactly ${count} unique hashtags as a space-separated list. Mix popular and niche tags. Include a variety of sizes (broad, medium, niche). Example: #photography #naturephotography #landscapephoto`,
+  ].filter(Boolean).join('');
+
+  try {
+    const pconf = await getActiveProviderConfig();
+    const model = pconf.model;
+    let text = '';
+
+    if (pconf.provider === 'ollama') {
+      const res = await axios.post(`${pconf.endpoint}/api/generate`, { model, prompt, system, stream: false }, { timeout: 60000 });
+      text = res.data.response;
+    } else if (pconf.provider === 'openai' || pconf.provider === 'groq') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: `${pconf.provider} API key not configured` });
+      const res = await axios.post(`${pconf.baseUrl}/chat/completions`, {
+        model, messages: buildOpenAIMessages(prompt, system), stream: false,
+      }, { headers: { Authorization: `Bearer ${pconf.apiKey}` }, timeout: 60000 });
+      text = res.data.choices[0]?.message?.content || '';
+    } else if (pconf.provider === 'gemini') {
+      if (!pconf.apiKey) return reply.code(503).send({ error: 'Gemini API key not configured' });
+      const res = await axios.post(
+        `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${pconf.apiKey}`,
+        { contents: buildGeminiContents(prompt, system) },
+        { timeout: 60000 },
+      );
+      text = res.data.candidates?.[0]?.content?.parts?.[0]?.text || '';
+    } else {
+      return reply.code(400).send({ error: 'AI not configured' });
+    }
+
+    const tags = [...new Set((text.match(/#[a-zA-Z]\w*/g) || []).map((t) => t.toLowerCase()))].slice(0, count);
+    return { success: true, hashtags: tags };
+  } catch (err) {
+    return reply.code(503).send({ error: 'AI suggestion failed', detail: err.message });
+  }
+});
+
 module.exports = app;

+ 34 - 0
ui/src/locales/en.ts

@@ -156,6 +156,7 @@ export default {
     hashtagSuggestions: 'Suggested hashtags',
     hashtagsLoading: 'Suggesting…',
     hashtagsRefresh: 'Refresh',
+    hashtagGroups: 'Hashtag groups',
 
     aiButton: 'AI',
     aiPanelTitle: 'Generate with AI',
@@ -278,6 +279,39 @@ export default {
       timezoneAuto: 'Use browser timezone',
     },
 
+    hashtags: {
+      sectionTitle: 'Hashtag Groups',
+      sectionSubtitle: 'Save hashtag presets and analyse performance from your published posts.',
+      addGroup: 'Add Group',
+      createGroup: 'Create Group',
+      noGroups: 'No hashtag groups yet.',
+      edit: 'Edit',
+      delete: 'Delete',
+      deleteConfirm: 'Delete this hashtag group?',
+      save: 'Save',
+      cancel: 'Cancel',
+      groupNamePlaceholder: 'Group name (e.g. Photography)',
+      hashtagsPlaceholder: '#photography #nature #travel — space or comma separated',
+      showStats: 'Show Stats',
+      hideStats: 'Hide Stats',
+      statsTitle: 'Hashtag Performance',
+      sortByUsage: 'By Usage',
+      sortByEngagement: 'By Engagement',
+      scanPosts: 'Scan Posts',
+      scanning: 'Scanning…',
+      loadingStats: 'Loading stats…',
+      noStats: 'No hashtag stats yet — scan your posts to analyse performance.',
+      allAccounts: 'All accounts',
+      aiSuggest: 'AI Suggest',
+      suggesting: 'Generating…',
+      selectToGroup: 'Click tags to select, then save as a group:',
+      colHashtag: 'Hashtag',
+      colUses: 'Uses',
+      colEngagement: 'Avg Engagement',
+      colGrade: 'Grade',
+      colPlatforms: 'Platforms',
+    },
+
     tiktok: {
       sectionTitle: 'TikTok',
       sectionSubtitle: 'Connect your TikTok account to publish videos.',

+ 34 - 0
ui/src/locales/tr.ts

@@ -156,6 +156,7 @@ export default {
     hashtagSuggestions: 'Önerilen hashtagler',
     hashtagsLoading: 'Öneriliyor…',
     hashtagsRefresh: 'Yenile',
+    hashtagGroups: 'Hashtag grupları',
 
     aiButton: 'YZ',
     aiPanelTitle: 'YZ ile Oluştur',
@@ -278,6 +279,39 @@ export default {
       timezoneAuto: 'Tarayıcı saat dilimini kullan',
     },
 
+    hashtags: {
+      sectionTitle: 'Hashtag Grupları',
+      sectionSubtitle: 'Hashtag ön ayarlarını kaydet ve yayınlanan gönderilerden performansı analiz et.',
+      addGroup: 'Grup Ekle',
+      createGroup: 'Grup Oluştur',
+      noGroups: 'Henüz hashtag grubu yok.',
+      edit: 'Düzenle',
+      delete: 'Sil',
+      deleteConfirm: 'Bu hashtag grubunu sil?',
+      save: 'Kaydet',
+      cancel: 'İptal',
+      groupNamePlaceholder: 'Grup adı (örn. Fotoğrafçılık)',
+      hashtagsPlaceholder: '#fotoğraf #doğa #seyahat — boşluk veya virgülle ayırın',
+      showStats: 'İstatistikleri Göster',
+      hideStats: 'İstatistikleri Gizle',
+      statsTitle: 'Hashtag Performansı',
+      sortByUsage: 'Kullanıma Göre',
+      sortByEngagement: 'Etkileşime Göre',
+      scanPosts: 'Gönderileri Tara',
+      scanning: 'Taranıyor…',
+      loadingStats: 'İstatistikler yükleniyor…',
+      noStats: 'Henüz istatistik yok — performansı analiz etmek için gönderileri tarayın.',
+      allAccounts: 'Tüm hesaplar',
+      aiSuggest: 'YZ ile Öner',
+      suggesting: 'Oluşturuluyor…',
+      selectToGroup: 'Etiketlere tıklayarak seç, ardından grup olarak kaydet:',
+      colHashtag: 'Hashtag',
+      colUses: 'Kullanım',
+      colEngagement: 'Ort. Etkileşim',
+      colGrade: 'Not',
+      colPlatforms: 'Platformlar',
+    },
+
     tiktok: {
       sectionTitle: 'TikTok',
       sectionSubtitle: 'Video yayınlamak için TikTok hesabını bağla.',

+ 106 - 0
ui/src/stores/hashtags.ts

@@ -0,0 +1,106 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import axios from 'axios'
+
+export interface HashtagGroup {
+  _id: string
+  name: string
+  hashtags: string[]
+  createdAt: string
+  updatedAt: string
+}
+
+export interface HashtagStat {
+  _id: string        // the hashtag e.g. '#photography'
+  count: number
+  avgEngagement: number
+  grade: 'A' | 'B' | 'C' | 'D'
+  platforms: string[]
+  lastScraped: string
+}
+
+export const useHashtagStore = defineStore('hashtags', () => {
+  const groups = ref<HashtagGroup[]>([])
+  const stats = ref<HashtagStat[]>([])
+  const groupsLoading = ref(false)
+  const statsLoading = ref(false)
+  const scraping = ref(false)
+  const aiSuggesting = ref(false)
+  const aiSuggestions = ref<string[]>([])
+  const error = ref('')
+
+  async function fetchGroups() {
+    groupsLoading.value = true
+    error.value = ''
+    try {
+      const res = await axios.get('/api/hashtag-groups')
+      groups.value = res.data.groups || []
+    } catch (err: any) {
+      error.value = err.response?.data?.error || err.message
+    } finally {
+      groupsLoading.value = false
+    }
+  }
+
+  async function createGroup(name: string, hashtags: string[]) {
+    const res = await axios.post('/api/hashtag-groups', { name, hashtags })
+    await fetchGroups()
+    return res.data._id
+  }
+
+  async function updateGroup(id: string, name: string, hashtags: string[]) {
+    await axios.put(`/api/hashtag-groups/${id}`, { name, hashtags })
+    await fetchGroups()
+  }
+
+  async function deleteGroup(id: string) {
+    await axios.delete(`/api/hashtag-groups/${id}`)
+    groups.value = groups.value.filter((g) => g._id !== id)
+  }
+
+  async function scrapeHashtags() {
+    scraping.value = true
+    error.value = ''
+    try {
+      await axios.post('/api/hashtags/scrape')
+      await fetchStats()
+    } catch (err: any) {
+      error.value = err.response?.data?.error || err.message
+    } finally {
+      scraping.value = false
+    }
+  }
+
+  async function fetchStats(sort: 'count' | 'engagement' = 'count') {
+    statsLoading.value = true
+    try {
+      const res = await axios.get('/api/hashtags/stats', { params: { sort } })
+      stats.value = res.data.stats || []
+    } catch (err: any) {
+      error.value = err.response?.data?.error || err.message
+    } finally {
+      statsLoading.value = false
+    }
+  }
+
+  async function aiSuggest(accountKey?: string, count = 20) {
+    aiSuggesting.value = true
+    aiSuggestions.value = []
+    error.value = ''
+    try {
+      const topTags = stats.value.slice(0, 15)
+      const res = await axios.post('/api/hashtags/ai-suggest', { accountKey, topTags, count })
+      aiSuggestions.value = res.data.hashtags || []
+    } catch (err: any) {
+      error.value = err.response?.data?.error || err.message
+    } finally {
+      aiSuggesting.value = false
+    }
+  }
+
+  return {
+    groups, stats, groupsLoading, statsLoading, scraping, aiSuggesting, aiSuggestions, error,
+    fetchGroups, createGroup, updateGroup, deleteGroup,
+    scrapeHashtags, fetchStats, aiSuggest,
+  }
+})

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

@@ -300,6 +300,26 @@
           </div>
         </div>
 
+        <!-- Hashtag Groups -->
+        <div
+          v-if="hashtagStore.groups.length"
+          class="bg-gray-900 border border-gray-800 rounded-xl px-4 py-3"
+        >
+          <p class="text-xs text-gray-500 mb-2">{{ $t('compose.hashtagGroups') }}</p>
+          <div class="flex flex-wrap gap-1.5">
+            <button
+              v-for="group in hashtagStore.groups"
+              :key="group._id"
+              @click="insertHashtagGroup(group.hashtags)"
+              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"
+              :title="group.hashtags.join(' ')"
+            >
+              # {{ group.name }}
+              <span class="text-emerald-600 ml-1">{{ group.hashtags.length }}</span>
+            </button>
+          </div>
+        </div>
+
         <!-- First Comment -->
         <div class="bg-gray-900 border border-gray-800 rounded-xl overflow-hidden">
           <button
@@ -438,6 +458,7 @@ import axios from 'axios'
 import { useComposeStore } from '../stores/compose'
 import { usePlatformsStore } from '../stores/platforms'
 import { useAiStore } from '../stores/ai'
+import { useHashtagStore } from '../stores/hashtags'
 import PostPreview from '../components/compose/PostPreview.vue'
 import { COMMON_TIMEZONES, getBrowserTimezone, getTimezoneAbbr, utcToNaiveDatetimeString } from '../utils/timezone'
 
@@ -445,6 +466,7 @@ const { t } = useI18n()
 const composeStore = useComposeStore()
 const platformsStore = usePlatformsStore()
 const aiStore = useAiStore()
+const hashtagStore = useHashtagStore()
 const router = useRouter()
 const route = useRoute()
 
@@ -464,6 +486,7 @@ onMounted(async () => {
     platformsStore.fetchStatuses(),
     platformsStore.fetchMetaConnections(),
     aiStore.fetchConfig(),
+    hashtagStore.fetchGroups(),
   ])
   composeStore.initDestinations()
 
@@ -857,6 +880,13 @@ function insertHashtag(tag: string) {
   composeStore.content += `${sep}${tag}`
 }
 
+function insertHashtagGroup(hashtags: string[]) {
+  const toInsert = hashtags.filter((t) => !contentHasTag(t))
+  if (!toInsert.length) return
+  const sep = composeStore.content.endsWith(' ') || !composeStore.content ? '' : ' '
+  composeStore.content += `${sep}${toInsert.join(' ')}`
+}
+
 // Debounced watcher — triggers suggestion after 1.5 s of no typing
 watch(
   () => composeStore.content,

+ 331 - 0
ui/src/views/Settings.vue

@@ -742,6 +742,258 @@
 
       </div>
 
+      <!-- ═══════════════════════════════════════════════════════════════════
+           HASHTAG GROUPS
+      ════════════════════════════════════════════════════════════════════ -->
+      <div class="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden">
+
+        <!-- Header -->
+        <div class="p-5 border-b border-gray-800 flex items-center justify-between">
+          <div class="flex items-center gap-3">
+            <div class="w-9 h-9 rounded-full bg-emerald-700 flex items-center justify-center text-white font-bold text-base shrink-0">#</div>
+            <div>
+              <p class="font-semibold">{{ $t('settings.hashtags.sectionTitle') }}</p>
+              <p class="text-xs text-gray-500 mt-0.5">{{ $t('settings.hashtags.sectionSubtitle') }}</p>
+            </div>
+          </div>
+          <button
+            @click="showHashtagStats = !showHashtagStats; showHashtagStats && hashtagStore.stats.length === 0 && hashtagStore.fetchStats()"
+            class="text-xs px-3 py-1.5 rounded-lg border border-gray-700 text-gray-400 hover:bg-gray-800 transition-colors"
+          >
+            {{ showHashtagStats ? $t('settings.hashtags.hideStats') : $t('settings.hashtags.showStats') }}
+          </button>
+        </div>
+
+        <div class="p-5 space-y-4">
+
+          <!-- Group list -->
+          <div v-if="hashtagStore.groups.length" class="space-y-3">
+            <div
+              v-for="group in hashtagStore.groups"
+              :key="group._id"
+              class="border border-gray-800 rounded-xl p-4"
+            >
+              <!-- View mode -->
+              <template v-if="editingGroupId !== group._id">
+                <div class="flex items-start justify-between gap-3">
+                  <div class="min-w-0">
+                    <p class="text-sm font-medium text-gray-200">{{ group.name }}</p>
+                    <div class="flex flex-wrap gap-1 mt-2">
+                      <span
+                        v-for="tag in group.hashtags"
+                        :key="tag"
+                        class="text-xs px-2 py-0.5 rounded-full bg-gray-800 text-gray-400 border border-gray-700"
+                      >{{ tag }}</span>
+                    </div>
+                  </div>
+                  <div class="flex gap-1.5 shrink-0">
+                    <button
+                      @click="startEditGroup(group)"
+                      class="text-xs px-2.5 py-1 rounded-lg border border-gray-700 text-gray-400 hover:bg-gray-800 transition-colors"
+                    >{{ $t('settings.hashtags.edit') }}</button>
+                    <button
+                      @click="handleDeleteGroup(group._id)"
+                      class="text-xs px-2.5 py-1 rounded-lg border border-red-900/60 text-red-400 hover:bg-red-900/20 transition-colors"
+                    >{{ $t('settings.hashtags.delete') }}</button>
+                  </div>
+                </div>
+              </template>
+              <!-- Edit mode -->
+              <template v-else>
+                <div class="space-y-2">
+                  <input
+                    v-model="editGroupName"
+                    :placeholder="$t('settings.hashtags.groupNamePlaceholder')"
+                    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-emerald-500"
+                  />
+                  <textarea
+                    v-model="editGroupHashtags"
+                    :placeholder="$t('settings.hashtags.hashtagsPlaceholder')"
+                    rows="3"
+                    class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 resize-none focus:outline-none focus:border-emerald-500"
+                  ></textarea>
+                  <div class="flex gap-2">
+                    <button
+                      @click="saveEditGroup(group._id)"
+                      class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 rounded-lg text-sm font-medium text-white transition-colors"
+                    >{{ $t('settings.hashtags.save') }}</button>
+                    <button
+                      @click="editingGroupId = ''"
+                      class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-gray-300 transition-colors"
+                    >{{ $t('settings.hashtags.cancel') }}</button>
+                  </div>
+                </div>
+              </template>
+            </div>
+          </div>
+
+          <p v-else-if="!hashtagStore.groupsLoading" class="text-sm text-gray-600 text-center py-2">
+            {{ $t('settings.hashtags.noGroups') }}
+          </p>
+
+          <!-- Add new group form -->
+          <div v-if="addingHashtagGroup" class="border border-emerald-900/40 bg-emerald-950/20 rounded-xl p-4 space-y-2">
+            <input
+              v-model="newGroupName"
+              :placeholder="$t('settings.hashtags.groupNamePlaceholder')"
+              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-emerald-500"
+            />
+            <textarea
+              v-model="newGroupHashtags"
+              :placeholder="$t('settings.hashtags.hashtagsPlaceholder')"
+              rows="4"
+              class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 placeholder-gray-600 resize-none focus:outline-none focus:border-emerald-500"
+            ></textarea>
+            <div class="flex gap-2">
+              <button
+                @click="handleCreateGroup"
+                :disabled="!newGroupName.trim()"
+                class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors"
+              >{{ $t('settings.hashtags.createGroup') }}</button>
+              <button
+                @click="addingHashtagGroup = false"
+                class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-gray-300 transition-colors"
+              >{{ $t('settings.hashtags.cancel') }}</button>
+            </div>
+          </div>
+
+          <button
+            v-if="!addingHashtagGroup"
+            @click="addingHashtagGroup = true"
+            class="text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
+          >+ {{ $t('settings.hashtags.addGroup') }}</button>
+
+          <!-- Stats panel -->
+          <div v-if="showHashtagStats" class="border-t border-gray-800 pt-4 space-y-4">
+
+            <!-- Controls row -->
+            <div class="flex items-center flex-wrap gap-2">
+              <p class="text-sm font-semibold text-gray-300 mr-2">{{ $t('settings.hashtags.statsTitle') }}</p>
+
+              <button
+                @click="statsSort = 'count'; hashtagStore.fetchStats('count')"
+                class="text-xs px-2.5 py-1 rounded-lg border transition-colors"
+                :class="statsSort === 'count' ? 'border-emerald-600 text-emerald-300 bg-emerald-900/20' : 'border-gray-700 text-gray-400 hover:bg-gray-800'"
+              >{{ $t('settings.hashtags.sortByUsage') }}</button>
+
+              <button
+                @click="statsSort = 'engagement'; hashtagStore.fetchStats('engagement')"
+                class="text-xs px-2.5 py-1 rounded-lg border transition-colors"
+                :class="statsSort === 'engagement' ? 'border-emerald-600 text-emerald-300 bg-emerald-900/20' : 'border-gray-700 text-gray-400 hover:bg-gray-800'"
+              >{{ $t('settings.hashtags.sortByEngagement') }}</button>
+
+              <button
+                @click="scrapeHashtagsNow"
+                :disabled="hashtagStore.scraping"
+                class="ml-auto text-xs px-3 py-1.5 rounded-lg border border-gray-700 text-gray-300 hover:bg-gray-800 disabled:opacity-50 transition-colors flex items-center gap-1.5"
+              >
+                <svg v-if="hashtagStore.scraping" class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
+                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
+                </svg>
+                {{ hashtagStore.scraping ? $t('settings.hashtags.scanning') : $t('settings.hashtags.scanPosts') }}
+              </button>
+            </div>
+
+            <!-- AI Suggest row -->
+            <div class="flex items-center gap-2 flex-wrap">
+              <select
+                v-model="aiSuggestAccount"
+                class="bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-300 focus:outline-none focus:border-violet-500 flex-1 min-w-0"
+              >
+                <option value="">{{ $t('settings.hashtags.allAccounts') }}</option>
+                <option v-for="acc in allConnectedAccounts" :key="acc.key" :value="acc.key">{{ acc.label }}</option>
+              </select>
+              <button
+                @click="handleAiSuggest"
+                :disabled="hashtagStore.aiSuggesting"
+                class="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-violet-700/60 text-violet-300 hover:bg-violet-900/30 disabled:opacity-50 transition-colors shrink-0"
+              >
+                <svg v-if="hashtagStore.aiSuggesting" class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
+                  <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
+                  <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z"/>
+                </svg>
+                <span v-if="!hashtagStore.aiSuggesting">✨</span>
+                {{ hashtagStore.aiSuggesting ? $t('settings.hashtags.suggesting') : $t('settings.hashtags.aiSuggest') }}
+              </button>
+            </div>
+
+            <!-- AI suggestions chips -->
+            <div v-if="hashtagStore.aiSuggestions.length" class="space-y-3">
+              <p class="text-xs text-gray-500">{{ $t('settings.hashtags.selectToGroup') }}</p>
+              <div class="flex flex-wrap gap-1.5">
+                <button
+                  v-for="tag in hashtagStore.aiSuggestions"
+                  :key="tag"
+                  @click="toggleAiTag(tag)"
+                  class="text-xs px-2.5 py-0.5 rounded-full border transition-colors"
+                  :class="selectedAiTags.has(tag)
+                    ? 'bg-emerald-800 border-emerald-600 text-white'
+                    : 'border-gray-700 text-gray-400 hover:border-gray-500 hover:text-gray-300'"
+                >{{ tag }}</button>
+              </div>
+              <div v-if="selectedAiTags.size" class="flex gap-2">
+                <input
+                  v-model="aiGroupName"
+                  :placeholder="$t('settings.hashtags.groupNamePlaceholder')"
+                  class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-3 py-1.5 text-sm text-gray-100 placeholder-gray-600 focus:outline-none focus:border-emerald-500 min-w-0"
+                />
+                <button
+                  @click="createGroupFromAi"
+                  :disabled="!aiGroupName.trim()"
+                  class="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 rounded-lg text-sm font-medium text-white transition-colors shrink-0"
+                >{{ $t('settings.hashtags.createGroup') }}</button>
+              </div>
+            </div>
+
+            <!-- Stats table -->
+            <div v-if="hashtagStore.statsLoading" class="text-center py-4 text-sm text-gray-600">
+              {{ $t('settings.hashtags.loadingStats') }}
+            </div>
+            <div v-else-if="hashtagStore.stats.length" class="overflow-x-auto">
+              <table class="w-full text-xs">
+                <thead>
+                  <tr class="text-gray-500 border-b border-gray-800">
+                    <th class="text-left py-2 pr-4 font-medium">{{ $t('settings.hashtags.colHashtag') }}</th>
+                    <th class="text-right py-2 pr-4 font-medium">{{ $t('settings.hashtags.colUses') }}</th>
+                    <th class="text-right py-2 pr-4 font-medium">{{ $t('settings.hashtags.colEngagement') }}</th>
+                    <th class="text-center py-2 pr-4 font-medium">{{ $t('settings.hashtags.colGrade') }}</th>
+                    <th class="text-left py-2 font-medium">{{ $t('settings.hashtags.colPlatforms') }}</th>
+                  </tr>
+                </thead>
+                <tbody class="divide-y divide-gray-800/60">
+                  <tr
+                    v-for="stat in hashtagStore.stats.slice(0, 100)"
+                    :key="stat._id"
+                    class="hover:bg-gray-800/30 transition-colors"
+                  >
+                    <td class="py-1.5 pr-4 font-mono text-emerald-400">{{ stat._id }}</td>
+                    <td class="py-1.5 pr-4 text-right text-gray-300">{{ stat.count }}</td>
+                    <td class="py-1.5 pr-4 text-right text-gray-300">{{ stat.avgEngagement }}</td>
+                    <td class="py-1.5 pr-4 text-center">
+                      <span
+                        class="px-1.5 py-0.5 rounded text-xs font-bold"
+                        :class="{
+                          'bg-orange-900/50 text-orange-300': stat.grade === 'A',
+                          'bg-green-900/50 text-green-300':  stat.grade === 'B',
+                          'bg-blue-900/50 text-blue-300':    stat.grade === 'C',
+                          'bg-gray-800 text-gray-500':       stat.grade === 'D',
+                        }"
+                      >{{ gradeLabel(stat.grade) }}</span>
+                    </td>
+                    <td class="py-1.5 text-gray-500">{{ stat.platforms.join(', ') }}</td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+            <p v-else class="text-sm text-gray-600 text-center py-4">
+              {{ $t('settings.hashtags.noStats') }}
+            </p>
+          </div>
+
+        </div>
+      </div>
+
       <!-- ═══════════════════════════════════════════════════════════════════
            AI INTEGRATION — Ollama configuration card
       ════════════════════════════════════════════════════════════════════ -->
@@ -974,6 +1226,7 @@ import { useI18n } from 'vue-i18n'
 import axios from 'axios'
 import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 import { useAiStore, PROVIDER_MODELS } from '../stores/ai'
+import { useHashtagStore, type HashtagGroup } from '../stores/hashtags'
 import { COMMON_TIMEZONES } from '../utils/timezone'
 
 const { t } = useI18n()
@@ -981,6 +1234,7 @@ const { t } = useI18n()
 const route = useRoute()
 const platformsStore = usePlatformsStore()
 const aiStore = useAiStore()
+const hashtagStore = useHashtagStore()
 
 // ─── App credential form state ──────────────────────────────────────────────
 
@@ -1099,6 +1353,82 @@ function confirmTikTokDisconnect() {
   }
 }
 
+// ─── Hashtag Groups ──────────────────────────────────────────────────────────
+
+const addingHashtagGroup = ref(false)
+const newGroupName = ref('')
+const newGroupHashtags = ref('')
+const editingGroupId = ref('')
+const editGroupName = ref('')
+const editGroupHashtags = ref('')
+const showHashtagStats = ref(false)
+const statsSort = ref<'count' | 'engagement'>('count')
+const aiSuggestAccount = ref('')
+const selectedAiTags = ref(new Set<string>())
+const aiGroupName = ref('')
+
+function parseHashtagInput(raw: string): string[] {
+  return [...new Set(
+    raw.replace(/,/g, ' ').split(/\s+/).map((t) => t.trim()).filter((t) => t.length > 1)
+  )]
+}
+
+function toggleAiTag(tag: string) {
+  const s = new Set(selectedAiTags.value)
+  if (s.has(tag)) s.delete(tag)
+  else s.add(tag)
+  selectedAiTags.value = s
+}
+
+async function handleCreateGroup() {
+  if (!newGroupName.value.trim()) return
+  const tags = parseHashtagInput(newGroupHashtags.value)
+  await hashtagStore.createGroup(newGroupName.value.trim(), tags)
+  newGroupName.value = ''
+  newGroupHashtags.value = ''
+  addingHashtagGroup.value = false
+}
+
+function startEditGroup(group: HashtagGroup) {
+  editingGroupId.value = group._id
+  editGroupName.value = group.name
+  editGroupHashtags.value = group.hashtags.join(' ')
+}
+
+async function saveEditGroup(id: string) {
+  const tags = parseHashtagInput(editGroupHashtags.value)
+  await hashtagStore.updateGroup(id, editGroupName.value.trim(), tags)
+  editingGroupId.value = ''
+}
+
+async function handleDeleteGroup(id: string) {
+  if (window.confirm(t('settings.hashtags.deleteConfirm'))) {
+    await hashtagStore.deleteGroup(id)
+  }
+}
+
+async function scrapeHashtagsNow() {
+  await hashtagStore.scrapeHashtags()
+  statsSort.value = 'count'
+}
+
+async function handleAiSuggest() {
+  await hashtagStore.aiSuggest(aiSuggestAccount.value || undefined)
+}
+
+async function createGroupFromAi() {
+  if (!aiGroupName.value.trim() || !selectedAiTags.value.size) return
+  await hashtagStore.createGroup(aiGroupName.value.trim(), [...selectedAiTags.value])
+  aiGroupName.value = ''
+  selectedAiTags.value = new Set()
+  hashtagStore.aiSuggestions.splice(0)
+}
+
+function gradeLabel(grade: string): string {
+  const map: Record<string, string> = { A: '🔥 A', B: '✅ B', C: '🔵 C', D: '⚪ D' }
+  return map[grade] || grade
+}
+
 // ─── Other platforms (not Meta) ──────────────────────────────────────────────
 
 const otherPlatforms = computed(() => {
@@ -1369,6 +1699,7 @@ onMounted(async () => {
     platformsStore.fetchTokenExpiry(),
     aiStore.fetchConfig(),
     aiStore.fetchProviders(),
+    hashtagStore.fetchGroups(),
   ])
 
   // Seed board checkboxes from current selection