Compare commits

...

2 Commits

Author SHA1 Message Date
0881846717 feat(logger): 添加 winston 日志系统
- 新增 src/lib/logger/ 模块
- 支持 dev/prod 环境不同输出格式
- createLogger() 创建带上下文的 logger
- 更新 AGENTS.md 添加日志使用约定
2026-03-08 14:52:24 +08:00
d7149366e9 feat(folders): 完善公开文件夹功能 - 添加 /explore 和 /favorites 页面
- 新增 /explore 页面:浏览和搜索公开文件夹
- 新增 /explore/[id] 页面:以只读模式查看公开文件夹
- 新增 /favorites 页面:管理收藏的文件夹
- 重构 /folders 页面:仅显示当前用户的文件夹
- 更新导航栏:添加 Explore 和 Favorites 链接
- 添加 i18n 翻译:explore 和 favorites 相关文本
- 更新 AGENTS.md:添加数据库迁移规范(必须使用 migrate dev)
2026-03-08 14:47:35 +08:00
21 changed files with 914 additions and 294 deletions

View File

@@ -93,6 +93,17 @@ if (!session?.user?.id) return { success: false, message: "未授权" };
// 变更前检查所有权 // 变更前检查所有权
``` ```
### 日志
```typescript
import { createLogger } from "@/lib/logger";
const log = createLogger("folder-repository");
log.debug("Fetching public folders");
log.info("Fetched folders", { count: folders.length });
log.error("Failed to fetch folders", { error });
```
## 反模式 (本项目) ## 反模式 (本项目)
-`index.ts` barrel exports -`index.ts` barrel exports
@@ -100,7 +111,7 @@ if (!session?.user?.id) return { success: false, message: "未授权" };
- ❌ 用 API routes 做数据操作 (使用 Server Actions) - ❌ 用 API routes 做数据操作 (使用 Server Actions)
- ❌ Server Component 可行时用 Client Component - ❌ Server Component 可行时用 Client Component
- ❌ npm 或 yarn (使用 pnpm) - ❌ npm 或 yarn (使用 pnpm)
- ❌ 生产代码中使用 `console.log` - ❌ 生产代码中使用 `console.log` (使用 winston logger)
## 独特风格 ## 独特风格
@@ -132,6 +143,20 @@ pnpm lint # ESLint
pnpm prisma studio # 数据库 GUI pnpm prisma studio # 数据库 GUI
``` ```
### 数据库迁移
**必须使用 `prisma migrate dev`,禁止使用 `db push`**
```bash
# 修改 schema 后创建迁移
DATABASE_URL=your_db_url pnpm prisma migrate dev --name your_migration_name
# 生成 Prisma Client
DATABASE_URL=your_db_url pnpm prisma generate
```
`db push` 会绕过迁移历史,导致生产环境无法正确迁移。
## 备注 ## 备注
- Tailwind CSS v4 (无 tailwind.config.ts) - Tailwind CSS v4 (无 tailwind.config.ts)

View File

