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) => {
{{ 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{{ confirmMsg }}
+{{ confirmMsg }}
+灵感与美学的无声对话
-BF-&Sl(9$VF)2itu~`*#kAb?@>lVk4QF0#w(U_q4y+D zF@iipAvElX4WJ$mRfEbLLJ0MAYG_QFm>^w4qaFgZPC|$R)qvk5Dvz2-k!UGAJM#;H z)O8-fRn6C+Xv_5wU0@uzk&p1@Gh6ee{DDN2Tb6R)+?TAE-ow{#ChKq9%GZx4>w}l@ z^^# |(Bm23_PO6s-_oSRyr6Zc8#y{;MTm#!rbboQ zfp=?>;RYT$A=9^t0_d1 BuGsj#l6xy}=0_$8dCJ%^B6kH>cDKJk&3Gs6lVk zLL~R8r-r&qsqNS_uv~dkA|} 5&?a;sE*^>39i6OxB47%D {Vg}8lE zM>SqDswsh+8uB`-A-FJ9#fuUrM4>_$(<~jS*Cd|w2uH@B+|AYgcCwx^nXi{}9aR@~ zRcg-uFnPH=Uan~JoP^jwS&pJzC5{7y=oG~$swG@D=4d7q2tSdoO`e+eV{XIylBb9V zr-GVYAA%#F@dDb?R4Cnd2zrLdiKNW}Nv*&_O$&d58X1h_qQ=IxH;K!lfEmonY!gU= zqSpqU1+ANS3*9Z+Lyu_0g0*o?JgM#J1gb}YL7<2*G9fh4eO{-b2JuZBK_tLsXHug; zT857Nsxl;Os5!uu^iVo!5!~P9)uOFS{Su@WMMOt>r>FpNxkK(lI;uy@K_rMg2R6z) z(I%jl?^- YKXVGha1JHgNixyG-Q$^{7mtP>2hU^{4?%; zb{Ig1yk^pp{No02bwn7HjUi$#icXKti+60zE{IobLBiB@g9HxbF>GE*GYdi4any*y z@+Q)RO(N;xWRnAr@l^WTWc{P2T%{&=R Imt_b|8d_5#tAC&m|YXVc^ z>zZV}qb^sGbGi7>TeD5^Cm&`ja`w~ehF^}(35JRM-5*sC-aERw>|XI=b=uGI^mWPm zGgfekRwnDEzv1g7zT#%`b>c$H+WGRz1TuCMryIX)oII&`W?E*&{pRpeENO&4=ywAu zsSo Wx=55t`XNxMl(M~a1=(`a^9 *391n>BJ&j$xX&p+}cc)AN z!MjtwB{rccLHyQ3a!>Df_)1=cAHFIpPGY4H-N7DV$R}GGT-rrE@+_>Vg?JwZj%9~2 zyO5Xh@7&l9hIejUEjBSzhPY#2QpWR>99lXOm_ti7v5S#nEOa_d_b`+2AwMFJfj$AT zgGT|18)Ta4;NaluU0^u4dY@n*DFby-tYbQm6hjCSE~2H4MpcU#Dk>AnOzUaA-tOX6 z_}g8Jgq(WH5I_A2 t zYoVdIjH`U#iO0dPZ$ORMMNJuE%DV+Jo~!(=F)M(1YmDe;8yi!Mi5-xo5q}+uxWb!E zhUQg8uWmVx>!Gd_o#vb8w1D8vb0!Ee$D{-XlDF+TMAZ$1Bh&U!;ri%+ke|@<7!-q( zo)O93X*
2znIH_E3u S$z>5li<&v%t{(P?(4?W<<(B zLf=99wV^2#z9wo^m4%^$9Eo%5u4UV?b`PIk?0WJz2zEVH5N8 Y)N$|JS^9%CqC$v4HG&_gn!P)(fe>5w1z@ z;ZCz7%Wv6^thi;x(|a+_F3>Dk2gvIKxk+9h*kh8Ry^t>K)Z=@vH4g^nwdUdCJVR29 zrkfh(f|g2jl-DNOA0%HxQ*{rWhJkh_B%*77^}H?+yn0@bO$Mg~#3h*7AhV)%K!b}= zi*$t>W1)mZzyOKX({pd1c?B4@&y2)r2c-;g9UlUEm+{m7Yi Gg{@3YzJ?5dV8zBbfUX>)IjB z^tyK8A&t{gh+zaYG|`3x2(D<|DaJk~JOm{w5-gfAPvZl!=3;5>aS)Uq$q9|NrUZmq zxW~SSK@sK;s3(xbxtDuzai$|=xjuK&=3;RZ4l=#CX_XLiYDy4)<7$MA4T~UT?d}#p zYImP6AX9oF`ySwl@4J8$KJEsj@UifZ#!FKO<>Ol)r5Z+9yiq|} V7IVxg=$X-(87KykE*qwKYQr15z_Io_wx6tT?$Bp={;q#EmQF=}I8- zRHU57Nh#zj^b*`Xnv1!K6F`ugD27+XZ>9wCUgY>>iab5bjsueQdNh7<3PF`>Q`@zW z(={|0J_aqGfyC&b#6;bW*23e#tGOuOup9*P4G##DO-u=_$Z-S8bToCmSY)XF&?p%w zYYjpSKh#1Hj0b^qdkYAp+s6xni&6p-p!oCgp@2?po{U07Hq@f1%?*^g78;x+ !IoPe2*2;@iB9S6aY$8utmvr~e2&>?P^WUBtrmUdu1+VUHL`Hx I3$`Kmy&Vf!f3fF{XgmXb%6hh1X0U9NPtk5033iB6;UG z$H}j>7#W7>Q4HU#M@PO1>4JUIr0H6%MbCOk8oyQu*cd5m|i za<#PluG1d)9qp4m7r(z#u5?a$QGQ0QQ7$j x4^hw$n1 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, + }; +}