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 e0503a1..780a3d1 100644 Binary files a/packages/drizzle-pkg/db.sqlite and b/packages/drizzle-pkg/db.sqlite differ diff --git a/packages/drizzle-pkg/lib/schema/content.ts b/packages/drizzle-pkg/lib/schema/content.ts index 49e245a..e742da4 100644 --- a/packages/drizzle-pkg/lib/schema/content.ts +++ b/packages/drizzle-pkg/lib/schema/content.ts @@ -7,6 +7,7 @@ import { index, uniqueIndex, } from "drizzle-orm/sqlite-core"; +import { users } from "./auth"; // ============ CardType ENUM ============ export const CardTypes = [ @@ -111,6 +112,27 @@ export const cardTags = sqliteTable( ], ); +// ============ Favorite(收藏表)============ +export const favorites = sqliteTable( + "favorites", + { + userId: integer("user_id") + .notNull() + .references(() => 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, + }; +}