@@ -159,7 +159,9 @@
"sourceCode": "GitHub", "sourceCode": "GitHub",
"sign_in": "Sign In", "sign_in": "Sign In",
"profile": "Profile", "profile": "Profile",
"folders": "Folders" "folders": "Folders",
"explore": "Explore",
"favorites": "Favorites"
}, },
"profile": { "profile": {
"myProfile": "My Profile", "myProfile": "My Profile",
@@ -255,6 +257,26 @@
"savedToFolder": "Saved to folder: {folderName}", "savedToFolder": "Saved to folder: {folderName}",
"saveFailed": "Save failed, please try again later" "saveFailed": "Save failed, please try again later"
}, },
"explore": {
"title": "Explore",
"subtitle": "Discover public folders",
"searchPlaceholder": "Search public folders...",
"loading": "Loading...",
"noFolders": "No public folders found",
"folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"pleaseLogin": "Please login first"
},
"favorites": {
"title": "My Favorites",
"subtitle": "Folders you've favorited",
"loading": "Loading...",
"noFavorites": "No favorites yet",
"folderInfo": "{userName} • {totalPairs} pairs",
"unknownUser": "Unknown User"
},
"user_profile": { "user_profile": {
"anonymous": "Anonymous", "anonymous": "Anonymous",
"email": "Email", "email": "Email",

View File

@@ -159,7 +159,9 @@
"sourceCode": "源码", "sourceCode": "源码",
"sign_in": "登录", "sign_in": "登录",
"profile": "个人资料", "profile": "个人资料",
"folders": "文件夹" "folders": "文件夹",
"explore": "探索",
"favorites": "收藏"
}, },
"profile": { "profile": {
"myProfile": "我的个人资料", "myProfile": "我的个人资料",
@@ -255,6 +257,34 @@
"savedToFolder": "已保存到文件夹:{folderName}", "savedToFolder": "已保存到文件夹:{folderName}",
"saveFailed": "保存失败,请稍后重试" "saveFailed": "保存失败,请稍后重试"
}, },
"explore": {
"title": "探索",
"subtitle": "发现公开文件夹",
"searchPlaceholder": "搜索公开文件夹...",
"loading": "加载中...",
"noFolders": "没有找到公开文件夹",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户",
"favorite": "收藏",
"unfavorite": "取消收藏",
"pleaseLogin": "请先登录"
},
"favorites": {
"title": "收藏",
"subtitle": "我收藏的文件夹",
"loading": "加载中...",
"noFavorites": "还没有收藏",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户"
},
"favorites": {
"title": "我的收藏",
"subtitle": "收藏的公开文件夹",
"loading": "加载中...",
"noFavorites": "还没有收藏任何文件夹",
"folderInfo": "{userName} • {totalPairs} 个文本对",
"unknownUser": "未知用户"
},
"user_profile": { "user_profile": {
"anonymous": "匿名", "anonymous": "匿名",
"email": "邮箱", "email": "邮箱",

View File

@@ -27,6 +27,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"unstorage": "^1.17.3", "unstorage": "^1.17.3",
"winston": "^3.19.0",
"zod": "^4.3.5", "zod": "^4.3.5",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },

163
pnpm-lock.yaml generated
View File

@@ -60,6 +60,9 @@ importers:
unstorage: unstorage:
specifier: ^1.17.3 specifier: ^1.17.3
version: 1.17.3 version: 1.17.3
winston:
specifier: ^3.19.0
version: 3.19.0
zod: zod:
specifier: ^4.3.5 specifier: ^4.3.5
version: 4.3.5 version: 4.3.5
@@ -321,6 +324,13 @@ packages:
'@clack/prompts@0.11.0': '@clack/prompts@0.11.0':
resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==} resolution: {integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==}
'@colors/colors@1.6.0':
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
'@dabh/diagnostics@2.0.8':
resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==}
'@electric-sql/pglite-socket@0.0.20': '@electric-sql/pglite-socket@0.0.20':
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==} resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
hasBin: true hasBin: true
@@ -841,6 +851,9 @@ packages:
'@schummar/icu-type-parser@1.21.5': '@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==} resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@so-ric/colorspace@1.1.6':
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
'@standard-schema/spec@1.0.0': '@standard-schema/spec@1.0.0':
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
@@ -1047,6 +1060,9 @@ packages:
'@types/react@19.2.7': '@types/react@19.2.7':
resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==}
'@types/triple-beam@1.3.5':
resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==}
'@typescript-eslint/eslint-plugin@8.48.1': '@typescript-eslint/eslint-plugin@8.48.1':
resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==} resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1345,6 +1361,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
async@3.2.6:
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1547,9 +1566,25 @@ packages:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
color-convert@3.1.3:
resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==}
engines: {node: '>=14.6'}
color-name@1.1.4: color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-name@2.1.0:
resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==}
engines: {node: '>=12.20'}
color-string@2.1.4:
resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==}
engines: {node: '>=18'}
color@5.0.3:
resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==}
engines: {node: '>=18'}
commander@12.1.0: commander@12.1.0:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1790,6 +1825,9 @@ packages:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'} engines: {node: '>=14'}
enabled@2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
end-of-stream@1.4.5: end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
@@ -1989,6 +2027,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fecha@4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@@ -2011,6 +2052,9 @@ packages:
flatted@3.3.3: flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
fn.name@1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
for-each@0.3.5: for-each@0.3.5:
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2278,6 +2322,10 @@ packages:
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
is-string@1.1.1: is-string@1.1.1:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -2364,6 +2412,9 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'} engines: {node: '>=6'}
kuler@2.0.0:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
kysely@0.28.8: kysely@0.28.8:
resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==} resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2467,6 +2518,10 @@ packages:
lodash@4.17.21: lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
logform@2.7.0:
resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==}
engines: {node: '>= 12.0.0'}
long@5.3.2: long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
@@ -2656,6 +2711,9 @@ packages:
once@1.4.0: once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
one-time@1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
open@10.2.0: open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2930,6 +2988,10 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
safe-stable-stringify@2.5.0:
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
engines: {node: '>=10'}
safer-buffer@2.1.2: safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
@@ -3028,6 +3090,9 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stack-trace@0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
std-env@3.10.0: std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -3111,6 +3176,9 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
text-hex@1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
tinyexec@1.0.2: tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -3123,6 +3191,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'} engines: {node: '>=8.0'}
triple-beam@1.4.1:
resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==}
engines: {node: '>= 14.0.0'}
ts-api-utils@2.1.0: ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@@ -3300,6 +3372,14 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
winston-transport@4.9.0:
resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==}
engines: {node: '>= 12.0.0'}
winston@3.19.0:
resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==}
engines: {node: '>= 12.0.0'}
word-wrap@1.2.5: word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -3708,6 +3788,14 @@ snapshots:
picocolors: 1.1.1 picocolors: 1.1.1
sisteransi: 1.0.5 sisteransi: 1.0.5
'@colors/colors@1.6.0': {}
'@dabh/diagnostics@2.0.8':
dependencies:
'@so-ric/colorspace': 1.1.6
enabled: 2.0.0
kuler: 2.0.0
'@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)': '@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)':
dependencies: dependencies:
'@electric-sql/pglite': 0.3.15 '@electric-sql/pglite': 0.3.15
@@ -4156,6 +4244,11 @@ snapshots:
'@schummar/icu-type-parser@1.21.5': {} '@schummar/icu-type-parser@1.21.5': {}
'@so-ric/colorspace@1.1.6':
dependencies:
color: 5.0.3
text-hex: 1.0.0
'@standard-schema/spec@1.0.0': {} '@standard-schema/spec@1.0.0': {}
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
@@ -4314,6 +4407,8 @@ snapshots:
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
'@types/triple-beam@1.3.5': {}
'@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.48.1(@typescript-eslint/parser@8.48.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -4659,6 +4754,8 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
async@3.2.6: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
@@ -4874,8 +4971,23 @@ snapshots:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
color-convert@3.1.3:
dependencies:
color-name: 2.1.0
color-name@1.1.4: {} color-name@1.1.4: {}
color-name@2.1.0: {}
color-string@2.1.4:
dependencies:
color-name: 2.1.0
color@5.0.3:
dependencies:
color-convert: 3.1.3
color-string: 2.1.4
commander@12.1.0: {} commander@12.1.0: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
@@ -5012,6 +5124,8 @@ snapshots:
empathic@2.0.0: {} empathic@2.0.0: {}
enabled@2.0.0: {}
end-of-stream@1.4.5: end-of-stream@1.4.5:
dependencies: dependencies:
once: 1.4.0 once: 1.4.0
@@ -5359,6 +5473,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.3 picomatch: 4.0.3
fecha@4.2.3: {}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
@@ -5381,6 +5497,8 @@ snapshots:
flatted@3.3.3: {} flatted@3.3.3: {}
fn.name@1.1.0: {}
for-each@0.3.5: for-each@0.3.5:
dependencies: dependencies:
is-callable: 1.2.7 is-callable: 1.2.7
@@ -5655,6 +5773,8 @@ snapshots:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
is-stream@2.0.1: {}
is-string@1.1.1: is-string@1.1.1:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4
@@ -5735,6 +5855,8 @@ snapshots:
kleur@3.0.3: {} kleur@3.0.3: {}
kuler@2.0.0: {}
kysely@0.28.8: {} kysely@0.28.8: {}
language-subtag-registry@0.3.23: {} language-subtag-registry@0.3.23: {}
@@ -5807,6 +5929,15 @@ snapshots:
lodash@4.17.21: {} lodash@4.17.21: {}
logform@2.7.0:
dependencies:
'@colors/colors': 1.6.0
'@types/triple-beam': 1.3.5
fecha: 4.2.3
ms: 2.1.3
safe-stable-stringify: 2.5.0
triple-beam: 1.4.1
long@5.3.2: {} long@5.3.2: {}
loose-envify@1.4.0: loose-envify@1.4.0:
@@ -6001,6 +6132,10 @@ snapshots:
dependencies: dependencies:
wrappy: 1.0.2 wrappy: 1.0.2
one-time@1.0.0:
dependencies:
fn.name: 1.1.0
open@10.2.0: open@10.2.0:
dependencies: dependencies:
default-browser: 5.4.0 default-browser: 5.4.0
@@ -6292,6 +6427,8 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-regex: 1.2.1 is-regex: 1.2.1
safe-stable-stringify@2.5.0: {}
safer-buffer@2.1.2: {} safer-buffer@2.1.2: {}
scheduler@0.27.0: {} scheduler@0.27.0: {}
@@ -6419,6 +6556,8 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stack-trace@0.0.10: {}
std-env@3.10.0: {} std-env@3.10.0: {}
stop-iteration-iterator@1.1.0: stop-iteration-iterator@1.1.0:
@@ -6520,6 +6659,8 @@ snapshots:
inherits: 2.0.4 inherits: 2.0.4
readable-stream: 3.6.2 readable-stream: 3.6.2
text-hex@1.0.0: {}
tinyexec@1.0.2: {} tinyexec@1.0.2: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
@@ -6531,6 +6672,8 @@ snapshots:
dependencies: dependencies:
is-number: 7.0.0 is-number: 7.0.0
triple-beam@1.4.1: {}
ts-api-utils@2.1.0(typescript@5.9.3): ts-api-utils@2.1.0(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
@@ -6718,6 +6861,26 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
winston-transport@4.9.0:
dependencies:
logform: 2.7.0
readable-stream: 3.6.2
triple-beam: 1.4.1
winston@3.19.0:
dependencies:
'@colors/colors': 1.6.0
'@dabh/diagnostics': 2.0.8
async: 3.2.6
is-stream: 2.0.1
logform: 2.7.0
one-time: 1.0.0
readable-stream: 3.6.2
safe-stable-stringify: 2.5.0
stack-trace: 0.0.10
triple-beam: 1.4.1
winston-transport: 4.9.0
word-wrap@1.2.5: {} word-wrap@1.2.5: {}
wrappy@1.0.2: {} wrappy@1.0.2: {}

View File

@@ -0,0 +1,33 @@
-- CreateEnum
CREATE TYPE "Visibility" AS ENUM ('PRIVATE', 'PUBLIC');
-- AlterTable
ALTER TABLE "folders" ADD COLUMN "visibility" "Visibility" NOT NULL DEFAULT 'PRIVATE';
-- CreateTable
CREATE TABLE "folder_favorites" (
"id" SERIAL NOT NULL,
"user_id" TEXT NOT NULL,
"folder_id" INTEGER NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "folder_favorites_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "folder_favorites_folder_id_idx" ON "folder_favorites"("folder_id");
-- CreateIndex
CREATE UNIQUE INDEX "folder_favorites_user_id_folder_id_key" ON "folder_favorites"("user_id", "folder_id");
-- CreateIndex
CREATE INDEX "folder_favorites_user_id_idx" ON "folder_favorites"("user_id");
-- CreateIndex
CREATE INDEX "folders_visibility_idx" ON "folders"("visibility");
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "folder_favorites" ADD CONSTRAINT "folder_favorites_folder_id_fkey" FOREIGN KEY ("folder_id") REFERENCES "folders"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,195 @@
"use client";
import {
ChevronRight,
Folder as Fd,
Heart,
Search,
} from "lucide-react";
import { CircleButton } from "@/design-system/base/button";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import {
actionSearchPublicFolders,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-aciton";
import { TPublicFolder } from "@/shared/folder-type";
import { authClient } from "@/lib/auth-client";
interface PublicFolderCardProps {
folder: TPublicFolder;
currentUserId?: string;
onFavoriteChange?: () => void;
}
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => {
const router = useRouter();
const t = useTranslations("explore");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then((result) => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!currentUserId) {
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onFavoriteChange?.();
} else {
toast.error(result.message);
}
};
return (
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/explore/${folder.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Heart
size={14}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
<span>{favoriteCount}</span>
</div>
<CircleButton
onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")}
>
<Heart
size={18}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
</CircleButton>
<ChevronRight size={20} className="text-gray-400" />
</div>
</div>
);
};
interface ExploreClientProps {
initialPublicFolders: TPublicFolder[];
}
export function ExploreClient({ initialPublicFolders }: ExploreClientProps) {
const t = useTranslations("explore");
const router = useRouter();
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { data: session } = authClient.useSession();
const currentUserId = session?.user?.id;
const handleSearch = async () => {
if (!searchQuery.trim()) {
setPublicFolders(initialPublicFolders);
return;
}
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim());
if (result.success && result.data) {
setPublicFolders(result.data);
}
setLoading(false);
};
const refreshFolders = async () => {
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim() || "");
if (result.success && result.data) {
setPublicFolders(result.data);
}
setLoading(false);
};
return (
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("searchPlaceholder")}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<CircleButton onClick={handleSearch}>
<Search size={18} />
</CircleButton>
</div>
<div className="mt-4">
<CardList>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : publicFolders.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFolders")}</p>
</div>
) : (
publicFolders.map((folder) => (
<PublicFolderCard
key={folder.id}
folder={folder}
currentUserId={currentUserId}
onFavoriteChange={refreshFolders}
/>
))
)}
</CardList>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,29 @@
import { redirect } from "next/navigation";
import { InFolder } from "@/app/folders/[folder_id]/InFolder";
import { actionGetFolderVisibility } from "@/modules/folder/folder-aciton";
export default async function ExploreFolderPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
if (!id) {
redirect("/explore");
}
const folderInfo = (await actionGetFolderVisibility(Number(id))).data;
if (!folderInfo) {
redirect("/explore");
}
const isPublic = folderInfo.visibility === "PUBLIC";
if (!isPublic) {
redirect("/explore");
}
return <InFolder folderId={Number(id)} isReadOnly={true} />;
}

