feat(app): add bookmarks/favorites with IndexedDB persistence

Create favorites Pinia store with IndexedDB backend for persistent
favorites. Add FavoriteButton.vue heart toggle (accent-colored when
active). Create FavoritesGrid.vue with type filtering. Wire favorite
buttons into FilmCard and SongCard. Add Favorites tab to ContentPanel
that appears when items are saved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-03 20:49:57 +00:00
co-authored by Claude Opus 4.6
parent 1991d75850
commit 258a03d7f1
8 changed files with 365 additions and 4 deletions
@@ -33,11 +33,11 @@
<!-- Tab bar (when multiple tabs available) -->
<div
v-if="availableTabs.length > 1 && !hasDetailOpen"
v-if="displayTabs.length > 1 && !hasDetailOpen"
class="flex items-center gap-1 px-3 pt-3 pb-1 shrink-0 overflow-x-auto scrollbar-hide"
>
<button
v-for="tab in availableTabs"
v-for="tab in displayTabs"
:key="tab"
class="text-[10px] px-2.5 py-1.5 rounded-lg font-medium whitespace-nowrap transition-all duration-150"
:class="activeTab === tab
@@ -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="setActiveTab(tab)"
@click="tab === 'favorites' ? (activeTab = tab) : setActiveTab(tab)"
>
{{ tabLabel(tab) }}
</button>
@@ -148,6 +148,9 @@
<NostrGrid
v-else-if="activeTab === 'nostr'"
/>
<FavoritesGrid
v-else-if="activeTab === 'favorites'"
/>
</ErrorBoundary>
</div>
</aside>
@@ -170,13 +173,17 @@ import ArticleDetail from './ArticleDetail.vue'
import MagazineGrid from './MagazineGrid.vue'
import ProjectGrid from './ProjectGrid.vue'
import NostrGrid from './NostrGrid.vue'
import FavoritesGrid from './FavoritesGrid.vue'
import ErrorBoundary from '@/components/ui/ErrorBoundary.vue'
import { useFavoritesStore } from '@/stores/favorites'
// Film and song renderers loaded from plugin registry
const filmRenderer = computed(() => getRendererForContentType('film'))
const songRenderer = computed(() => getRendererForContentType('song'))
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const {
panelOpen,
panelFilms,
@@ -221,6 +228,14 @@ const hasDetailOpen = computed(() =>
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value < 1024)
const displayTabs = computed(() => {
const tabs = [...availableTabs.value]
if (favoritesStore.items.length > 0 && !tabs.includes('favorites')) {
tabs.push('favorites')
}
return tabs
})
function onResize() {
windowWidth.value = window.innerWidth
}
@@ -239,6 +254,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
code: 'Code',
'design-system': 'Design',
nostr: 'Nostr',
favorites: 'Favorites',
}
function tabLabel(tab: ContentTab): string {
@@ -0,0 +1,162 @@
<template>
<div class="h-full flex flex-col">
<div class="p-4 space-y-3" :style="isDark
? 'border-bottom: 1px solid rgba(255, 255, 255, 0.08)'
: 'border-bottom: 1px solid rgba(0, 0, 0, 0.06)'"
>
<div class="flex items-center justify-between gap-2">
<h3 class="text-sm font-bold" :class="isDark ? 'text-white/90' : 'text-gray-900'">
Favorites
</h3>
<span class="text-[10px] font-mono" :class="isDark ? 'text-white/30' : 'text-gray-400'">
{{ filteredItems.length }} saved
</span>
</div>
<div class="flex gap-1.5 flex-wrap">
<button
v-for="filter in typeFilters"
:key="filter.id"
class="text-[10px] px-2 py-1 rounded-md transition-all duration-150"
:class="activeType === filter.id
? 'nav-tab-active'
: isDark
? 'text-white/40 hover:text-white/70 hover:bg-white/5'
: 'text-gray-500 hover:text-gray-800 hover:bg-black/5'"
@click="activeType = activeType === filter.id ? null : filter.id"
>
{{ filter.label }}
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar px-4 pt-3 pb-16 space-y-2">
<div
v-if="filteredItems.length === 0"
class="flex flex-col items-center justify-center py-12 gap-2"
>
<svg
class="w-8 h-8"
:class="isDark ? 'text-white/10' : 'text-gray-200'"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
<p class="text-xs" :class="isDark ? 'text-white/30' : 'text-gray-400'">
No favorites yet
</p>
</div>
<div
v-for="item in filteredItems"
:key="item.id"
class="flex items-center gap-3 p-3 rounded-xl transition-all duration-150"
:class="isDark
? 'bg-white/[0.03] hover:bg-white/[0.07] border border-white/5'
: 'bg-black/[0.02] hover:bg-black/[0.05] border border-black/5'"
>
<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"
:class="isDark ? 'text-white/80' : 'text-gray-800'"
>
{{ item.title }}
</div>
<div
v-if="item.subtitle"
class="text-[10px] truncate"
:class="isDark ? 'text-white/40' : 'text-gray-500'"
>
{{ item.subtitle }}
</div>
</div>
<button
class="shrink-0 text-accent/60 hover:text-accent transition-colors p-1"
aria-label="Remove from favorites"
@click="store.removeFavorite(item.id)"
>
<svg class="w-3.5 h-3.5" fill="currentColor" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useTheme } from '@/composables/useTheme'
import { useFavoritesStore, type FavoriteType } from '@/stores/favorites'
const { isDark } = useTheme()
const store = useFavoritesStore()
const activeType = ref<FavoriteType | null>(null)
const typeFilters: { id: FavoriteType; label: string }[] = [
{ id: 'film', label: 'Films' },
{ id: 'song', label: 'Songs' },
{ id: 'podcast', label: 'Podcasts' },
{ id: 'book', label: 'Books' },
{ id: 'tv', label: 'TV' },
{ id: 'place', label: 'Places' },
]
const filteredItems = computed(() => {
if (activeType.value) {
return store.getFavoritesByType(activeType.value)
}
return store.sortedItems
})
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 {
if (isDark.value) {
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'
}
const colors: Record<string, string> = {
film: 'bg-blue-50 text-blue-600',
song: 'bg-green-50 text-green-600',
podcast: 'bg-orange-50 text-orange-600',
book: 'bg-yellow-50 text-yellow-600',
tv: 'bg-indigo-50 text-indigo-600',
place: 'bg-red-50 text-red-600',
article: 'bg-cyan-50 text-cyan-600',
}
return colors[type] ?? 'bg-gray-50 text-gray-600'
}
</script>
@@ -53,6 +53,11 @@
>
{{ src.type }}
</span>
<FavoriteButton
class="ml-auto"
:favorited="favoritesStore.isFavorited(film.id)"
@toggle="favoritesStore.toggleFavorite({ id: film.id, type: 'film', title: film.title, subtitle: `${film.year} · ${film.director}`, data: film })"
/>
</div>
</div>
</button>
@@ -63,11 +68,14 @@ import { computed } from 'vue'
import type { Film } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { handleImgError } from '@/composables/useImageFallback'
import FavoriteButton from '@/components/ui/FavoriteButton.vue'
import { useFavoritesStore } from '@/stores/favorites'
const props = defineProps<{ film: Film }>()
defineEmits<{ select: [film: Film] }>()
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const isExternal = computed(() => props.film.id.startsWith('ext-'))
@@ -43,6 +43,11 @@
>
{{ src.type }}
</span>
<FavoriteButton
class="ml-auto"
:favorited="favoritesStore.isFavorited(song.id)"
@toggle="favoritesStore.toggleFavorite({ id: song.id, type: 'song', title: song.title, subtitle: song.artist, data: song })"
/>
</div>
</div>
</button>
@@ -53,11 +58,14 @@ import { ref, computed, onMounted } from 'vue'
import type { Song } from '@aiui/core/types/content'
import { useTheme } from '@/composables/useTheme'
import { generateSongCoverFallback, fetchMusicCover } from '@/composables/useImageFallback'
import FavoriteButton from '@/components/ui/FavoriteButton.vue'
import { useFavoritesStore } from '@/stores/favorites'
const props = defineProps<{ song: Song }>()
defineEmits<{ select: [song: Song] }>()
const { isDark } = useTheme()
const favoritesStore = useFavoritesStore()
const coverFailed = ref(false)
const fetchedCover = ref<string | null>(null)
@@ -0,0 +1,41 @@
<template>
<button
class="p-1 rounded-full transition-all duration-200 active:scale-125"
:class="favorited
? 'text-accent hover:text-accent/80'
: isDark
? 'text-white/20 hover:text-white/50'
: 'text-gray-300 hover:text-gray-500'"
:aria-label="favorited ? 'Remove from favorites' : 'Add to favorites'"
@click.stop="$emit('toggle')"
>
<svg
class="w-4 h-4 transition-transform duration-200"
:class="{ 'scale-110': favorited }"
:fill="favorited ? 'currentColor' : 'none'"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
</template>
<script setup lang="ts">
import { useTheme } from '@/composables/useTheme'
defineProps<{
favorited: boolean
}>()
defineEmits<{
toggle: []
}>()
const { isDark } = useTheme()
</script>
@@ -1,4 +1,4 @@
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr'
export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'favorites'
export interface MagazineSection {
title: string
+1
View File
@@ -570,6 +570,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
code: 'Code',
'design-system': 'Design',
nostr: 'Nostr',
favorites: 'Favorites',
}
function tabLabel(tab: ContentTab): string {
+125
View File
@@ -0,0 +1,125 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export type FavoriteType = 'film' | 'song' | 'podcast' | 'book' | 'tv' | 'place' | 'article'
export interface FavoriteItem {
id: string
type: FavoriteType
title: string
subtitle?: string
data: unknown
savedAt: number
}
const IDB_STORE = 'favorites'
const DB_NAME = 'aiui-favorites'
const DB_VERSION = 1
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(IDB_STORE)) {
const store = db.createObjectStore(IDB_STORE, { keyPath: 'id' })
store.createIndex('type', 'type', { unique: false })
store.createIndex('savedAt', 'savedAt', { unique: false })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function idbGetAll(): Promise<FavoriteItem[]> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readonly')
const store = tx.objectStore(IDB_STORE)
const req = store.getAll()
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
}
async function idbPut(item: FavoriteItem): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite')
const store = tx.objectStore(IDB_STORE)
const req = store.put(item)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}
async function idbDelete(id: string): Promise<void> {
const db = await openDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(IDB_STORE, 'readwrite')
const store = tx.objectStore(IDB_STORE)
const req = store.delete(id)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
}
export const useFavoritesStore = defineStore('favorites', () => {
const items = ref<FavoriteItem[]>([])
const loaded = ref(false)
async function loadFavorites() {
try {
items.value = await idbGetAll()
} catch {
// IDB unavailable — in-memory only
}
loaded.value = true
}
loadFavorites()
const sortedItems = computed(() =>
[...items.value].sort((a, b) => b.savedAt - a.savedAt)
)
function isFavorited(id: string): boolean {
return items.value.some(i => i.id === id)
}
async function addFavorite(item: Omit<FavoriteItem, 'savedAt'>) {
if (isFavorited(item.id)) return
const fav: FavoriteItem = { ...item, savedAt: Date.now() }
items.value = [...items.value, fav]
idbPut(fav).catch(() => {})
}
async function removeFavorite(id: string) {
items.value = items.value.filter(i => i.id !== id)
idbDelete(id).catch(() => {})
}
async function toggleFavorite(item: Omit<FavoriteItem, 'savedAt'>) {
if (isFavorited(item.id)) {
await removeFavorite(item.id)
} else {
await addFavorite(item)
}
}
function getFavoritesByType(type: FavoriteType): FavoriteItem[] {
return sortedItems.value.filter(i => i.type === type)
}
return {
items,
sortedItems,
loaded,
isFavorited,
addFavorite,
removeFavorite,
toggleFavorite,
getFavoritesByType,
}
})