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 @@
+
+
+
+
+
+ Favorites
+
+
+ {{ filteredItems.length }} saved
+
+
+
+
+
+
+
+
+
+
+
+
+
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,
+ }
+})