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
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user