diff --git a/packages/app/src/components/content/ContentPanel.vue b/packages/app/src/components/content/ContentPanel.vue index 67aa918a..f9ab84cf 100644 --- a/packages/app/src/components/content/ContentPanel.vue +++ b/packages/app/src/components/content/ContentPanel.vue @@ -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) }} @@ -168,6 +168,9 @@ + @@ -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 = { 'design-system': 'Design', nostr: 'Nostr', favorites: 'Favorites', + discover: 'Discover', } function tabLabel(tab: ContentTab): string { diff --git a/packages/app/src/components/content/DiscoverPanel.vue b/packages/app/src/components/content/DiscoverPanel.vue new file mode 100644 index 00000000..da77ce4c --- /dev/null +++ b/packages/app/src/components/content/DiscoverPanel.vue @@ -0,0 +1,421 @@ + + + + Discover + + + + + {{ tab.label }} + + + + + + + + + + + + Add favorites to get personalized suggestions + + + + + {{ typeIcon(item.type) }} + + + {{ item.title }} + {{ item.subtitle }} + + {{ item.type }} + + + + + + + {{ viewHistory.length }} items + + Clear + + + + + + + + No recently viewed items + + + + + {{ typeIcon(entry.type) }} + + + {{ entry.title }} + {{ entry.subtitle }} + + {{ timeAgo(entry.viewedAt) }} + + + + + + + + + + No trending items yet + + + + + {{ typeIcon(item.type) }} + + + {{ item.title }} + + + {{ item.count }}x + + + + + + + + + + + Create + + + + + + + + No collections yet + + + + + + {{ col.name }} + {{ col.description }} + + + {{ col.items.length }} items + + Delete + + + + + + + + {{ typeIcon(item.type) }} + + + + + + {{ typeIcon(item.type) }} + {{ item.title }} + + x + + + + + + + + + + + + No tags yet + Tag items from content cards to organize them + + + + + + {{ tc.tag }} {{ tc.count }} + + + + + + Items tagged "{{ activeTagFilter }}" + + {{ itemId }} + + untag + + + + + + + + + + + + No music data yet + + + + + + Recently Played + + S + + {{ entry.title }} + {{ entry.subtitle }} + + {{ timeAgo(entry.viewedAt) }} + + + + + + Most Played + + S + + {{ item.title }} + + {{ item.count }}x + + + + + + By Genre + + + {{ genre.genre }} {{ genre.count }} + + + + + + + By Decade + + + {{ dec.decade }}s {{ dec.count }} + + + + + + + + + + diff --git a/packages/app/src/components/content/ShareToNostr.vue b/packages/app/src/components/content/ShareToNostr.vue new file mode 100644 index 00000000..82bceb13 --- /dev/null +++ b/packages/app/src/components/content/ShareToNostr.vue @@ -0,0 +1,128 @@ + + + + + Share to Nostr + + + + + + + + + + + Preview + {{ previewText }} + + + + + Cancel + + + {{ isPublishing ? 'Publishing...' : 'Publish' }} + + + + + Sign in with Nostr to share + + + + {{ publishResult }} + + + + + + diff --git a/packages/app/src/components/content/SimilarContent.vue b/packages/app/src/components/content/SimilarContent.vue new file mode 100644 index 00000000..aa12b0d2 --- /dev/null +++ b/packages/app/src/components/content/SimilarContent.vue @@ -0,0 +1,38 @@ + + + More Like This + + + Finding similar content... + + + + + {{ item.title }} + {{ item.reason }} + + + + + + diff --git a/packages/app/src/composables/contentFiltering.ts b/packages/app/src/composables/contentFiltering.ts index 35ab08d9..8ffc02df 100644 --- a/packages/app/src/composables/contentFiltering.ts +++ b/packages/app/src/composables/contentFiltering.ts @@ -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 diff --git a/packages/app/src/composables/useContentCollections.ts b/packages/app/src/composables/useContentCollections.ts new file mode 100644 index 00000000..67eb533f --- /dev/null +++ b/packages/app/src/composables/useContentCollections.ts @@ -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([]) + +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, + } +} diff --git a/packages/app/src/composables/useContentDiscovery.ts b/packages/app/src/composables/useContentDiscovery.ts new file mode 100644 index 00000000..9e29fb79 --- /dev/null +++ b/packages/app/src/composables/useContentDiscovery.ts @@ -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() + 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>(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() + 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() + 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([]) + +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) { + // 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([]) + +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, + } +} diff --git a/packages/app/src/composables/useSimilarContent.ts b/packages/app/src/composables/useSimilarContent.ts new file mode 100644 index 00000000..be894b41 --- /dev/null +++ b/packages/app/src/composables/useSimilarContent.ts @@ -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([]) + 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, + } +} diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index 60ab8ddb..fd186be7 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -571,6 +571,7 @@ const TAB_LABELS: Record = { 'design-system': 'Design', nostr: 'Nostr', favorites: 'Favorites', + discover: 'Discover', } function tabLabel(tab: ContentTab): string {
Add favorites to get personalized suggestions
No recently viewed items
No trending items yet
No collections yet
{{ col.name }}
{{ col.description }}
No tags yet
Tag items from content cards to organize them
Items tagged "{{ activeTagFilter }}"
No music data yet
Recently Played
{{ entry.title }}
{{ entry.subtitle }}
Most Played
{{ item.title }}
By Genre
By Decade
Preview
{{ previewText }}
+ Sign in with Nostr to share +
+ {{ publishResult }} +
More Like This
Finding similar content...
{{ item.reason }}