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:
co-authored by
Claude Opus 4.6
parent
1991d75850
commit
258a03d7f1
@@ -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
|
||||
|
||||
@@ -570,6 +570,7 @@ const TAB_LABELS: Record<ContentTab, string> = {
|
||||
code: 'Code',
|
||||
'design-system': 'Design',
|
||||
nostr: 'Nostr',
|
||||
favorites: 'Favorites',
|
||||
}
|
||||
|
||||
function tabLabel(tab: ContentTab): string {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user