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 {