Compare commits
2 Commits
b0fa1a4201
...
0881846717
| Author | SHA1 | Date | |
|---|---|---|---|
| 0881846717 | |||
| d7149366e9 |
27
AGENTS.md
27
AGENTS.md
@@ -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
|
||||
@@ -100,7 +111,7 @@ if (!session?.user?.id) return { success: false, message: "未授权" };
|
||||
- ❌ 用 API routes 做数据操作 (使用 Server Actions)
|
||||
- ❌ Server Component 可行时用 Client Component
|
||||
- ❌ npm 或 yarn (使用 pnpm)
|
||||
- ❌ 生产代码中使用 `console.log`
|
||||
- ❌ 生产代码中使用 `console.log` (使用 winston logger)
|
||||
|
||||
## 独特风格
|
||||
|
||||
@@ -132,6 +143,20 @@ pnpm lint # ESLint
|
||||
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)
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
"sourceCode": "GitHub",
|
||||
"sign_in": "Sign In",
|
||||
"profile": "Profile",
|
||||
"folders": "Folders"
|
||||
"folders": "Folders",
|
||||
"explore": "Explore",
|
||||
"favorites": "Favorites"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "My Profile",
|
||||
@@ -255,6 +257,26 @@
|
||||
"savedToFolder": "Saved to folder: {folderName}",
|
||||
"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": {
|
||||
"anonymous": "Anonymous",
|
||||
"email": "Email",
|
||||
|
||||
@@ -159,7 +159,9 @@
|
||||
"sourceCode": "源码",
|
||||
"sign_in": "登录",
|
||||
"profile": "个人资料",
|
||||
"folders": "文件夹"
|
||||
"folders": "文件夹",
|
||||
"explore": "探索",
|
||||
"favorites": "收藏"
|
||||
},
|
||||
"profile": {
|
||||
"myProfile": "我的个人资料",
|
||||
@@ -255,6 +257,34 @@
|
||||
"savedToFolder": "已保存到文件夹:{folderName}",
|
||||
"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": {
|
||||
"anonymous": "匿名",
|
||||
"email": "邮箱",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unstorage": "^1.17.3",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^4.3.5",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
|
||||
163
pnpm-lock.yaml
generated
163
pnpm-lock.yaml
generated
@@ -60,6 +60,9 @@ importers:
|
||||
unstorage:
|
||||
specifier: ^1.17.3
|
||||
version: 1.17.3
|
||||
winston:
|
||||
specifier: ^3.19.0
|
||||
version: 3.19.0
|
||||
zod:
|
||||
specifier: ^4.3.5
|
||||
version: 4.3.5
|
||||
@@ -321,6 +324,13 @@ packages:
|
||||
'@clack/prompts@0.11.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
|
||||
hasBin: true
|
||||
@@ -841,6 +851,9 @@ packages:
|
||||
'@schummar/icu-type-parser@1.21.5':
|
||||
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
|
||||
|
||||
'@so-ric/colorspace@1.1.6':
|
||||
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
|
||||
|
||||
'@standard-schema/spec@1.0.0':
|
||||
resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
|
||||
|
||||
@@ -1047,6 +1060,9 @@ packages:
|
||||
'@types/react@19.2.7':
|
||||
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':
|
||||
resolution: {integrity: sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1345,6 +1361,9 @@ packages:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
async@3.2.6:
|
||||
resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1547,9 +1566,25 @@ packages:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
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:
|
||||
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:
|
||||
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1790,6 +1825,9 @@ packages:
|
||||
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
enabled@2.0.0:
|
||||
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
|
||||
|
||||
@@ -1989,6 +2027,9 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fecha@4.2.3:
|
||||
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -2011,6 +2052,9 @@ packages:
|
||||
flatted@3.3.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2278,6 +2322,10 @@ packages:
|
||||
resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-stream@2.0.1:
|
||||
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-string@1.1.1:
|
||||
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2364,6 +2412,9 @@ packages:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
kuler@2.0.0:
|
||||
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
|
||||
|
||||
kysely@0.28.8:
|
||||
resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2467,6 +2518,10 @@ packages:
|
||||
lodash@4.17.21:
|
||||
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:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
@@ -2656,6 +2711,9 @@ packages:
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
one-time@1.0.0:
|
||||
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2930,6 +2988,10 @@ packages:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
@@ -3028,6 +3090,9 @@ packages:
|
||||
stable-hash@0.0.5:
|
||||
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:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@@ -3111,6 +3176,9 @@ packages:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
text-hex@1.0.0:
|
||||
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3123,6 +3191,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
|
||||
engines: {node: '>=18.12'}
|
||||
@@ -3300,6 +3372,14 @@ packages:
|
||||
engines: {node: '>= 8'}
|
||||
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:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3708,6 +3788,14 @@ snapshots:
|
||||
picocolors: 1.1.1
|
||||
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)':
|
||||
dependencies:
|
||||
'@electric-sql/pglite': 0.3.15
|
||||
@@ -4156,6 +4244,11 @@ snapshots:
|
||||
|
||||
'@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.1.0': {}
|
||||
@@ -4314,6 +4407,8 @@ snapshots:
|
||||
dependencies:
|
||||
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)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -4659,6 +4754,8 @@ snapshots:
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
async@3.2.6: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
@@ -4874,8 +4971,23 @@ snapshots:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-convert@3.1.3:
|
||||
dependencies:
|
||||
color-name: 2.1.0
|
||||
|
||||
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: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
@@ -5012,6 +5124,8 @@ snapshots:
|
||||
|
||||
empathic@2.0.0: {}
|
||||
|
||||
enabled@2.0.0: {}
|
||||
|
||||
end-of-stream@1.4.5:
|
||||
dependencies:
|
||||
once: 1.4.0
|
||||
@@ -5359,6 +5473,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fecha@4.2.3: {}
|
||||
|
||||
file-entry-cache@8.0.0:
|
||||
dependencies:
|
||||
flat-cache: 4.0.1
|
||||
@@ -5381,6 +5497,8 @@ snapshots:
|
||||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
fn.name@1.1.0: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
dependencies:
|
||||
is-callable: 1.2.7
|
||||
@@ -5655,6 +5773,8 @@ snapshots:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
|
||||
is-string@1.1.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -5735,6 +5855,8 @@ snapshots:
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
kuler@2.0.0: {}
|
||||
|
||||
kysely@0.28.8: {}
|
||||
|
||||
language-subtag-registry@0.3.23: {}
|
||||
@@ -5807,6 +5929,15 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
@@ -6001,6 +6132,10 @@ snapshots:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
one-time@1.0.0:
|
||||
dependencies:
|
||||
fn.name: 1.1.0
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.4.0
|
||||
@@ -6292,6 +6427,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-regex: 1.2.1
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
@@ -6419,6 +6556,8 @@ snapshots:
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stack-trace@0.0.10: {}
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
@@ -6520,6 +6659,8 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
text-hex@1.0.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
@@ -6531,6 +6672,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
triple-beam@1.4.1: {}
|
||||
|
||||
ts-api-utils@2.1.0(typescript@5.9.3):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
@@ -6718,6 +6861,26 @@ snapshots:
|
||||
dependencies:
|
||||
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: {}
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
@@ -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;
|
||||
195
src/app/(features)/explore/ExploreClient.tsx
Normal file
195
src/app/(features)/explore/ExploreClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/(features)/explore/[id]/page.tsx
Normal file
29
src/app/(features)/explore/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
9
src/app/(features)/explore/page.tsx
Normal file
9
src/app/(features)/explore/page.tsx
Normal 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} />;
|
||||
}
|
||||
116
src/app/(features)/favorites/FavoritesClient.tsx
Normal file
116
src/app/(features)/favorites/FavoritesClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/(features)/favorites/page.tsx
Normal file
14
src/app/(features)/favorites/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -6,9 +6,7 @@ import {
|
||||
FolderPen,
|
||||
FolderPlus,
|
||||
Globe,
|
||||
Heart,
|
||||
Lock,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { CircleButton, LightButton } from "@/design-system/base/button";
|
||||
@@ -24,22 +22,16 @@ import {
|
||||
actionDeleteFolderById,
|
||||
actionGetFoldersWithTotalPairsByUserId,
|
||||
actionRenameFolderById,
|
||||
actionSearchPublicFolders,
|
||||
actionSetFolderVisibility,
|
||||
actionToggleFavorite,
|
||||
actionCheckFavorite,
|
||||
} from "@/modules/folder/folder-aciton";
|
||||
import { TPublicFolder, TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
import { TSharedFolderWithTotalPairs } from "@/shared/folder-type";
|
||||
|
||||
type TabType = "my" | "public";
|
||||
|
||||
interface FolderProps {
|
||||
interface FolderCardProps {
|
||||
folder: TSharedFolderWithTotalPairs;
|
||||
refresh: () => void;
|
||||
showVisibility?: boolean;
|
||||
}
|
||||
|
||||
const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) => {
|
||||
const FolderCard = ({ folder, refresh }: FolderCardProps) => {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("folders");
|
||||
|
||||
@@ -69,16 +61,14 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 truncate">{folder.name}</h3>
|
||||
{showVisibility && (
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Globe size={12} />
|
||||
) : (
|
||||
<Lock size={12} />
|
||||
)}
|
||||
{folder.visibility === "PUBLIC" ? t("public") : t("private")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{t("folderInfo", {
|
||||
@@ -91,32 +81,28 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-4">
|
||||
{showVisibility && (
|
||||
<CircleButton
|
||||
onClick={handleToggleVisibility}
|
||||
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
>
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Lock size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
)}
|
||||
<CircleButton
|
||||
onClick={handleToggleVisibility}
|
||||
title={folder.visibility === "PUBLIC" ? t("setPrivate") : t("setPublic")}
|
||||
>
|
||||
{folder.visibility === "PUBLIC" ? (
|
||||
<Lock size={18} />
|
||||
) : (
|
||||
<Globe size={18} />
|
||||
)}
|
||||
</CircleButton>
|
||||
<CircleButton
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const newName = prompt(t("enterNewName"))?.trim();
|
||||
if (newName && newName.length > 0) {
|
||||
actionRenameFolderById(folder.id, newName)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
}
|
||||
else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
actionRenameFolderById(folder.id, newName).then((result) => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -127,15 +113,13 @@ const FolderCard = ({ folder, refresh, showVisibility = true }: FolderProps) =>
|
||||
e.stopPropagation();
|
||||
const confirm = prompt(t("confirmDelete", { name: folder.name }));
|
||||
if (confirm === folder.name) {
|
||||
actionDeleteFolderById(folder.id)
|
||||
.then(result => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
}
|
||||
else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
actionDeleteFolderById(folder.id).then((result) => {
|
||||
if (result.success) {
|
||||
refresh();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="text-gray-400 hover:text-red-500 hover:bg-red-50"
|
||||
@@ -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 {
|
||||
userId: string | null;
|
||||
initialPublicFolders: TPublicFolder[];
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function FoldersClient({ userId, initialPublicFolders }: FoldersClientProps) {
|
||||
export function FoldersClient({ userId }: FoldersClientProps) {
|
||||
const t = useTranslations("folders");
|
||||
const router = useRouter();
|
||||
const [folders, setFolders] = useState<TSharedFolderWithTotalPairs[]>([]);
|
||||
const [publicFolders, setPublicFolders] = useState<TPublicFolder[]>(initialPublicFolders);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>(userId ? "my" : "public");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setLoading(true);
|
||||
actionGetFoldersWithTotalPairsByUserId(userId)
|
||||
.then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setFolders(result.data);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
loadFolders();
|
||||
}, [userId]);
|
||||
|
||||
const updateFolders = async () => {
|
||||
if (!userId) return;
|
||||
const loadFolders = async () => {
|
||||
setLoading(true);
|
||||
const result = await actionGetFoldersWithTotalPairsByUserId(userId);
|
||||
if (result.success && result.data) {
|
||||
@@ -271,28 +155,14 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
|
||||
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 () => {
|
||||
if (!userId) return;
|
||||
const folderName = prompt(t("enterFolderName"));
|
||||
if (!folderName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await actionCreateFolder(userId, folderName);
|
||||
if (result.success) {
|
||||
updateFolders();
|
||||
loadFolders();
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
}
|
||||
@@ -305,109 +175,36 @@ export function FoldersClient({ userId, initialPublicFolders }: FoldersClientPro
|
||||
<PageLayout>
|
||||
<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>
|
||||
<LightButton
|
||||
onClick={handleCreateFolder}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed mb-4"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</LightButton>
|
||||
|
||||
{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"
|
||||
/>
|
||||
<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>
|
||||
<CircleButton onClick={handleSearch}>
|
||||
<Search size={18} />
|
||||
</CircleButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "my" && userId && (
|
||||
<LightButton
|
||||
onClick={handleCreateFolder}
|
||||
disabled={loading}
|
||||
className="w-full border-dashed"
|
||||
>
|
||||
<FolderPlus size={20} />
|
||||
<span>{loading ? t("creating") : t("newFolder")}</span>
|
||||
</LightButton>
|
||||
)}
|
||||
|
||||
<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>
|
||||
) : folders.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">
|
||||
<FolderPlus size={24} className="text-gray-400" />
|
||||
</div>
|
||||
) : activeTab === "my" && userId ? (
|
||||
folders.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">
|
||||
<FolderPlus size={24} className="text-gray-400" />
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
folders
|
||||
.toSorted((a, b) => a.id - b.id)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
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>
|
||||
</div>
|
||||
<p className="text-sm">{t("noFoldersYet")}</p>
|
||||
</div>
|
||||
) : (
|
||||
folders
|
||||
.toSorted((a, b) => b.id - a.id)
|
||||
.map((folder) => (
|
||||
<FolderCard key={folder.id} folder={folder} refresh={loadFolders} />
|
||||
))
|
||||
)}
|
||||
</CardList>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,16 @@
|
||||
import { auth } from "@/auth";
|
||||
import { FoldersClient } from "./FoldersClient";
|
||||
import { headers } from "next/headers";
|
||||
import { actionGetPublicFolders } from "@/modules/folder/folder-aciton";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function FoldersPage() {
|
||||
const session = await auth.api.getSession(
|
||||
{ headers: await headers() }
|
||||
);
|
||||
|
||||
const publicFoldersResult = await actionGetPublicFolders();
|
||||
const publicFolders = publicFoldersResult.success ? publicFoldersResult.data ?? [] : [];
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<FoldersClient
|
||||
userId={null}
|
||||
initialPublicFolders={publicFolders}
|
||||
/>
|
||||
);
|
||||
redirect("/login?redirect=/folders");
|
||||
}
|
||||
|
||||
return (
|
||||
<FoldersClient
|
||||
userId={session.user.id}
|
||||
initialPublicFolders={publicFolders}
|
||||
/>
|
||||
);
|
||||
return <FoldersClient userId={session.user.id} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Image from "next/image";
|
||||
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 { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
@@ -41,6 +41,22 @@ export async function Navbar() {
|
||||
<GhostLightButton href="/folders" className="md:hidden! block!" size="md">
|
||||
<Folder size={20} />
|
||||
</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
|
||||
className="hidden! md:block!"
|
||||
size="md"
|
||||
|
||||
16
src/lib/logger/index.ts
Normal file
16
src/lib/logger/index.ts
Normal 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
9
src/lib/logger/logger.ts
Normal 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],
|
||||
});
|
||||
20
src/lib/logger/transports.ts
Normal file
20
src/lib/logger/transports.ts
Normal 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()),
|
||||
});
|
||||
@@ -3,8 +3,39 @@
|
||||
import { auth } from "@/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ValidateError } from "@/lib/errors";
|
||||
import { ActionInputCreatePair, ActionInputUpdatePairById, ActionOutputGetFoldersWithTotalPairsByUserId, ActionOutputGetPublicFolders, ActionOutputSetFolderVisibility, ActionOutputToggleFavorite, ActionOutputCheckFavorite, validateActionInputCreatePair, validateActionInputUpdatePairById } from "./folder-action-dto";
|
||||
import { repoCreateFolder, repoCreatePair, repoDeleteFolderById, repoDeletePairById, repoGetFolderIdByPairId, repoGetFolderVisibility, repoGetFoldersByUserId, repoGetFoldersWithTotalPairsByUserId, repoGetPairsByFolderId, repoGetPublicFolders, repoGetUserIdByFolderId, repoRenameFolderById, repoSearchPublicFolders, repoUpdateFolderVisibility, repoUpdatePairById, repoToggleFavorite, repoCheckFavorite } from "./folder-repository";
|
||||
import {
|
||||
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 z from "zod";
|
||||
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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,3 +84,21 @@ export type ActionOutputCheckFavorite = {
|
||||
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[];
|
||||
};
|
||||
|
||||
@@ -71,3 +71,21 @@ export type RepoOutputFavoriteStatus = {
|
||||
isFavorited: boolean;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
RepoInputToggleFavorite,
|
||||
RepoInputCheckFavorite,
|
||||
RepoOutputFavoriteStatus,
|
||||
RepoInputGetUserFavorites,
|
||||
RepoOutputUserFavorite,
|
||||
} from "./folder-repository-dto";
|
||||
import { Visibility } from "../../../generated/prisma/enums";
|
||||
|
||||
@@ -272,3 +274,34 @@ export async function repoCheckFavorite(
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user