View File

@@ -0,0 +1,9 @@
import { ExploreClient } from "./ExploreClient";
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton";
export default async function ExplorePage() {
const publicFoldersResult = await actionGetPublicFolders();
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
return <ExploreClient initialPublicFolders={publicFolders} />;
}

View File

@@ -0,0 +1,116 @@
"use client";
import {
ChevronRight,
Folder as Fd,
Heart,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { PageLayout } from "@/components/ui/PageLayout";
import { PageHeader } from "@/components/ui/PageHeader";
import { CardList } from "@/components/ui/CardList";
import { actionGetUserFavorites } from "@/modules/folder/folder-aciton";
type UserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
interface FavoriteCardProps {
favorite: UserFavorite;
}
const FavoriteCard = ({ favorite }: FavoriteCardProps) => {
const router = useRouter();
const t = useTranslations("favorites");
return (
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/explore/${favorite.folderId}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{favorite.folderName}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", {
userName: favorite.folderOwnerName ?? favorite.folderOwnerUsername ?? t("unknownUser"),
totalPairs: favorite.folderTotalPairs,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Heart size={18} className="fill-red-500 text-red-500" />
<ChevronRight size={20} className="text-gray-400" />
</div>
</div>
);
};
interface FavoritesClientProps {
userId: string;
}
export function FavoritesClient({ userId }: FavoritesClientProps) {
const t = useTranslations("favorites");
const [favorites, setFavorites] = useState<UserFavorite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadFavorites();
}, [userId]);
const loadFavorites = async () => {
setLoading(true);
const result = await actionGetUserFavorites();
if (result.success && result.data) {
setFavorites(result.data);
}
setLoading(false);
};
return (
<PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="mt-4">
<CardList>
{loading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p>
</div>
) : favorites.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Heart size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noFavorites")}</p>
</div>
) : (
favorites.map((favorite) => (
<FavoriteCard key={favorite.id} favorite={favorite} />
))
)}
</CardList>
</div>
</PageLayout>
);
}

