feat(discover): content discovery panel with For You, tags, playlists, collections (M13.1-M13.8)
- For You feed: frequency map from favorites, sorted by most-favorited type - Content tagging: user tags on any item, tag cloud, filter by tag - Smart playlists: recently played, most played, by genre, by decade - Similar content: background AI call for 3 suggestions, cached 7 days - Recently viewed history: last 50 items with time-ago display - Content collections: user-curated mixed-type lists with mosaic grid - Trending: most-referenced items across 30 days with badge - Share to Nostr: compose preview, sign via NIP-07, broadcast to relays Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d5d8a1f8b2
commit
52e62e33cd
@@ -45,7 +45,7 @@
|
||||
: isDark
|
||||
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
|
||||
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
|
||||
@click="tab === 'favorites' ? (activeTab = tab) : setActiveTab(tab)"
|
||||
@click="tab === 'favorites' || tab === 'discover' ? (activeTab = tab) : setActiveTab(tab)"
|
||||
>
|
||||
{{ tabLabel(tab) }}
|
||||
</button>
|
||||
@@ -168,6 +168,9 @@
|
||||
<FavoritesGrid
|
||||
v-else-if="activeTab === 'favorites'"
|
||||
/>
|
||||
<DiscoverPanel
|
||||
v-else-if="activeTab === 'discover'"
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -194,6 +197,7 @@ import MagazineGrid from './MagazineGrid.vue'
|
||||
import ProjectGrid from './ProjectGrid.vue'
|
||||
import NostrGrid from './NostrGrid.vue'
|
||||
import FavoritesGrid from './FavoritesGrid.vue'
|
||||
import DiscoverPanel from './DiscoverPanel.vue'
|
||||
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
|
||||
import { useFavoritesStore } from '@/stores/favorites'
|
||||
|
||||
@@ -259,6 +263,9 @@ const displayTabs = computed(() => {
|
||||
if (favoritesStore.items.length > 0 && !tabs.includes('favorites')) {
|
||||
tabs.push('favorites')
|
||||
}
|
||||
if (!tabs.includes('discover')) {
|
||||
tabs.push('discover')
|
||||
}
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -281,6 +288,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
|
||||
'design-system': 'Design',
|
||||
nostr: 'Nostr',
|
||||
favorites: 'Favorites',
|
||||
discover: 'Discover',
|
||||
}
|
||||
|
||||
function tabLabel(tab: ContentTab): string {
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-4 border-b border-white/[0.08]">
|
||||
<h3 class="text-sm font-bold text-white/90 mb-3">Discover</h3>
|
||||
|
||||
<!-- Sub-tabs -->
|
||||
<div class="flex gap-1.5 flex-wrap">
|
||||
<button
|
||||
v-for="tab in subTabs"
|
||||
:key="tab.id"
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
|
||||
:class="activeSubTab === tab.id
|
||||
? 'nav-tab-active'
|
||||
: 'text-white/40 hover:text-white/70 hover:bg-white/5'"
|
||||
@click="activeSubTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
|
||||
<!-- For You -->
|
||||
<template v-if="activeSubTab === 'foryou'">
|
||||
<div v-if="forYouItems.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">Add favorites to get personalized suggestions</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in forYouItems.slice(0, 30)"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<span class="text-[10px] w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(item.type)">
|
||||
{{ typeIcon(item.type) }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-semibold truncate text-white/80">{{ item.title }}</div>
|
||||
<div v-if="item.subtitle" class="text-[10px] truncate text-white/40">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
<span class="text-[8px] text-white/20 shrink-0">{{ item.type }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Recent -->
|
||||
<template v-else-if="activeSubTab === 'recent'">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-[10px] text-white/30">{{ viewHistory.length }} items</span>
|
||||
<button
|
||||
v-if="viewHistory.length > 0"
|
||||
class="text-[9px] text-red-400/50 hover:text-red-400/80 transition-colors"
|
||||
@click="clearHistory"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="viewHistory.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">No recently viewed items</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="entry in viewHistory"
|
||||
:key="entry.id + entry.viewedAt"
|
||||
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<span class="text-[10px] w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(entry.type)">
|
||||
{{ typeIcon(entry.type) }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-semibold truncate text-white/80">{{ entry.title }}</div>
|
||||
<div v-if="entry.subtitle" class="text-[10px] truncate text-white/40">{{ entry.subtitle }}</div>
|
||||
</div>
|
||||
<span class="text-[8px] text-white/20 shrink-0">{{ timeAgo(entry.viewedAt) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Trending -->
|
||||
<template v-else-if="activeSubTab === 'trending'">
|
||||
<div v-if="trending.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">No trending items yet</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="item in trending"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-3 p-3 rounded-xl bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 transition-all duration-150"
|
||||
>
|
||||
<span class="text-[10px] w-6 h-6 rounded flex items-center justify-center shrink-0" :class="typeStyle(item.type)">
|
||||
{{ typeIcon(item.type) }}
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs font-semibold truncate text-white/80">{{ item.title }}</div>
|
||||
</div>
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-accent/15 text-accent/80 shrink-0">
|
||||
{{ item.count }}x
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Collections -->
|
||||
<template v-else-if="activeSubTab === 'collections'">
|
||||
<!-- Create new -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<input
|
||||
v-model="newCollectionName"
|
||||
type="text"
|
||||
placeholder="New collection name..."
|
||||
class="flex-1 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors"
|
||||
@keydown.enter="createNewCollection"
|
||||
/>
|
||||
<button
|
||||
class="px-2.5 py-2 rounded-lg text-[10px] bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
|
||||
:disabled="!newCollectionName.trim()"
|
||||
@click="createNewCollection"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="collections.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">No collections yet</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="col in collections"
|
||||
:key="col.id"
|
||||
class="rounded-xl bg-white/[0.03] border border-white/5 p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold text-white/80 truncate">{{ col.name }}</p>
|
||||
<p v-if="col.description" class="text-[9px] text-white/30 truncate">{{ col.description }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<span class="text-[9px] text-white/25">{{ col.items.length }} items</span>
|
||||
<button
|
||||
class="text-[9px] px-1.5 py-0.5 rounded text-red-400/50 hover:text-red-400/80 hover:bg-red-400/10 transition-colors"
|
||||
@click="deleteCollection(col.id)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mosaic thumbnails -->
|
||||
<div v-if="col.items.length > 0" class="grid grid-cols-4 gap-1">
|
||||
<div
|
||||
v-for="item in col.items.slice(0, 4)"
|
||||
:key="item.id"
|
||||
class="aspect-square rounded bg-white/5 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-[8px] font-bold" :class="typeStyle(item.type)">{{ typeIcon(item.type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Items list -->
|
||||
<div v-for="item in col.items" :key="item.id" class="flex items-center gap-2 text-[10px]">
|
||||
<span :class="typeStyle(item.type)" class="w-4 h-4 rounded flex items-center justify-center text-[7px] shrink-0">{{ typeIcon(item.type) }}</span>
|
||||
<span class="text-white/60 truncate flex-1">{{ item.title }}</span>
|
||||
<button
|
||||
class="text-red-400/40 hover:text-red-400/70 transition-colors text-[8px] shrink-0"
|
||||
@click="removeFromCollection(col.id, item.id)"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tags -->
|
||||
<template v-else-if="activeSubTab === 'tags'">
|
||||
<div v-if="tagCloud.length === 0" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">No tags yet</p>
|
||||
<p class="text-[10px] text-white/20">Tag items from content cards to organize them</p>
|
||||
</div>
|
||||
|
||||
<!-- Tag cloud -->
|
||||
<div v-if="tagCloud.length > 0" class="flex flex-wrap gap-1.5 mb-4">
|
||||
<button
|
||||
v-for="tc in tagCloud"
|
||||
:key="tc.tag"
|
||||
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
|
||||
:class="activeTagFilter === tc.tag
|
||||
? 'nav-tab-active'
|
||||
: 'text-white/40 hover:text-white/70 bg-white/5 hover:bg-white/10'"
|
||||
@click="activeTagFilter = activeTagFilter === tc.tag ? null : tc.tag"
|
||||
>
|
||||
{{ tc.tag }} <span class="text-white/20 ml-0.5">{{ tc.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filtered items by tag -->
|
||||
<div v-if="activeTagFilter" class="space-y-2">
|
||||
<p class="text-[10px] text-white/30">Items tagged "{{ activeTagFilter }}"</p>
|
||||
<div
|
||||
v-for="itemId in getItemsByTag(activeTagFilter)"
|
||||
:key="itemId"
|
||||
class="flex items-center gap-2 p-2.5 rounded-xl bg-white/[0.03] border border-white/5"
|
||||
>
|
||||
<span class="text-[10px] text-white/60 font-mono truncate">{{ itemId }}</span>
|
||||
<button
|
||||
class="text-[8px] text-red-400/50 hover:text-red-400/80 transition-colors shrink-0"
|
||||
@click="removeTag(itemId, activeTagFilter!)"
|
||||
>
|
||||
untag
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Smart Playlists -->
|
||||
<template v-else-if="activeSubTab === 'playlists'">
|
||||
<div v-if="!hasAnySongs" class="flex flex-col items-center justify-center py-12 gap-2">
|
||||
<svg class="w-8 h-8 text-white/10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
<p class="text-xs text-white/30">No music data yet</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Recently played songs -->
|
||||
<div v-if="recentSongs.length > 0" class="mb-4">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold mb-2">Recently Played</p>
|
||||
<div
|
||||
v-for="entry in recentSongs.slice(0, 10)"
|
||||
:key="entry.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 mb-1 transition-colors"
|
||||
>
|
||||
<span class="text-[10px] w-5 h-5 rounded flex items-center justify-center shrink-0 bg-green-500/20 text-green-400">S</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[11px] text-white/70 truncate">{{ entry.title }}</p>
|
||||
<p v-if="entry.subtitle" class="text-[9px] text-white/30 truncate">{{ entry.subtitle }}</p>
|
||||
</div>
|
||||
<span class="text-[8px] text-white/20 shrink-0">{{ timeAgo(entry.viewedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Most played songs -->
|
||||
<div v-if="mostPlayedSongs.length > 0" class="mb-4">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold mb-2">Most Played</p>
|
||||
<div
|
||||
v-for="item in mostPlayedSongs.slice(0, 10)"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-white/[0.03] hover:bg-white/[0.07] border border-white/5 mb-1 transition-colors"
|
||||
>
|
||||
<span class="text-[10px] w-5 h-5 rounded flex items-center justify-center shrink-0 bg-green-500/20 text-green-400">S</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[11px] text-white/70 truncate">{{ item.title }}</p>
|
||||
</div>
|
||||
<span class="text-[9px] px-1.5 py-0.5 rounded bg-green-400/15 text-green-400/80 shrink-0">{{ item.count }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By genre -->
|
||||
<div v-if="songsByGenre.length > 0" class="mb-4">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold mb-2">By Genre</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="genre in songsByGenre"
|
||||
:key="genre.genre"
|
||||
class="text-[10px] px-2 py-1 rounded-md bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{{ genre.genre }} <span class="text-white/20">{{ genre.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- By decade -->
|
||||
<div v-if="songsByDecade.length > 0">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold mb-2">By Decade</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="dec in songsByDecade"
|
||||
:key="dec.decade"
|
||||
class="text-[10px] px-2 py-1 rounded-md bg-white/5 text-white/50 hover:text-white/70 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{{ dec.decade }}s <span class="text-white/20">{{ dec.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useForYouFeed, useContentTags, useViewHistory, useTrending } from '@/composables/useContentDiscovery'
|
||||
import { useContentCollections } from '@/composables/useContentCollections'
|
||||
import { useFavoritesStore, type FavoriteType } from '@/stores/favorites'
|
||||
|
||||
type SubTab = 'foryou' | 'recent' | 'trending' | 'collections' | 'tags' | 'playlists'
|
||||
|
||||
const subTabs: { id: SubTab; label: string }[] = [
|
||||
{ id: 'foryou', label: 'For You' },
|
||||
{ id: 'recent', label: 'Recent' },
|
||||
{ id: 'trending', label: 'Trending' },
|
||||
{ id: 'collections', label: 'Collections' },
|
||||
{ id: 'tags', label: 'Tags' },
|
||||
{ id: 'playlists', label: 'Playlists' },
|
||||
]
|
||||
|
||||
const activeSubTab = ref<SubTab>('foryou')
|
||||
|
||||
// M13.1 — For You
|
||||
const { forYouItems } = useForYouFeed()
|
||||
|
||||
// M13.2 — Tags
|
||||
const { tagCloud, getItemsByTag, removeTag } = useContentTags()
|
||||
const activeTagFilter = ref<string | null>(null)
|
||||
|
||||
// M13.5 — Recent
|
||||
const { viewHistory, clearHistory } = useViewHistory()
|
||||
|
||||
// M13.6 — Collections
|
||||
const { collections, createCollection, deleteCollection, removeFromCollection } = useContentCollections()
|
||||
const newCollectionName = ref('')
|
||||
|
||||
function createNewCollection() {
|
||||
const name = newCollectionName.value.trim()
|
||||
if (!name) return
|
||||
createCollection(name)
|
||||
newCollectionName.value = ''
|
||||
}
|
||||
|
||||
// M13.7 — Trending
|
||||
const { trending } = useTrending()
|
||||
|
||||
// M13.3 — Smart Playlists
|
||||
const favoritesStore = useFavoritesStore()
|
||||
|
||||
const recentSongs = computed(() =>
|
||||
viewHistory.value.filter(h => h.type === 'song')
|
||||
)
|
||||
|
||||
const mostPlayedSongs = computed(() =>
|
||||
trending.value.filter(t => t.type === 'song')
|
||||
)
|
||||
|
||||
const hasAnySongs = computed(() =>
|
||||
recentSongs.value.length > 0 || mostPlayedSongs.value.length > 0 || favoritesStore.getFavoritesByType('song').length > 0
|
||||
)
|
||||
|
||||
const songsByGenre = computed(() => {
|
||||
const songFavs = favoritesStore.getFavoritesByType('song')
|
||||
const counts = new Map<string, number>()
|
||||
for (const fav of songFavs) {
|
||||
const data = fav.data as { genres?: string[] } | undefined
|
||||
for (const g of data?.genres ?? []) {
|
||||
counts.set(g, (counts.get(g) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([genre, count]) => ({ genre, count }))
|
||||
})
|
||||
|
||||
const songsByDecade = computed(() => {
|
||||
const songFavs = favoritesStore.getFavoritesByType('song')
|
||||
const counts = new Map<number, number>()
|
||||
for (const fav of songFavs) {
|
||||
const data = fav.data as { year?: number } | undefined
|
||||
if (data?.year) {
|
||||
const decade = Math.floor(data.year / 10) * 10
|
||||
counts.set(decade, (counts.get(decade) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([decade, count]) => ({ decade, count }))
|
||||
})
|
||||
|
||||
// Helpers
|
||||
function typeIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
film: 'F', song: 'S', podcast: 'P', book: 'B', tv: 'T', place: 'L', article: 'A',
|
||||
}
|
||||
return icons[type] ?? '?'
|
||||
}
|
||||
|
||||
function typeStyle(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
film: 'bg-blue-500/20 text-blue-400',
|
||||
song: 'bg-green-500/20 text-green-400',
|
||||
podcast: 'bg-orange-500/20 text-orange-400',
|
||||
book: 'bg-yellow-500/20 text-yellow-400',
|
||||
tv: 'bg-indigo-500/20 text-indigo-400',
|
||||
place: 'bg-red-500/20 text-red-400',
|
||||
article: 'bg-cyan-500/20 text-cyan-400',
|
||||
}
|
||||
return colors[type] ?? 'bg-white/10 text-white/40'
|
||||
}
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return 'just now'
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}d ago`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="showCompose" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div class="w-full max-w-md mx-4 rounded-2xl bg-[#0a0a0a] border border-white/10 p-5 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold text-white/90">Share to Nostr</h3>
|
||||
<button
|
||||
class="p-1.5 rounded-lg text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="noteContent"
|
||||
class="w-full h-32 px-3 py-2 rounded-lg text-xs bg-white/5 text-white/80 placeholder:text-white/25 outline-none focus:bg-white/10 transition-colors resize-none"
|
||||
placeholder="Add a note about this content..."
|
||||
/>
|
||||
|
||||
<div class="rounded-lg bg-white/[0.03] border border-white/5 p-3">
|
||||
<p class="text-[10px] text-white/25 mb-1">Preview</p>
|
||||
<p class="text-[11px] text-white/60 whitespace-pre-wrap">{{ previewText }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg text-xs font-medium text-white/40 hover:text-white/70 hover:bg-white/5 transition-colors"
|
||||
@click="close"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2.5 rounded-lg text-xs font-medium bg-accent/15 text-accent/80 hover:bg-accent/25 transition-colors disabled:opacity-30"
|
||||
:disabled="isPublishing || !isLoggedIn"
|
||||
@click="publish"
|
||||
>
|
||||
{{ isPublishing ? 'Publishing...' : 'Publish' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!isLoggedIn" class="text-[10px] text-yellow-400/60 text-center">
|
||||
Sign in with Nostr to share
|
||||
</p>
|
||||
|
||||
<p v-if="publishResult" class="text-[10px] text-center" :class="publishOk ? 'text-emerald-400/60' : 'text-red-400/60'">
|
||||
{{ publishResult }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useNostrIdentity } from '@/composables/useNostrIdentity'
|
||||
import { useNostr } from '@/composables/useNostr'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
type: string
|
||||
description?: string
|
||||
url?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const { isLoggedIn, signEvent } = useNostrIdentity()
|
||||
const { publishEvent } = useNostr()
|
||||
|
||||
const showCompose = ref(true)
|
||||
const noteContent = ref('')
|
||||
const isPublishing = ref(false)
|
||||
const publishResult = ref('')
|
||||
const publishOk = ref(false)
|
||||
|
||||
const previewText = computed(() => {
|
||||
const parts: string[] = []
|
||||
if (noteContent.value.trim()) {
|
||||
parts.push(noteContent.value.trim())
|
||||
parts.push('')
|
||||
}
|
||||
parts.push(`${props.type.charAt(0).toUpperCase() + props.type.slice(1)}: ${props.title}`)
|
||||
if (props.description) parts.push(props.description)
|
||||
if (props.url) parts.push(props.url)
|
||||
return parts.join('\n')
|
||||
})
|
||||
|
||||
function close() {
|
||||
showCompose.value = false
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
isPublishing.value = true
|
||||
publishResult.value = ''
|
||||
|
||||
const content = previewText.value
|
||||
|
||||
const unsigned = {
|
||||
kind: 1,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [] as string[][],
|
||||
content,
|
||||
}
|
||||
|
||||
const signed = await signEvent(unsigned)
|
||||
if (!signed) {
|
||||
isPublishing.value = false
|
||||
publishResult.value = 'Signing failed'
|
||||
publishOk.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const results = await publishEvent(signed)
|
||||
const successes = results.filter(r => r.success).length
|
||||
|
||||
isPublishing.value = false
|
||||
if (successes > 0) {
|
||||
publishResult.value = `Published to ${successes}/${results.length} relays`
|
||||
publishOk.value = true
|
||||
setTimeout(close, 1500)
|
||||
} else {
|
||||
publishResult.value = 'Failed to publish'
|
||||
publishOk.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div v-if="isLoading || suggestions.length > 0" class="mt-4 pt-4 border-t border-white/[0.08]">
|
||||
<p class="text-[10px] text-accent/60 uppercase tracking-wider font-bold mb-2">More Like This</p>
|
||||
|
||||
<div v-if="isLoading" class="py-3">
|
||||
<p class="text-[10px] text-white/30">Finding similar content...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-1.5">
|
||||
<div
|
||||
v-for="(item, i) in suggestions"
|
||||
:key="i"
|
||||
class="p-2.5 rounded-lg bg-white/[0.03] border border-white/5"
|
||||
>
|
||||
<p class="text-[11px] font-semibold text-white/80">{{ item.title }}</p>
|
||||
<p class="text-[9px] text-white/40 mt-0.5">{{ item.reason }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useSimilarContent } from '@/composables/useSimilarContent'
|
||||
import type { FavoriteType } from '@/stores/favorites'
|
||||
|
||||
const props = defineProps<{
|
||||
itemId: string
|
||||
title: string
|
||||
type: FavoriteType
|
||||
}>()
|
||||
|
||||
const { suggestions, isLoading, fetchSimilar } = useSimilarContent()
|
||||
|
||||
onMounted(() => {
|
||||
fetchSimilar(props.itemId, props.title, props.type)
|
||||
})
|
||||
</script>
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'favorites'
|
||||
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'favorites' | 'discover'
|
||||
|
||||
export interface MagazineSection {
|
||||
title: string
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ref } from 'vue'
|
||||
import type { FavoriteType } from '@/stores/favorites'
|
||||
|
||||
const STORAGE_KEY = 'aiui-content-collections'
|
||||
|
||||
export interface CollectionItem {
|
||||
id: string
|
||||
type: FavoriteType
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface ContentCollection {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
items: CollectionItem[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const collections = ref<ContentCollection[]>([])
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) collections.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function save() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(collections.value))
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
export function useContentCollections() {
|
||||
function createCollection(name: string, description = ''): ContentCollection {
|
||||
const collection: ContentCollection = {
|
||||
id: crypto.randomUUID(),
|
||||
name,
|
||||
description,
|
||||
items: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
collections.value.push(collection)
|
||||
save()
|
||||
return collection
|
||||
}
|
||||
|
||||
function deleteCollection(id: string) {
|
||||
collections.value = collections.value.filter(c => c.id !== id)
|
||||
save()
|
||||
}
|
||||
|
||||
function addToCollection(collectionId: string, item: CollectionItem) {
|
||||
const collection = collections.value.find(c => c.id === collectionId)
|
||||
if (!collection) return
|
||||
if (collection.items.find(i => i.id === item.id)) return
|
||||
collection.items.push(item)
|
||||
collection.updatedAt = Date.now()
|
||||
save()
|
||||
}
|
||||
|
||||
function removeFromCollection(collectionId: string, itemId: string) {
|
||||
const collection = collections.value.find(c => c.id === collectionId)
|
||||
if (!collection) return
|
||||
collection.items = collection.items.filter(i => i.id !== itemId)
|
||||
collection.updatedAt = Date.now()
|
||||
save()
|
||||
}
|
||||
|
||||
function renameCollection(id: string, name: string) {
|
||||
const collection = collections.value.find(c => c.id === id)
|
||||
if (!collection) return
|
||||
collection.name = name
|
||||
collection.updatedAt = Date.now()
|
||||
save()
|
||||
}
|
||||
|
||||
return {
|
||||
collections,
|
||||
createCollection,
|
||||
deleteCollection,
|
||||
addToCollection,
|
||||
removeFromCollection,
|
||||
renameCollection,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { useFavoritesStore, type FavoriteItem, type FavoriteType } from '@/stores/favorites'
|
||||
|
||||
// M13.1 — "For You" feed
|
||||
export function useForYouFeed() {
|
||||
const favoritesStore = useFavoritesStore()
|
||||
|
||||
const forYouItems = computed(() => {
|
||||
const items = favoritesStore.sortedItems
|
||||
if (items.length === 0) return []
|
||||
|
||||
// Build frequency map by type
|
||||
const typeFreq = new Map<FavoriteType, number>()
|
||||
for (const item of items) {
|
||||
typeFreq.set(item.type, (typeFreq.get(item.type) ?? 0) + 1)
|
||||
}
|
||||
|
||||
// Sort types by frequency (most favorited first)
|
||||
const sortedTypes = [...typeFreq.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([type]) => type)
|
||||
|
||||
// Return items prioritized by favorite frequency, most recent first
|
||||
return [...items].sort((a, b) => {
|
||||
const aRank = sortedTypes.indexOf(a.type)
|
||||
const bRank = sortedTypes.indexOf(b.type)
|
||||
if (aRank !== bRank) return aRank - bRank
|
||||
return b.savedAt - a.savedAt
|
||||
})
|
||||
})
|
||||
|
||||
return { forYouItems }
|
||||
}
|
||||
|
||||
// M13.2 — Content tagging
|
||||
const TAGS_STORAGE_KEY = 'aiui-content-tags'
|
||||
|
||||
export interface ContentTag {
|
||||
itemId: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
const contentTags = ref<Map<string, string[]>>(new Map())
|
||||
|
||||
function loadTags() {
|
||||
try {
|
||||
const stored = localStorage.getItem(TAGS_STORAGE_KEY)
|
||||
if (stored) {
|
||||
const entries = JSON.parse(stored) as [string, string[]][]
|
||||
contentTags.value = new Map(entries)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveTags() {
|
||||
localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify([...contentTags.value.entries()]))
|
||||
}
|
||||
|
||||
loadTags()
|
||||
|
||||
export function useContentTags() {
|
||||
function getTagsForItem(itemId: string): string[] {
|
||||
return contentTags.value.get(itemId) ?? []
|
||||
}
|
||||
|
||||
function addTag(itemId: string, tag: string) {
|
||||
const tags = getTagsForItem(itemId)
|
||||
if (!tags.includes(tag)) {
|
||||
contentTags.value.set(itemId, [...tags, tag])
|
||||
saveTags()
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(itemId: string, tag: string) {
|
||||
const tags = getTagsForItem(itemId).filter(t => t !== tag)
|
||||
if (tags.length === 0) {
|
||||
contentTags.value.delete(itemId)
|
||||
} else {
|
||||
contentTags.value.set(itemId, tags)
|
||||
}
|
||||
saveTags()
|
||||
}
|
||||
|
||||
const allTags = computed(() => {
|
||||
const tagSet = new Set<string>()
|
||||
for (const tags of contentTags.value.values()) {
|
||||
for (const tag of tags) tagSet.add(tag)
|
||||
}
|
||||
return [...tagSet].sort()
|
||||
})
|
||||
|
||||
const tagCloud = computed(() => {
|
||||
const counts = new Map<string, number>()
|
||||
for (const tags of contentTags.value.values()) {
|
||||
for (const tag of tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
return [...counts.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([tag, count]) => ({ tag, count }))
|
||||
})
|
||||
|
||||
function getItemsByTag(tag: string): string[] {
|
||||
const ids: string[] = []
|
||||
for (const [id, tags] of contentTags.value.entries()) {
|
||||
if (tags.includes(tag)) ids.push(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
return {
|
||||
contentTags,
|
||||
allTags,
|
||||
tagCloud,
|
||||
getTagsForItem,
|
||||
addTag,
|
||||
removeTag,
|
||||
getItemsByTag,
|
||||
}
|
||||
}
|
||||
|
||||
// M13.5 — Recently viewed history
|
||||
const HISTORY_STORAGE_KEY = 'aiui-view-history'
|
||||
const MAX_HISTORY = 50
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string
|
||||
type: FavoriteType
|
||||
title: string
|
||||
subtitle?: string
|
||||
viewedAt: number
|
||||
}
|
||||
|
||||
const viewHistory = ref<HistoryEntry[]>([])
|
||||
|
||||
function loadHistory() {
|
||||
try {
|
||||
const stored = localStorage.getItem(HISTORY_STORAGE_KEY)
|
||||
if (stored) viewHistory.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveHistory() {
|
||||
localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(viewHistory.value))
|
||||
}
|
||||
|
||||
loadHistory()
|
||||
|
||||
export function useViewHistory() {
|
||||
function addToHistory(entry: Omit<HistoryEntry, 'viewedAt'>) {
|
||||
// Remove duplicate
|
||||
viewHistory.value = viewHistory.value.filter(h => h.id !== entry.id)
|
||||
// Add to front
|
||||
viewHistory.value.unshift({ ...entry, viewedAt: Date.now() })
|
||||
// Cap at MAX_HISTORY
|
||||
if (viewHistory.value.length > MAX_HISTORY) {
|
||||
viewHistory.value = viewHistory.value.slice(0, MAX_HISTORY)
|
||||
}
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
viewHistory.value = []
|
||||
saveHistory()
|
||||
}
|
||||
|
||||
return {
|
||||
viewHistory,
|
||||
addToHistory,
|
||||
clearHistory,
|
||||
}
|
||||
}
|
||||
|
||||
// M13.7 — Trending in conversations
|
||||
const REFERENCE_STORAGE_KEY = 'aiui-content-references'
|
||||
|
||||
interface ReferenceEntry {
|
||||
id: string
|
||||
title: string
|
||||
type: FavoriteType
|
||||
count: number
|
||||
lastReferenced: number
|
||||
}
|
||||
|
||||
const trendingItems = ref<ReferenceEntry[]>([])
|
||||
|
||||
function loadReferences() {
|
||||
try {
|
||||
const stored = localStorage.getItem(REFERENCE_STORAGE_KEY)
|
||||
if (stored) trendingItems.value = JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function saveReferences() {
|
||||
localStorage.setItem(REFERENCE_STORAGE_KEY, JSON.stringify(trendingItems.value))
|
||||
}
|
||||
|
||||
loadReferences()
|
||||
|
||||
export function useTrending() {
|
||||
function recordReference(item: { id: string; title: string; type: FavoriteType }) {
|
||||
const existing = trendingItems.value.find(t => t.id === item.id)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
existing.lastReferenced = Date.now()
|
||||
} else {
|
||||
trendingItems.value.push({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
type: item.type,
|
||||
count: 1,
|
||||
lastReferenced: Date.now(),
|
||||
})
|
||||
}
|
||||
saveReferences()
|
||||
}
|
||||
|
||||
const trending = computed(() => {
|
||||
const thirtyDaysAgo = Date.now() - 30 * 86400 * 1000
|
||||
return trendingItems.value
|
||||
.filter(t => t.lastReferenced > thirtyDaysAgo)
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 20)
|
||||
})
|
||||
|
||||
return {
|
||||
trending,
|
||||
recordReference,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ref } from 'vue'
|
||||
import type { FavoriteType } from '@/stores/favorites'
|
||||
import { getApiKey } from '@/utils/key-vault'
|
||||
|
||||
const CACHE_KEY = 'aiui-similar-content'
|
||||
const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
interface SimilarItem {
|
||||
title: string
|
||||
type: FavoriteType
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface CachedSuggestion {
|
||||
itemId: string
|
||||
suggestions: SimilarItem[]
|
||||
cachedAt: number
|
||||
}
|
||||
|
||||
function loadCache(): CachedSuggestion[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(CACHE_KEY)
|
||||
if (stored) return JSON.parse(stored)
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
}
|
||||
|
||||
function saveCache(cache: CachedSuggestion[]) {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache))
|
||||
}
|
||||
|
||||
export function useSimilarContent() {
|
||||
const suggestions = ref<SimilarItem[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
async function fetchSimilar(itemId: string, title: string, type: FavoriteType) {
|
||||
// Check cache first
|
||||
const cache = loadCache()
|
||||
const now = Date.now()
|
||||
const cached = cache.find(c => c.itemId === itemId && (now - c.cachedAt) < CACHE_DURATION)
|
||||
if (cached) {
|
||||
suggestions.value = cached.suggestions
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
suggestions.value = []
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey('claude')
|
||||
if (!apiKey) {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const typeLabel = type === 'tv' ? 'TV series' : type
|
||||
const prompt = `List exactly 3 ${typeLabel}s similar to "${title}". For each, respond with ONLY a JSON array like: [{"title":"Name","reason":"one sentence why"}]. No other text.`
|
||||
|
||||
const res = await fetch('/api/claude/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey },
|
||||
body: JSON.stringify({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 300,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
isLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const text = data.content?.[0]?.text ?? ''
|
||||
|
||||
// Extract JSON array from response
|
||||
const match = text.match(/\[[\s\S]*\]/)
|
||||
if (match) {
|
||||
const parsed = JSON.parse(match[0]) as { title: string; reason: string }[]
|
||||
const items: SimilarItem[] = parsed.slice(0, 3).map(p => ({
|
||||
title: p.title,
|
||||
type,
|
||||
reason: p.reason,
|
||||
}))
|
||||
suggestions.value = items
|
||||
|
||||
// Cache result
|
||||
const updatedCache = cache.filter(c => c.itemId !== itemId)
|
||||
updatedCache.push({ itemId, suggestions: items, cachedAt: now })
|
||||
// Keep cache small
|
||||
if (updatedCache.length > 100) updatedCache.splice(0, updatedCache.length - 100)
|
||||
saveCache(updatedCache)
|
||||
}
|
||||
} catch { /* ignore errors */ } finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
isLoading,
|
||||
fetchSimilar,
|
||||
}
|
||||
}
|
||||
@@ -571,6 +571,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
|
||||
'design-system': 'Design',
|
||||
nostr: 'Nostr',
|
||||
favorites: 'Favorites',
|
||||
discover: 'Discover',
|
||||
}
|
||||
|
||||
function tabLabel(tab: ContentTab): string {
|
||||
|
||||
Reference in New Issue
Block a user