diff --git a/packages/app/src/components/content/ContentPanel.vue b/packages/app/src/components/content/ContentPanel.vue index 288d82db..d4b5b001 100644 --- a/packages/app/src/components/content/ContentPanel.vue +++ b/packages/app/src/components/content/ContentPanel.vue @@ -33,11 +33,11 @@
@@ -148,6 +148,9 @@ +
@@ -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 = { code: 'Code', 'design-system': 'Design', nostr: 'Nostr', + favorites: 'Favorites', } function tabLabel(tab: ContentTab): string { diff --git a/packages/app/src/components/content/FavoritesGrid.vue b/packages/app/src/components/content/FavoritesGrid.vue new file mode 100644 index 00000000..b56adb16 --- /dev/null +++ b/packages/app/src/components/content/FavoritesGrid.vue @@ -0,0 +1,162 @@ + + + diff --git a/packages/app/src/components/content/FilmCard.vue b/packages/app/src/components/content/FilmCard.vue index 99725a87..f9ee5ae9 100644 --- a/packages/app/src/components/content/FilmCard.vue +++ b/packages/app/src/components/content/FilmCard.vue @@ -53,6 +53,11 @@ > {{ src.type }} + @@ -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-')) diff --git a/packages/app/src/components/content/SongCard.vue b/packages/app/src/components/content/SongCard.vue index 53e23fbd..66269204 100644 --- a/packages/app/src/components/content/SongCard.vue +++ b/packages/app/src/components/content/SongCard.vue @@ -43,6 +43,11 @@ > {{ src.type }} + @@ -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(null) diff --git a/packages/app/src/components/ui/FavoriteButton.vue b/packages/app/src/components/ui/FavoriteButton.vue new file mode 100644 index 00000000..d56574b9 --- /dev/null +++ b/packages/app/src/components/ui/FavoriteButton.vue @@ -0,0 +1,41 @@ + + + diff --git a/packages/app/src/composables/contentFiltering.ts b/packages/app/src/composables/contentFiltering.ts index 9546fbfb..35ab08d9 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' +export type ContentTab = 'film' | 'song' | 'podcast' | 'book' | 'tvshow' | 'image' | 'place' | 'news' | 'websites' | 'magazine' | 'code' | 'design-system' | 'nostr' | 'favorites' export interface MagazineSection { title: string diff --git a/packages/app/src/pages/ChatPage.vue b/packages/app/src/pages/ChatPage.vue index 48684dd4..60ab8ddb 100644 --- a/packages/app/src/pages/ChatPage.vue +++ b/packages/app/src/pages/ChatPage.vue @@ -570,6 +570,7 @@ const TAB_LABELS: Record = { code: 'Code', 'design-system': 'Design', nostr: 'Nostr', + favorites: 'Favorites', } function tabLabel(tab: ContentTab): string { diff --git a/packages/app/src/stores/favorites.ts b/packages/app/src/stores/favorites.ts new file mode 100644 index 00000000..4f3deb12 --- /dev/null +++ b/packages/app/src/stores/favorites.ts @@ -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 { + 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 { + 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 { + 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 { + 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([]) + 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) { + 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) { + 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, + } +})