From 7681e5e0f299884efaa13eeefb553ec105429a3a Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Fri, 5 Jun 2026 11:19:14 +0800 Subject: [PATCH] feat: add favorites functionality for cards - Implemented a new `favorites` table in the database schema to store user favorites. - Added API endpoint to toggle favorite status for cards. - Enhanced card listing to include favorited status for the current user. - Created service functions to manage favorites, including toggling and listing favorites. - Updated migrations to include the new `favorites` table and its associated indexes. --- app/components/TopNav.vue | 8 +- app/components/index/CardDetailModal.vue | 57 +- app/components/index/LeftSidebar.vue | 1 + app/composables/useFavorite.ts | 95 ++ app/pages/admin/scheduler/[id]/index.vue | 42 +- app/pages/admin/scheduler/index.vue | 35 +- app/pages/index/index.vue | 132 ++- app/pages/photo/index.vue | 360 ------- app/pages/portfolio/index.vue | 5 - app/pages/profile/index.vue | 5 - docs/superpowers/oauth2/API.md | 2 +- packages/bolt-ui/components/Dialog/src/Dialog.vue | 11 +- packages/drizzle-pkg/db.sqlite | Bin 282624 -> 282624 bytes packages/drizzle-pkg/lib/schema/content.ts | 22 + packages/drizzle-pkg/migrations/0005_favorites.sql | 11 + .../drizzle-pkg/migrations/meta/0005_snapshot.json | 1069 ++++++++++++++++++++ packages/drizzle-pkg/migrations/meta/_journal.json | 7 + server/api/cards/[id]/favorite.post.ts | 13 + server/api/cards/index.get.ts | 10 +- server/service/card/index.ts | 33 + server/service/favorite/index.ts | 195 ++++ 21 files changed, 1694 insertions(+), 419 deletions(-) create mode 100644 app/composables/useFavorite.ts delete mode 100644 app/pages/photo/index.vue delete mode 100644 app/pages/portfolio/index.vue delete mode 100644 app/pages/profile/index.vue create mode 100644 packages/drizzle-pkg/migrations/0005_favorites.sql create mode 100644 packages/drizzle-pkg/migrations/meta/0005_snapshot.json create mode 100644 server/api/cards/[id]/favorite.post.ts create mode 100644 server/service/favorite/index.ts diff --git a/app/components/TopNav.vue b/app/components/TopNav.vue index de83c9b..d74fb98 100644 --- a/app/components/TopNav.vue +++ b/app/components/TopNav.vue @@ -8,10 +8,8 @@ const router = useRouter() const { loggedIn, user, clear, initialized } = useAuthSession() -const links = [ - { label: '摄影', to: '/photo' }, - { label: '作品集', to: '/portfolio' }, - { label: '关于', to: '/about' }, +const links: any = [ + ] const searchQuery = ref((route.query.q as string) ?? '') @@ -104,7 +102,7 @@ watch(() => route.query.q, (val) => { 注册
  • - {{ user?.username }} + {{ user?.username }}
  • diff --git a/app/components/index/CardDetailModal.vue b/app/components/index/CardDetailModal.vue index 7894b20..8b00f50 100644 --- a/app/components/index/CardDetailModal.vue +++ b/app/components/index/CardDetailModal.vue @@ -24,6 +24,8 @@ const emit = defineEmits<{ 'update:visible': [v: boolean] }>() +const { isFavorited, toggle, loadingIds } = useFavorite() + const categoryName = computed(() => { if (!props.card?.categoryId) return null function find(nodes: CategoryNode[]): string | null { @@ -117,7 +119,18 @@ function formatDate(d?: string) { {{ formatDate(card.createdAt) }} -

    {{ card.title }}

    +
    +

    {{ card.title }}

    + +

    {{ card.description }}

    @@ -283,7 +296,49 @@ function formatDate(d?: string) { margin-left: auto; } +.detail-title-row { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.detail-fav-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 6px; + background: transparent; + color: var(--color-muted-soft); + cursor: pointer; + transition: all 0.15s ease; + margin-top: 2px; +} + +.detail-fav-btn :deep(svg) { + width: 18px; + height: 18px; +} + +.detail-fav-btn:hover { + color: var(--color-accent-amber); + background: rgba(232, 165, 90, 0.1); +} + +.detail-fav-btn.favorited { + color: var(--color-accent-amber); +} + +.detail-fav-btn.loading { + opacity: 0.5; + pointer-events: none; +} + .detail-title { + flex: 1; font-family: var(--font-display); font-size: 26px; font-weight: 400; diff --git a/app/components/index/LeftSidebar.vue b/app/components/index/LeftSidebar.vue index 7af44d8..439f1e5 100644 --- a/app/components/index/LeftSidebar.vue +++ b/app/components/index/LeftSidebar.vue @@ -25,6 +25,7 @@ const props = withDefaults(defineProps<{ side: 'left', tools: () => [ { key: 'home', label: '主页', icon: 'lucide:home' }, + { key: 'collect', label: '收藏', icon: 'lucide:star' }, // { key: 'search', label: '搜索', icon: 'lucide:search' }, // { key: 'tags', label: '标签', icon: 'lucide:tag' }, // { key: 'highlights', label: '高亮', icon: 'lucide:highlighter' }, diff --git a/app/composables/useFavorite.ts b/app/composables/useFavorite.ts new file mode 100644 index 0000000..4c80dd0 --- /dev/null +++ b/app/composables/useFavorite.ts @@ -0,0 +1,95 @@ +import { useAuthSession } from "./useAuthSession"; + +/** + * Favorite management composable. + * Provides a reactive set of favorited card IDs and toggle function. + */ +export function useFavorite() { + const { loggedIn } = useAuthSession() + const { $toast } = useNuxtApp() + + const favoritedCardIds = ref>(new Set()) + const loadingIds = ref>(new Set()) + + /** + * Check if a card is favorited. + */ + function isFavorited(cardId: number): boolean { + return favoritedCardIds.value.has(cardId) + } + + /** + * Toggle favorite for a card. + * Optimistically updates local state. + */ + async function toggle(cardId: number): Promise { + if (!loggedIn.value) { + $toast.info('请先登录') + return false + } + if (loadingIds.value.has(cardId)) return isFavorited(cardId) + + const wasFavorited = favoritedCardIds.value.has(cardId) + // Optimistic update + const next = new Set(favoritedCardIds.value) + if (wasFavorited) { + next.delete(cardId) + } else { + next.add(cardId) + } + favoritedCardIds.value = next + loadingIds.value = new Set(loadingIds.value).add(cardId) + + try { + const raw = await $fetch<{ code: number; data: { favorited: boolean } }>( + `/api/cards/${cardId}/favorite`, + { method: 'POST' }, + ) + if (raw.code !== 0 || !raw.data.favorited) { + // Server says unfavorited — ensure it's removed + const corrected = new Set(favoritedCardIds.value) + corrected.delete(cardId) + favoritedCardIds.value = corrected + } + return raw.data.favorited + } catch { + // Revert optimistic update + const reverted = new Set(favoritedCardIds.value) + if (wasFavorited) { + reverted.add(cardId) + } else { + reverted.delete(cardId) + } + favoritedCardIds.value = reverted + $toast.error('操作失败,请稍后重试') + return wasFavorited + } finally { + const next = new Set(loadingIds.value) + next.delete(cardId) + loadingIds.value = next + } + } + + /** + * Sync favorited status from server response (batch). + */ + function syncFromCards(cards: Array<{ id: number; isFavorited?: boolean }>) { + const next = new Set(favoritedCardIds.value) + for (const card of cards) { + if (card.isFavorited) { + next.add(card.id) + } else { + next.delete(card.id) + } + } + favoritedCardIds.value = next + } + + return { + favoritedCardIds, + loadingIds, + isFavorited, + toggle, + syncFromCards, + } +} diff --git a/app/pages/admin/scheduler/[id]/index.vue b/app/pages/admin/scheduler/[id]/index.vue index 14a9ed9..c513575 100644 --- a/app/pages/admin/scheduler/[id]/index.vue +++ b/app/pages/admin/scheduler/[id]/index.vue @@ -19,16 +19,34 @@ async function handleToggle() { refresh() } +// confirmation dialog +const confirmShow = ref(false) +const confirmMsg = ref('') +let confirmAction: (() => void) | null = null + +function showConfirm(msg: string, action: () => void) { + confirmMsg.value = msg + confirmAction = action + confirmShow.value = true +} + +function onConfirm() { + confirmAction?.() + confirmShow.value = false +} + async function handleDeleteLog(logId: string) { - if (!confirm('确定删除这条执行记录?')) return - await $fetch(`/api/scheduler/executions/${logId}`, { method: "DELETE" }) - refresh() + showConfirm('确定删除这条执行记录?', async () => { + await $fetch(`/api/scheduler/executions/${logId}`, { method: "DELETE" }) + refresh() + }) } async function handleClearAll() { - if (!confirm('确定清除该任务的所有执行记录?')) return - await $fetch(`/api/scheduler/executions/delete-all?taskId=${id}`, { method: "POST" }) - refresh() + showConfirm('确定清除该任务的所有执行记录?', async () => { + await $fetch(`/api/scheduler/executions/delete-all?taskId=${id}`, { method: "POST" }) + refresh() + }) } function statusClass(status: string): string { @@ -176,6 +194,18 @@ function timeAgo(ts: number | string): string {
    任务未找到
    + + + +
    +

    确认操作

    +

    {{ confirmMsg }}

    +
    + + +
    +
    +
    diff --git a/app/pages/admin/scheduler/index.vue b/app/pages/admin/scheduler/index.vue index 4b6b1d2..d0ed3dd 100644 --- a/app/pages/admin/scheduler/index.vue +++ b/app/pages/admin/scheduler/index.vue @@ -22,6 +22,22 @@ const statsData = computed(() => (stats.data.value ?? {}) as { const showCreateModal = ref(false) const editingTask = ref(null) +// confirmation dialog +const confirmShow = ref(false) +const confirmMsg = ref('') +let confirmAction: (() => void) | null = null + +function showConfirm(msg: string, action: () => void) { + confirmMsg.value = msg + confirmAction = action + confirmShow.value = true +} + +function onConfirm() { + confirmAction?.() + confirmShow.value = false +} + function statusBadge(enabled: number) { return enabled ? { label: "运行中", class: "badge-active" } @@ -29,9 +45,10 @@ function statusBadge(enabled: number) { } async function handleDelete(id: string) { - if (!confirm("确定删除此任务?")) return - await $fetch(`/api/scheduler/tasks/${id}`, { method: "DELETE" }) - refresh() + showConfirm("确定删除此任务?", async () => { + await $fetch(`/api/scheduler/tasks/${id}`, { method: "DELETE" }) + refresh() + }) } async function handleToggle(id: string, enabled: boolean) { @@ -144,6 +161,18 @@ function onModalClose() { + + +
    +

    确认操作

    +

    {{ confirmMsg }}

    +
    + + +
    +
    +
    + (() => isMobile.value ? 'drawer' : 'rail') +// confirmation dialog +const confirmShow = ref(false) +const confirmMsg = ref('') +let confirmAction: (() => void) | null = null + +function showConfirm(msg: string, action: () => void) { + confirmMsg.value = msg + confirmAction = action + confirmShow.value = true +} + +function onConfirm() { + confirmAction?.() + confirmShow.value = false +} + // ── Server response types ── interface ServerCategory { @@ -119,10 +136,6 @@ async function fetchCategories() { } } -const totalCardCount = computed(() => - categories.value.reduce((sum, c) => sum + (c.count ?? 0), 0) -) - const activeCategoryName = computed(() => { function find(nodes: CategoryNode[]): CategoryNode | null { for (const n of nodes) { @@ -135,7 +148,7 @@ const activeCategoryName = computed(() => { return null } if (activeCategoryId.value === '__uncategorized__') return '未分类' - return find(categories.value)?.name ?? '全部灵感' + return find(categories.value)?.name ?? '全部卡片' }) // ── Tool selection ── @@ -171,6 +184,14 @@ function onSelectTool(key: string) { case 'home': activeCategoryId.value = 'all' break + case 'collect': + activeCategoryId.value = 'all' + if (!loggedIn.value) { + $toast.info('请先登录') + activeToolKey.value = 'home' + return + } + break case 'tags': showTagsModal.value = true fetchTags() @@ -365,24 +386,25 @@ function onCardEdit(card: CardDetail) { } async function onCardDelete(id: number) { - if (!window.confirm('确定删除这张卡片吗?')) return - try { - await request(`/api/cards/${id}`, { method: 'DELETE' }) - $toast.success('已删除卡片') - showDetailModal.value = false - // Refresh category counts - fetchCategories() - // Reload cards - page.value = 1 - hasMore.value = true - allItems.value = [] - columns.value = Array.from({ length: columnCount.value }, () => []) - columnHeights.value = new Array(columnCount.value).fill(0) - initLoading.value = true - loadMore() - } catch { - $toast.error('删除失败') - } + showConfirm('确定删除这张卡片吗?', async () => { + try { + await request(`/api/cards/${id}`, { method: 'DELETE' }) + $toast.success('已删除卡片') + showDetailModal.value = false + // Refresh category counts + fetchCategories() + // Reload cards + page.value = 1 + hasMore.value = true + allItems.value = [] + columns.value = Array.from({ length: columnCount.value }, () => []) + columnHeights.value = new Array(columnCount.value).fill(0) + initLoading.value = true + loadMore() + } catch { + $toast.error('删除失败') + } + }) } function onNewCard() { @@ -499,6 +521,16 @@ function onContainerResize() { }, 120) } +async function handleToggleFavorite(cardId: number) { + const wasFavorited = isFavorited(cardId) + const result = await toggleFavorite(cardId) + // When unfavoriting on the collect page, remove the card from the list immediately + if (wasFavorited && !result && activeToolKey.value === 'collect') { + allItems.value = allItems.value.filter((item) => item.id !== cardId) + distributeAll() + } +} + async function loadMore() { if (loading.value || !hasMore.value) return loading.value = true @@ -510,6 +542,10 @@ async function loadMore() { if (activeCategoryId.value !== 'all') { query.categoryId = activeCategoryId.value } + // favorites mode + if (activeToolKey.value === 'collect') { + query.favorited = 1 + } // '__uncategorized__' will be passed as-is to filter null categories const q = (route.query.q as string)?.trim() if (q) query.q = q @@ -523,6 +559,8 @@ async function loadMore() { }>('/api/cards', { query }) const result = raw.data + // Sync favorite status from server + syncFromCards(result.items as Array<{ id: number; isFavorited?: boolean }>) const mapped = result.items.map(mapCard) for (const item of mapped) { allItems.value.push(item) @@ -548,6 +586,11 @@ watch(() => route.query.q, () => { resetAndReload() }) +// Reset when tool changes (e.g. collect <-> home) +watch(activeToolKey, () => { + resetAndReload() +}) + function resetAndReload() { page.value = 1 hasMore.value = true @@ -608,7 +651,6 @@ onUnmounted(() => { :categories="categories" :active-tool-key="activeToolKey" :active-category-id="activeCategoryId" - :total-count="totalCardCount" :logged-in="loggedIn" @select-tool="onSelectTool" @select-category="onSelectCategory" @@ -627,7 +669,6 @@ onUnmounted(() => { :categories="categories" :active-tool-key="activeToolKey" :active-category-id="activeCategoryId" - :total-count="totalCardCount" :logged-in="loggedIn" @update:drawer-open="(v: boolean) => drawerOpen = v" @select-tool="onSelectTool" @@ -652,9 +693,12 @@ onUnmounted(() => { 分类 - {{ activeCategoryName }} + {{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }} + +

    {{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}

    +
    { + + +
    +
    + @@ -860,6 +925,17 @@ onUnmounted(() => { flex-direction: column; } +/* ── Category heading ── */ + +.category-heading { + font-family: var(--font-display); + font-size: 22px; + font-weight: 500; + color: var(--color-ink); + margin: 0 0 12px 0; + letter-spacing: -0.3px; +} + /* ── Mobile bar ── */ .mobile-bar { @@ -938,6 +1014,10 @@ onUnmounted(() => { background: rgba(198, 69, 69, 0.8); } +.card-action-faved { + color: var(--color-accent-amber); +} + @keyframes card-enter { from { opacity: 0; diff --git a/app/pages/photo/index.vue b/app/pages/photo/index.vue deleted file mode 100644 index 75329d1..0000000 --- a/app/pages/photo/index.vue +++ /dev/null @@ -1,360 +0,0 @@ - - - - - diff --git a/app/pages/portfolio/index.vue b/app/pages/portfolio/index.vue deleted file mode 100644 index 15de4bb..0000000 --- a/app/pages/portfolio/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/app/pages/profile/index.vue b/app/pages/profile/index.vue deleted file mode 100644 index 15de4bb..0000000 --- a/app/pages/profile/index.vue +++ /dev/null @@ -1,5 +0,0 @@ - \ No newline at end of file diff --git a/docs/superpowers/oauth2/API.md b/docs/superpowers/oauth2/API.md index 5f6fd51..ee0afa9 100644 --- a/docs/superpowers/oauth2/API.md +++ b/docs/superpowers/oauth2/API.md @@ -299,7 +299,7 @@ if (url.searchParams.get('bind_success') === '1') { if (url.searchParams.get('oauth_error')) { const error = url.searchParams.get('oauth_error'); - alert(`登录失败: ${error}`); + $toast.error(`登录失败: ${error}`); } ``` diff --git a/packages/bolt-ui/components/Dialog/src/Dialog.vue b/packages/bolt-ui/components/Dialog/src/Dialog.vue index 49fec82..0c9d748 100644 --- a/packages/bolt-ui/components/Dialog/src/Dialog.vue +++ b/packages/bolt-ui/components/Dialog/src/Dialog.vue @@ -52,8 +52,6 @@ defineOptions({ inheritAttrs: false, }); -const showContent = ref(true); - const attrs = useAttrs(); function setStyle(el: HTMLElement, css: Partial) { @@ -94,6 +92,9 @@ const props = withDefaults( inBox: false, // 对话框不全屏 } ); +const showContent = ref(props.show); +const isShow = ref(props.show); +const isShowWraper = ref(props.show); const emits = defineEmits<{ (e: "update:show", isShow: boolean): void; (e: "close"): void; @@ -145,8 +146,8 @@ const getDialogAnimType = () => { onMounted(() => { watch( () => props.show, - (isShow) => { - if (isShow) { + (isShowParam) => { + if (isShowParam) { show(); } else { hide(); @@ -158,8 +159,6 @@ onMounted(() => { ); }); -const isShow = ref(false); -const isShowWraper = ref(false); const isPlaying = ref(false); watch( diff --git a/packages/drizzle-pkg/db.sqlite b/packages/drizzle-pkg/db.sqlite index e0503a1aa8ef04adc73c1d5a2d515d120d769700..780a3d17a47058eecc0799f97bd3d80dcbfce2e1 100644 GIT binary patch delta 2079 zcma)7YfKbZ6rMZ#nAz7{R@eo^%AzYQmI?cq+1a(V0u`+hg(BOuv4)-5*=>lAQo%-> zD4@Y=T0xk?*t7+Vr0S0XcC8y@QjV$_YO&qh6`CC>xej|DrSWtIu8wUir!Q5b>gR6kWm zxuaQ0bE#D;wHmC!r17zWSBy9XZ-3Ip2cX#SjP zl^NT2i0DcJ=@QxZgpNhFEk-?0D(TAvMbQm?^_d_oI({|?L7KGs@(ZL@i-CrG(k!(M zEs$)N^IC`1>QrnyCQ-xLdc&Y;kD=C(WZKixQXJhr{5gT^7e$wj{sz>D^gF4g>s+0X zOQhy!f(BL9CNaFVO59o*;a`1ub5(7)GQ@8d?=NcXPnQ=LRutP-tX^9Dye;?;FlZ}X zZ3`ygL7O9}$X8a0!Cb-dpE~Nc)Py%Dq~`yR5(W4OCCYYO$u2sZNX^UyyK`%KVM92< znV4ivlUEcLl@#AM6|W6Tix1f)dh9W2{Cz>9y3>ehyQO^9TIr3ka;f964tM29=FvHp z1X-mkF`XAZHU88rB2A+Lz-&DZKmj!MU~1h(+_;k(gnhb;v7`d6bE;KCRzui&d-y4nVfGj+}+_fkVDi z0!}E>-UnrJ6EdtuoUB-3)?um17!lBDsy6a3g?T=dPyvyOBm^J0T#Hn&j18s4J~^ip z9F@yTKu+rSbS|>5BaXn7mF|McHq12{#O^oTL;ZO9~el*lo==6cid<-K= zjfP`k-(Qc9P&~$%Ik#)wna&A`S z!wkcs1C~+lCA+R|Z%Q8G*~=cUKsMd?)F(t(-o<<95bumAJR#&Mq75^G50MC7U)Vp| zb7Z9H&1|H3OFph4Vrg z{1~f{S2lq%{aTGa@VIj4Kz_CfEX%-AI*jdFhzQ$eUur1A4S{rva!1Hvd7u>7<%P|l z+1wVXeET|zYZ1EGIJvB(lKgWsNU;v0QhEi0y>K@)P*)%W*C{QUWjjcfJ5&KMlgR26 z;7l%Py~_@6)xr!c!>Tt>387e6JG8Lm90ZeTkx4AeFfhu1K84Njt8$RKnWaWw!M zg9XLzy|+Fws^TlFvg*#!!{0F+j$!a797E_KIG;erz+hF~`&txYLT9k*;avorz~C)7 zfq++`H1L-?-Jnt`n?V6-43C7EtZ_)3M`JxvfZOWTCWr9`dfENPC0?^80ML`+T^^6% z^U-`r;AqC_5^2B7AEMoUKgavRJj=3S1L!ze_?!=!&7AXjlj3snjUn6~Tk|!32G^QC sh3lv*G1~xs*tB3Kgyz^!LpY7AqVR**oMF5j0`u$92|N~^P^}974G6hiz5oCK delta 8318 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 Wsk`N6ilqFk<2|{v users.id, { onDelete: "cascade" }), + cardId: integer("card_id") + .notNull() + .references(() => cards.id, { onDelete: "cascade" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .defaultNow() + .notNull(), + }, + (table) => [ + primaryKey({ name: "favorite_pk", columns: [table.userId, table.cardId] }), + index("idx_favorite_user").on(table.userId), + index("idx_favorite_card").on(table.cardId), + ], +); + // ============ Tool(工具项表)============ export const tools = sqliteTable("tools", { id: text("id").primaryKey(), // UUID diff --git a/packages/drizzle-pkg/migrations/0005_favorites.sql b/packages/drizzle-pkg/migrations/0005_favorites.sql new file mode 100644 index 0000000..e6fb027 --- /dev/null +++ b/packages/drizzle-pkg/migrations/0005_favorites.sql @@ -0,0 +1,11 @@ +CREATE TABLE `favorites` ( + `user_id` integer NOT NULL, + `card_id` integer NOT NULL, + `created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, + PRIMARY KEY(`user_id`, `card_id`), + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`card_id`) REFERENCES `cards`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_favorite_user` ON `favorites` (`user_id`);--> statement-breakpoint +CREATE INDEX `idx_favorite_card` ON `favorites` (`card_id`); \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/0005_snapshot.json b/packages/drizzle-pkg/migrations/meta/0005_snapshot.json new file mode 100644 index 0000000..137407b --- /dev/null +++ b/packages/drizzle-pkg/migrations/meta/0005_snapshot.json @@ -0,0 +1,1069 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "486b8025-1873-4749-976b-dbf5f4631074", + "prevId": "2cb15457-99fc-41fe-b22c-1f218a0cf194", + "tables": { + "oauth_accounts": { + "name": "oauth_accounts", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_user_id": { + "name": "provider_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "nickname": { + "name": "nickname", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar": { + "name": "avatar", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "app_configs": { + "name": "app_configs", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_type": { + "name": "value_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_configs": { + "name": "user_configs", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value_type": { + "name": "value_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "user_configs_user_id_idx": { + "name": "user_configs_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_configs_user_id_users_id_fk": { + "name": "user_configs_user_id_users_id_fk", + "tableFrom": "user_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_configs_user_id_key_pk": { + "columns": [ + "user_id", + "key" + ], + "name": "user_configs_user_id_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "card_images": { + "name": "card_images", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_card_image_card": { + "name": "idx_card_image_card", + "columns": [ + "card_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "card_images_card_id_cards_id_fk": { + "name": "card_images_card_id_cards_id_fk", + "tableFrom": "card_images", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "card_tags": { + "name": "card_tags", + "columns": { + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_card_tag_card": { + "name": "idx_card_tag_card", + "columns": [ + "card_id" + ], + "isUnique": false + }, + "idx_card_tag_tag": { + "name": "idx_card_tag_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "card_tags_card_id_cards_id_fk": { + "name": "card_tags_card_id_cards_id_fk", + "tableFrom": "card_tags", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "card_tags_tag_id_tags_id_fk": { + "name": "card_tags_tag_id_tags_id_fk", + "tableFrom": "card_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "card_tag_pk": { + "columns": [ + "card_id", + "tag_id" + ], + "name": "card_tag_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cards": { + "name": "cards", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aspect_ratio": { + "name": "aspect_ratio", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "idx_card_category": { + "name": "idx_card_category", + "columns": [ + "category_id" + ], + "isUnique": false + }, + "idx_card_type": { + "name": "idx_card_type", + "columns": [ + "type" + ], + "isUnique": false + }, + "idx_card_created": { + "name": "idx_card_created", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "cards_category_id_categories_id_fk": { + "name": "cards_category_id_categories_id_fk", + "tableFrom": "cards", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text(500)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "idx_category_slug": { + "name": "idx_category_slug", + "columns": [ + "slug" + ], + "isUnique": true + }, + "idx_category_parent": { + "name": "idx_category_parent", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "favorites": { + "name": "favorites", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": { + "idx_favorite_user": { + "name": "idx_favorite_user", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_favorite_card": { + "name": "idx_favorite_card", + "columns": [ + "card_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_card_id_cards_id_fk": { + "name": "favorites_card_id_cards_id_fk", + "tableFrom": "favorites", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "favorite_pk": { + "columns": [ + "user_id", + "card_id" + ], + "name": "favorite_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_slug": { + "name": "idx_tag_slug", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tools": { + "name": "tools", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(100)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scheduled_tasks": { + "name": "scheduled_tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "function_name": { + "name": "function_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "function_payload": { + "name": "function_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_method": { + "name": "http_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_url": { + "name": "http_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_headers": { + "name": "http_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "http_body": { + "name": "http_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "catch_up": { + "name": "catch_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "max_retries": { + "name": "max_retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "retry_delay_seconds": { + "name": "retry_delay_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 60 + }, + "timeout_seconds": { + "name": "timeout_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 300 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_execution_logs": { + "name": "task_execution_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast((julianday('now') - 2440587.5)*86400000 as integer))" + }, + "finished_at": { + "name": "finished_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "task_execution_logs_task_id_scheduled_tasks_id_fk": { + "name": "task_execution_logs_task_id_scheduled_tasks_id_fk", + "tableFrom": "task_execution_logs", + "tableTo": "scheduled_tasks", + "columnsFrom": [ + "task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/drizzle-pkg/migrations/meta/_journal.json b/packages/drizzle-pkg/migrations/meta/_journal.json index c344cd5..35b5a7d 100644 --- a/packages/drizzle-pkg/migrations/meta/_journal.json +++ b/packages/drizzle-pkg/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1780573558785, "tag": "0004_talented_wasp", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1780625963260, + "tag": "0005_favorites", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/api/cards/[id]/favorite.post.ts b/server/api/cards/[id]/favorite.post.ts new file mode 100644 index 0000000..bd79c98 --- /dev/null +++ b/server/api/cards/[id]/favorite.post.ts @@ -0,0 +1,13 @@ +import { toggleFavorite } from "../../../service/favorite"; +import { requireUser } from "#server/utils/context"; + +export default defineWrappedResponseHandler(async (event) => { + const user = await requireUser(event); + const idParam = getRouterParam(event, "id"); + if (!idParam) return R.throwError(400, "缺少卡片 ID", null); + const cardId = parseInt(idParam); + if (isNaN(cardId)) return R.throwError(400, "无效的卡片 ID", null); + + const result = await toggleFavorite(user.id, cardId); + return R.success(result); +}); diff --git a/server/api/cards/index.get.ts b/server/api/cards/index.get.ts index 4970797..e52e506 100644 --- a/server/api/cards/index.get.ts +++ b/server/api/cards/index.get.ts @@ -1,4 +1,5 @@ import { listCards, type CardType } from "../../service/card"; +import { getCurrentUser } from "#server/utils/context"; export default defineWrappedResponseHandler(async (event) => { const query = getQuery(event); @@ -8,7 +9,14 @@ export default defineWrappedResponseHandler(async (event) => { const tagId = query.tagId ? parseInt(String(query.tagId)) : undefined; const type = query.type as CardType | undefined; const q = query.q as string | undefined; + const favorited = query.favorited === "true" || query.favorited === "1"; - const result = await listCards({ page, pageSize, categoryId, tagId, type, q }); + const user = await getCurrentUser(event); + + const result = await listCards({ + page, pageSize, categoryId, tagId, type, q, + favorited: favorited || undefined, + userId: user?.id, + }); return R.success(result); }); diff --git a/server/service/card/index.ts b/server/service/card/index.ts index 0ea918a..393b1e9 100644 --- a/server/service/card/index.ts +++ b/server/service/card/index.ts @@ -5,6 +5,7 @@ import { cardTags, tags, categories, + favorites, type CardType, CardTypes, } from "drizzle-pkg/lib/schema/content"; @@ -36,6 +37,7 @@ export interface CardWithRelations { updatedAt: Date; images: CardImageData[]; tags: TagData[]; + isFavorited?: boolean; } export interface CreateCardInput { @@ -65,6 +67,8 @@ export interface ListCardsOptions { tagId?: number; type?: CardType; q?: string; + favorited?: boolean; + userId?: number; } // ============ Helpers ============ @@ -127,6 +131,19 @@ export async function listCards( conditions.push(inArray(cards.id, tagCardIds)); } + // favorites filter (requires authenticated user) + if (opts.favorited && opts.userId) { + const favCardIds = await dbGlobal + .select({ cardId: favorites.cardId }) + .from(favorites) + .where(eq(favorites.userId, opts.userId)); + const favIds = favCardIds.map((r) => r.cardId); + if (favIds.length === 0) { + return { items: [], total: 0, page, pageSize, hasMore: false }; + } + conditions.push(inArray(cards.id, favIds)); + } + const where = conditions.length > 0 ? and(...conditions) : undefined; const [rows, countResult] = await Promise.all([ @@ -163,6 +180,21 @@ export async function listCards( : Promise.resolve([]), ]); + // Load favorite status for current user + let favoritedSet = new Set(); + if (opts.userId && cardIds.length > 0) { + const favRows = await dbGlobal + .select({ cardId: favorites.cardId }) + .from(favorites) + .where( + and( + eq(favorites.userId, opts.userId), + inArray(favorites.cardId, cardIds), + ), + ); + favoritedSet = new Set(favRows.map((r) => r.cardId)); + } + // Load referenced tags const tagIds = [...new Set(allCardTags.map((ct) => ct.tagId))]; const tagRows = @@ -203,6 +235,7 @@ export async function listCards( updatedAt: row.updatedAt, images: imagesByCard.get(row.id) ?? [], tags: tagsByCard.get(row.id) ?? [], + isFavorited: opts.userId ? favoritedSet.has(row.id) : undefined, })); return { diff --git a/server/service/favorite/index.ts b/server/service/favorite/index.ts new file mode 100644 index 0000000..2d8075b --- /dev/null +++ b/server/service/favorite/index.ts @@ -0,0 +1,195 @@ +import { dbGlobal } from "drizzle-pkg/lib/db"; +import { favorites, cards, cardImages, cardTags, tags } from "drizzle-pkg/lib/schema/content"; +import { eq, and, inArray, desc, sql, asc } from "drizzle-orm"; +import type { CardWithRelations } from "../card"; + +// ============ Types ============ + +export interface ListFavoritesOptions { + page?: number + pageSize?: number +} + +// ============ Toggle ============ + +/** + * Toggle favorite status for a card. + * Returns the new favorited state. + */ +export async function toggleFavorite( + userId: number, + cardId: number, +): Promise<{ favorited: boolean }> { + const [existing] = await dbGlobal + .select() + .from(favorites) + .where( + and( + eq(favorites.userId, userId), + eq(favorites.cardId, cardId), + ), + ) + .limit(1); + + if (existing) { + await dbGlobal + .delete(favorites) + .where( + and( + eq(favorites.userId, userId), + eq(favorites.cardId, cardId), + ), + ); + return { favorited: false }; + } + + await dbGlobal.insert(favorites).values({ + userId, + cardId, + }); + return { favorited: true }; +} + +// ============ Batch check ============ + +/** + * Given a list of card IDs, return which ones the user has favorited. + */ +export async function batchIsFavorited( + userId: number, + cardIds: number[], +): Promise> { + if (cardIds.length === 0) return new Set(); + + const rows = await dbGlobal + .select({ cardId: favorites.cardId }) + .from(favorites) + .where( + and( + eq(favorites.userId, userId), + inArray(favorites.cardId, cardIds), + ), + ); + + return new Set(rows.map((r) => r.cardId)); +} + +// ============ List ============ + +/** + * List favorited card IDs for a user. + */ +export async function listFavoriteCardIds( + userId: number, + opts: ListFavoritesOptions = {}, +): Promise<{ cardIds: number[]; total: number }> { + const page = opts.page ?? 1; + const pageSize = opts.pageSize ?? 12; + + const [rows, countResult] = await Promise.all([ + dbGlobal + .select({ cardId: favorites.cardId }) + .from(favorites) + .where(eq(favorites.userId, userId)) + .orderBy(desc(favorites.createdAt)) + .limit(pageSize) + .offset((page - 1) * pageSize), + dbGlobal + .select({ count: sql`count(*)` }) + .from(favorites) + .where(eq(favorites.userId, userId)), + ]); + + return { + cardIds: rows.map((r) => r.cardId), + total: countResult[0]?.count ?? 0, + }; +} + +/** + * List favorited cards with full card data. + */ +export async function listFavoriteCards( + userId: number, + opts: ListFavoritesOptions = {}, +): Promise<{ items: CardWithRelations[]; total: number; page: number; pageSize: number; hasMore: boolean }> { + const page = opts.page ?? 1; + const pageSize = opts.pageSize ?? 12; + + const { cardIds, total } = await listFavoriteCardIds(userId, { page, pageSize }); + + if (cardIds.length === 0) { + return { items: [], total, page, pageSize, hasMore: false }; + } + + // Fetch full card data via existing listCards with id filter + // Build a simple query with the IDs + const rows = await dbGlobal + .select() + .from(cards) + .where(inArray(cards.id, cardIds)) + .orderBy(desc(cards.createdAt)); + + // Load relations + const allImages = await dbGlobal + .select() + .from(cardImages) + .where(inArray(cardImages.cardId, cardIds)); + + const allCardTags = await dbGlobal + .select() + .from(cardTags) + .where(inArray(cardTags.cardId, cardIds)); + + const tagIds = [...new Set(allCardTags.map((ct) => ct.tagId))]; + const tagRows = + tagIds.length > 0 + ? await dbGlobal.select().from(tags).where(inArray(tags.id, tagIds)) + : []; + + const tagMap = new Map(tagRows.map((t) => [t.id, t])); + const imagesByCard = new Map(); + for (const img of allImages) { + if (!imagesByCard.has(img.cardId)) imagesByCard.set(img.cardId, []); + imagesByCard.get(img.cardId)!.push({ + id: img.id, + url: img.url, + sortOrder: img.sortOrder, + }); + } + const tagsByCard = new Map(); + for (const ct of allCardTags) { + const tag = tagMap.get(ct.tagId); + if (!tag) continue; + if (!tagsByCard.has(ct.cardId)) tagsByCard.set(ct.cardId, []); + tagsByCard.get(ct.cardId)!.push({ + id: tag.id, + name: tag.name, + slug: tag.slug, + }); + } + + const items: CardWithRelations[] = rows + .filter((row) => cardIds.includes(row.id)) + .sort((a, b) => cardIds.indexOf(a.id) - cardIds.indexOf(b.id)) + .map((row) => ({ + id: row.id, + type: row.type as CardWithRelations["type"], + title: row.title, + description: row.description, + aspectRatio: row.aspectRatio, + categoryId: row.categoryId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + images: imagesByCard.get(row.id) ?? [], + tags: tagsByCard.get(row.id) ?? [], + })); + + return { + items, + total, + page, + pageSize, + hasMore: page * pageSize < total, + }; +}