View File

@@ -0,0 +1,14 @@
import { auth } from "@/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { FavoritesClient } from "./FavoritesClient";
export default async function FavoritesPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/login?redirect=/favorites");
}
return <FavoritesClient userId={session.user.id} />;
}

View File

@@ -6,9 +6,7 @@ import {
FolderPen, FolderPen,
FolderPlus, FolderPlus,
Globe, Globe,
Heart,
Lock, Lock,
Search,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { CircleButton, LightButton } from "@/design-system/base/button"; import { CircleButton, LightButton } from "@/design-system/base/button";
@@ -24,22 +22,16 @@ import {
actionDeleteFolderById, actionDeleteFolderById,
actionGetFoldersWithTotalPairsByUserId, actionGetFoldersWithTotalPairsByUserId,
actionRenameFolderById, actionRenameFolderById,
actionSearchPublicFolders,
actionSetFolderVisibility, actionSetFolderVisibility,
actionToggleFavorite,
actionCheckFavorite,
} from "@/modules/folder/folder-aciton"; } from "@/modules/folder/folder-aciton";
import { TPublicFolder, TSharedFolderWithTotalPairs } from "@/shared/folder-type"; import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
type TabType = "my" | "public"; interface FolderCardProps {
interface FolderProps {
folder: TSharedFolderWithTotalPairs; folder: TSharedFolderWithTotalPairs;
refresh: () => void; refresh: () => void;
showVisibility?: boolean;
} }
const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) => { const FolderCard = ({ folder, refresh }: FolderCardProps) => {
const router = useRouter(); const router = useRouter();
const t = useTranslations("folders"); const t = useTranslations("folders");
@@ -69,7 +61,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3> <h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
{showVisibility && (
<span className="flex items-center gap-1 text-xs text-gray-400"> <span className="flex items-center gap-1 text-xs text-gray-400">
{folder.visibility === "PUBLIC" ? ( {folder.visibility === "PUBLIC" ? (
<Globe size={12} /> <Globe size={12} />
@@ -78,7 +69,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
)} )}
{folder.visibility === "PUBLIC" ? t("public") : t("private")} {folder.visibility === "PUBLIC" ? t("public") : t("private")}
</span> </span>
)}
</div> </div>
<p className="text-sm text-gray-500 mt-0.5"> <p className="text-sm text-gray-500 mt-0.5">
{t("folderInfo", { {t("folderInfo", {
@@ -91,7 +81,6 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
</div> </div>
<div className="flex items-center gap-1 ml-4"> <div className="flex items-center gap-1 ml-4">
{showVisibility && (
<CircleButton <CircleButton
onClick={handleToggleVisibility} onClick={handleToggleVisibility}
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")} title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
@@ -102,18 +91,15 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
<Globe size={18} /> <Globe size={18} />
)} )}
</CircleButton> </CircleButton>
)}
<CircleButton <CircleButton
onClick={(e: React.MouseEvent) => { onClick={(e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
const newName = prompt(t("enterNewName"))?.trim(); const newName = prompt(t("enterNewName"))?.trim();
if (newName && newName.length > 0) { if (newName && newName.length > 0) {
actionRenameFolderById(folder.id, newName) actionRenameFolderById(folder.id, newName).then((result) => {
.then(result => {
if (result.success) { if (result.success) {
refresh(); refresh();
} } else {
else {
toast.error(result.message); toast.error(result.message);
} }
}); });
@@ -127,12 +113,10 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
e.stopPropagation(); e.stopPropagation();
const confirm = prompt(t("confirmDelete", { name: folder.name })); const confirm = prompt(t("confirmDelete", { name: folder.name }));
if (confirm === folder.name) { if (confirm === folder.name) {
actionDeleteFolderById(folder.id) actionDeleteFolderById(folder.id).then((result) => {
.then(result => {
if (result.success) { if (result.success) {
refresh(); refresh();
} } else {
else {
toast.error(result.message); toast.error(result.message);
} }
}); });
@@ -148,121 +132,21 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
); );
}; };
interface PublicFolderCardProps {
folder: TPublicFolder;
currentUserId?: string;
onFavoriteChange?: () => void;
}
const PublicFolderCard = ({ folder, currentUserId, onFavoriteChange }: PublicFolderCardProps) => {
const router = useRouter();
const t = useTranslations("folders");
const [isFavorited, setIsFavorited] = useState(false);
const [favoriteCount, setFavoriteCount] = useState(folder.favoriteCount);
useEffect(() => {
if (currentUserId) {
actionCheckFavorite(folder.id).then(result => {
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
}
});
}
}, [folder.id, currentUserId]);
const handleToggleFavorite = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!currentUserId) {
toast.error(t("pleaseLogin"));
return;
}
const result = await actionToggleFavorite(folder.id);
if (result.success && result.data) {
setIsFavorited(result.data.isFavorited);
setFavoriteCount(result.data.favoriteCount);
onFavoriteChange?.();
} else {
toast.error(result.message);
}
};
return (
<div
className="flex justify-between items-center group py-4 px-5 border-b border-gray-200 last:border-b-0 hover:bg-gray-50 cursor-pointer transition-colors"
onClick={() => {
router.push(`/folders/${folder.id}`);
}}
>
<div className="flex items-center gap-4 flex-1">
<div className="shrink-0 text-primary-500">
<Fd size={24} />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
<p className="text-sm text-gray-500 mt-0.5">
{t("publicFolderInfo", {
userName: folder.userName ?? folder.userUsername ?? t("unknownUser"),
totalPairs: folder.totalPairs,
})}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Heart
size={14}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
<span>{favoriteCount}</span>
</div>
<CircleButton
onClick={handleToggleFavorite}
title={isFavorited ? t("unfavorite") : t("favorite")}
>
<Heart
size={18}
className={isFavorited ? "fill-red-500 text-red-500" : ""}
/>
</CircleButton>
<ChevronRight size={20} className="text-gray-400" />
</div>
</div>
);
};
interface FoldersClientProps { interface FoldersClientProps {
userId: string | null; userId: string;
initialPublicFolders: TPublicFolder[];
} }
export function FoldersClient({ userId, initialPublicFolders }: FoldersClientProps) { export function FoldersClient({ userId }: FoldersClientProps) {
const t = useTranslations("folders"); const t = useTranslations("folders");
const router = useRouter();
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]); const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders); const [loading, setLoading] = useState(true);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>(userId ? "my" : "public");
const [searchQuery, setSearchQuery] = useState("");
useEffect(() => { useEffect(() => {
if (userId) { loadFolders();
setLoading(true);
actionGetFoldersWithTotalPairsByUserId(userId)
.then((result) => {
if (result.success && result.data) {
setFolders(result.data);
}
})
.finally(() => {
setLoading(false);
});
}
}, [userId]); }, [userId]);
const updateFolders = async () => { const loadFolders = async () => {
if (!userId) return;
setLoading(true); setLoading(true);
const result = await actionGetFoldersWithTotalPairsByUserId(userId); const result = await actionGetFoldersWithTotalPairsByUserId(userId);
if (result.success && result.data) { if (result.success && result.data) {
@@ -271,28 +155,14 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
setLoading(false); setLoading(false);
}; };
const handleSearch = async () => {
if (!searchQuery.trim()) {
setPublicFolders(initialPublicFolders);
return;
}
setLoading(true);
const result = await actionSearchPublicFolders(searchQuery.trim());
if (result.success && result.data) {
setPublicFolders(result.data);
}
setLoading(false);
};
const handleCreateFolder = async () => { const handleCreateFolder = async () => {
if (!userId) return;
const folderName = prompt(t("enterFolderName")); const folderName = prompt(t("enterFolderName"));
if (!folderName) return; if (!folderName) return;
setLoading(true); setLoading(true);
try { try {
const result = await actionCreateFolder(userId, folderName); const result = await actionCreateFolder(userId, folderName);
if (result.success) { if (result.success) {
updateFolders(); loadFolders();
} else { } else {
toast.error(result.message); toast.error(result.message);
} }
@@ -305,70 +175,22 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
<PageLayout> <PageLayout>
<PageHeader title={t("title")} subtitle={t("subtitle")} /> <PageHeader title={t("title")} subtitle={t("subtitle")} />
<div className="flex items-center gap-2 mb-4">
{userId && (
<button
onClick={() => setActiveTab("my")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === "my"
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t("myFolders")}
</button>
)}
<button
onClick={() => setActiveTab("public")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
activeTab === "public"
? "bg-primary-500 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"
}`}
>
{t("publicFolders")}
</button>
</div>
{activeTab === "public" && (
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder={t("searchPlaceholder")}
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</div>
<CircleButton onClick={handleSearch}>
<Search size={18} />
</CircleButton>
</div>
)}
{activeTab === "my" && userId && (
<LightButton <LightButton
onClick={handleCreateFolder} onClick={handleCreateFolder}
disabled={loading} disabled={loading}
className="w-full border-dashed" className="w-full border-dashed mb-4"
> >
<FolderPlus size={20} /> <FolderPlus size={20} />
<span>{loading ? t("creating") : t("newFolder")}</span> <span>{loading ? t("creating") : t("newFolder")}</span>
</LightButton> </LightButton>
)}
<div className="mt-4">
<CardList> <CardList>
{loading ? ( {loading ? (
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div> <div className="w-8 h-8 border-2 border-gray-200 border-t-gray-400 rounded-full animate-spin mx-auto mb-3"></div>
<p className="text-sm text-gray-500">{t("loading")}</p> <p className="text-sm text-gray-500">{t("loading")}</p>
</div> </div>
) : activeTab === "my" && userId ? ( ) : folders.length === 0 ? (
folders.length === 0 ? (
<div className="text-center py-12 text-gray-400"> <div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center"> <div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<FolderPlus size={24} className="text-gray-400" /> <FolderPlus size={24} className="text-gray-400" />
@@ -377,37 +199,12 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
</div> </div>
) : ( ) : (
folders folders
.toSorted((a, b) => a.id - b.id) .toSorted((a, b) => b.id - a.id)
.map((folder) => ( .map((folder) => (
<FolderCard <FolderCard key={folder.id} folder={folder} refresh={loadFolders} />
key={folder.id}
folder={folder}
refresh={updateFolders}
showVisibility={true}
/>
)) ))
)
) : (
publicFolders.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<div className="w-16 h-16 mx-auto mb-3 rounded-full bg-gray-100 flex items-center justify-center">
<Fd size={24} className="text-gray-400" />
</div>
<p className="text-sm">{t("noPublicFolders")}</p>
</div>
) : (
publicFolders.map((folder) => (
<PublicFolderCard
key={folder.id}
folder={folder}
currentUserId={userId ?? undefined}
onFavoriteChange={handleSearch}
/>
))
)
)} )}
</CardList> </CardList>
</div>
</PageLayout> </PageLayout>
); );
} }

View File

@@ -1,29 +1,16 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { FoldersClient } from "./FoldersClient"; import { FoldersClient } from "./FoldersClient";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton"; import { redirect } from "next/navigation";
export default async function FoldersPage() { export default async function FoldersPage() {
const session = await auth.api.getSession( const session = await auth.api.getSession(
{ headers: await headers() } { headers: await headers() }
); );
const publicFoldersResult = await actionGetPublicFolders();
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
if (!session) { if (!session) {
return ( redirect("/login?redirect=/folders");
<FoldersClient
userId={null}
initialPublicFolders={publicFolders}
/>
);
} }
return ( return <FoldersClient userId={session.user.id} />;
<FoldersClient
userId={session.user.id}
initialPublicFolders={publicFolders}
/>
);
} }

View File

@@ -1,6 +1,6 @@
import Image from "next/image"; import Image from "next/image";
import { IMAGES } from "@/config/images"; import { IMAGES } from "@/config/images";
import { Folder, Home, User } from "lucide-react"; import { Compass, Folder, Heart, Home, User } from "lucide-react";
import { LanguageSettings } from "./LanguageSettings"; import { LanguageSettings } from "./LanguageSettings";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
@@ -41,6 +41,22 @@ export async function Navbar() {
<GhostLightButton href="/folders" className="md:hidden! block!" size="md"> <GhostLightButton href="/folders" className="md:hidden! block!" size="md">
<Folder size={20} /> <Folder size={20} />
</GhostLightButton> </GhostLightButton>
<GhostLightButton href="/explore" className="md:block! hidden!" size="md">
{t("explore")}
</GhostLightButton>
<GhostLightButton href="/explore" className="md:hidden! block!" size="md">
<Compass size={20} />
</GhostLightButton>
{session && (
<>
<GhostLightButton href="/favorites" className="md:block! hidden!" size="md">
{t("favorites")}
</GhostLightButton>
<GhostLightButton href="/favorites" className="md:hidden! block!" size="md">
<Heart size={20} />
</GhostLightButton>
</>
)}
<GhostLightButton <GhostLightButton
className="hidden! md:block!" className="hidden! md:block!"
size="md" size="md"

16
src/lib/logger/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { logger } from "./logger";
export { logger };
export function createLogger(context: string) {
return {
debug: (message: string, meta?: object) =>
logger.debug(`[${context}] ${message}`, meta),
info: (message: string, meta?: object) =>
logger.info(`[${context}] ${message}`, meta),
warn: (message: string, meta?: object) =>
logger.warn(`[${context}] ${message}`, meta),
error: (message: string, meta?: object) =>
logger.error(`[${context}] ${message}`, meta),
};
}

9
src/lib/logger/logger.ts Normal file
View File

@@ -0,0 +1,9 @@
import winston from "winston";
import { devTransport, prodTransport } from "./transports";
const isDev = process.env.NODE_ENV !== "production";
export const logger = winston.createLogger({
level: isDev ? "debug" : "info",
transports: [isDev ? devTransport : prodTransport],
});

View File

@@ -0,0 +1,20 @@
import { transports, format } from "winston";
const { combine, timestamp, printf, colorize, json } = format;
const customFormat = printf(({ level, message, timestamp, ...metadata }) => {
const metaStr = Object.keys(metadata).length ? JSON.stringify(metadata) : "";
return `${timestamp} [${level}]: ${message} ${metaStr}`;
});
export const devTransport = new transports.Console({
format: combine(
colorize(),
timestamp({ format: "YYYY-MM-DD HH:mm:ss" }),
customFormat
),
});
export const prodTransport = new transports.Console({
format: combine(timestamp(), json()),
});

View File

@@ -3,8 +3,39 @@
import { auth } from "@/auth"; import { auth } from "@/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { ValidateError } from "@/lib/errors"; import { ValidateError } from "@/lib/errors";
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetPublicFolders, ActionOutputSetFolderVisibility, ActionOutputToggleFavorite, ActionOutputCheckFavorite, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto"; import {
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFolderVisibility, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetPublicFolders, repoGetUserIdByFolderId, repoRenameFolderById, repoSearchPublicFolders, repoUpdateFolderVisibility, repoUpdatePairById, repoToggleFavorite, repoCheckFavorite } from "./folder-repository"; ActionInputCreatePair,
ActionInputUpdatePairById,
ActionOutputGetFoldersWithTotalPairsByUserId,
ActionOutputGetPublicFolders,
ActionOutputSetFolderVisibility,
ActionOutputToggleFavorite,
ActionOutputCheckFavorite,
ActionOutputGetUserFavorites,
ActionOutputUserFavorite,
validateActionInputCreatePair,
validateActionInputUpdatePairById,
} from "./folder-action-dto";
import {
repoCreateFolder,
repoCreatePair,
repoDeleteFolderById,
repoDeletePairById,
repoGetFolderIdByPairId,
repoGetFolderVisibility,
repoGetFoldersByUserId,
repoGetFoldersWithTotalPairsByUserId,
repoGetPairsByFolderId,
repoGetPublicFolders,
repoGetUserIdByFolderId,
repoRenameFolderById,
repoSearchPublicFolders,
repoUpdateFolderVisibility,
repoUpdatePairById,
repoToggleFavorite,
repoCheckFavorite,
repoGetUserFavorites,
} from "./folder-repository";
import { validate } from "@/utils/validate"; import { validate } from "@/utils/validate";
import z from "zod"; import z from "zod";
import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant"; import { LENGTH_MAX_FOLDER_NAME, LENGTH_MIN_FOLDER_NAME } from "@/shared/constant";
@@ -425,3 +456,41 @@ export async function actionCheckFavorite(
}; };
} }
} }
export async function actionGetUserFavorites(): Promise<ActionOutputGetUserFavorites> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return {
success: false,
message: 'Unauthorized',
};
}
const favorites = await repoGetUserFavorites({
userId: session.user.id,
});
return {
success: true,
message: 'success',
data: favorites.map((fav) => ({
id: fav.id,
folderId: fav.folderId,
folderName: fav.folderName,
folderCreatedAt: fav.folderCreatedAt,
folderTotalPairs: fav.folderTotalPairs,
folderOwnerId: fav.folderOwnerId,
folderOwnerName: fav.folderOwnerName,
folderOwnerUsername: fav.folderOwnerUsername,
favoritedAt: fav.favoritedAt,
})),
};
} catch (e) {
console.log(e);
return {
success: false,
message: 'Unknown error occured.',
};
}
}

View File

@@ -84,3 +84,21 @@ export type ActionOutputCheckFavorite = {
favoriteCount: number; favoriteCount: number;
}; };
}; };
export type ActionOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};
export type ActionOutputGetUserFavorites = {
message: string;
success: boolean;
data?: ActionOutputUserFavorite[];
};

View File

@@ -71,3 +71,21 @@ export type RepoOutputFavoriteStatus = {
isFavorited: boolean; isFavorited: boolean;
favoriteCount: number; favoriteCount: number;
}; };
export interface RepoInputGetUserFavorites {
userId: string;
limit?: number;
offset?: number;
}
export type RepoOutputUserFavorite = {
id: number;
folderId: number;
folderName: string;
folderCreatedAt: Date;
folderTotalPairs: number;
folderOwnerId: string;
folderOwnerName: string | null;
folderOwnerUsername: string | null;
favoritedAt: Date;
};

View File

@@ -11,6 +11,8 @@ import {
RepoInputToggleFavorite, RepoInputToggleFavorite,
RepoInputCheckFavorite, RepoInputCheckFavorite,
RepoOutputFavoriteStatus, RepoOutputFavoriteStatus,
RepoInputGetUserFavorites,
RepoOutputUserFavorite,
} from "./folder-repository-dto"; } from "./folder-repository-dto";
import { Visibility } from "../../../generated/prisma/enums"; import { Visibility } from "../../../generated/prisma/enums";
@@ -272,3 +274,34 @@ export async function repoCheckFavorite(
favoriteCount: count, favoriteCount: count,
}; };
} }
export async function repoGetUserFavorites(input: RepoInputGetUserFavorites) {
const { userId, limit = 50, offset = 0 } = input;
const favorites = await prisma.folderFavorite.findMany({
where: { userId },
include: {
folder: {
include: {
_count: { select: { pairs: true } },
user: { select: { name: true, username: true } },
},
},
},
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
});
return favorites.map((fav) => ({
id: fav.id,
folderId: fav.folderId,
folderName: fav.folder.name,
folderCreatedAt: fav.folder.createdAt,
folderTotalPairs: fav.folder._count.pairs,
folderOwnerId: fav.folder.userId,
folderOwnerName: fav.folder.user.name,
folderOwnerUsername: fav.folder.user.username,
favoritedAt: fav.createdAt,
}));
}