Compare commits
33 Commits
fc9d96f242
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 98fb2bb530 | |||
|
57a4451e15
|
|||
| 44f072efd2 | |||
|
15578c7801
|
|||
| 7e75ae57d9 | |||
|
ad28d2e04b
|
|||
| 1bd7e909de | |||
|
cca07a91cd
|
|||
| a63c3ad00b | |||
|
433dc0759c
|
|||
| 85e08e8819 | |||
|
3abbba5fbb
|
|||
| b9a07ef6d3 | |||
|
d3d9059d1e
|
|||
| ff8ac894ce | |||
|
e666ddf4c9
|
|||
| 46bcacaf9d | |||
|
2c058020f3
|
|||
| 386affeac3 | |||
|
e2960027f2
|
|||
|
93d66315a1
|
|||
|
26bdb65346
|
|||
|
b9e34e590d
|
|||
|
873e372bad
|
|||
|
4e61e4bff5
|
|||
|
c938974d2b
|
|||
|
79e6fae0f9
|
|||
|
56a5d77c6c
|
|||
| 9d2ce50b26 | |||
|
3addd38bba
|
|||
|
3ea1112369
|
|||
|
af17b6dc5a
|
|||
| 94e8058880 |
16
.env.example
Normal file
16
.env.example
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
FRONTEND_PATH=
|
||||||
|
PROJECT_ORG_SLUG=
|
||||||
|
|
||||||
|
SESSION_SECRET=
|
||||||
|
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
|
||||||
|
CLERK_SECRET_KEY=
|
||||||
|
|
||||||
|
S3_ENDPOINT=
|
||||||
|
S3_BUCKET_NAME=
|
||||||
|
S3_REGION=
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
S3_PUBLIC_URL=
|
||||||
43
.github/workflows/deploy-preview.yml
vendored
Normal file
43
.github/workflows/deploy-preview.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Vercel Preview Deployment
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: "!contains(github.event.pull_request.labels.*.name, 'skip-ci')"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Vercel CLI
|
||||||
|
run: npm install --global vercel@latest
|
||||||
|
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: vercel build --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
id: deploy
|
||||||
|
run: echo "url=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_TOKEN }})" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Comment Preview URL on PR
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body: `🔍 **Preview deployment ready!**\n\n${{ steps.deploy.outputs.url }}`
|
||||||
|
})
|
||||||
|
|
||||||
30
.github/workflows/deploy.yml
vendored
Normal file
30
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Vercel Preview Deployment
|
||||||
|
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: "!contains(github.event.pull_request.labels.*.name, 'skip-ci')"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Vercel CLI
|
||||||
|
run: npm install --global vercel@latest
|
||||||
|
|
||||||
|
- name: Pull Vercel Environment Information
|
||||||
|
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Project Artifacts
|
||||||
|
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
|
|
||||||
|
- name: Deploy Project Artifacts to Vercel
|
||||||
|
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
132
package-lock.json
generated
132
package-lock.json
generated
@@ -20,7 +20,7 @@
|
|||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
"jotai": "^2.19.0",
|
"jotai": "^2.19.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.1",
|
"next": "^16.2.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -1585,12 +1585,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/backend": {
|
"node_modules/@clerk/backend": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.12.tgz",
|
||||||
"integrity": "sha512-I3YLnSioYFG+EVFBYm0ilN28+FC8H+hkqMgB5Pdl7AcotQOn3JhiZMqLel2H0P390p8FEJKQNnrvXk3BemeKKQ==",
|
"integrity": "sha512-pTuD3+3IvLrjx9XMYdbqpttTltrWc7npxkOIDzhtID5/+IOomxZAzsnxU1G1hvOeBoR1Jl68ZgKQw66aZ+DRFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/shared": "^4.3.2",
|
"@clerk/shared": "^4.8.2",
|
||||||
"standardwebhooks": "^1.0.0",
|
"standardwebhooks": "^1.0.0",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
@@ -1599,14 +1599,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/nextjs": {
|
"node_modules/@clerk/nextjs": {
|
||||||
"version": "7.0.7",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.2.2.tgz",
|
||||||
"integrity": "sha512-Iqg4q0ns1LZZrAdC66r/QUFMY+Rs3HAJcAb/IR0uFBj7ZAZusxdVKMmNkZP9UP6sk3OOorCsJTdE0rTMoXD2YQ==",
|
"integrity": "sha512-kFQ+sXD5qAxL8C53hDgpKJT+KithlOFDlkMK5Bwv/YNFy/Sf5Tzkxdh4y/AfAgJlDU0ksvu0Hn7mtB2QT8t9bg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/backend": "^3.2.3",
|
"@clerk/backend": "^3.2.12",
|
||||||
"@clerk/react": "^6.1.3",
|
"@clerk/react": "^6.4.2",
|
||||||
"@clerk/shared": "^4.3.2",
|
"@clerk/shared": "^4.8.2",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
@@ -1620,12 +1620,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/react": {
|
"node_modules/@clerk/react": {
|
||||||
"version": "6.1.3",
|
"version": "6.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.4.2.tgz",
|
||||||
"integrity": "sha512-9t5C8eM5cTmOmpBO5nb8FDA40biQqeQLUW+cVwAE0t5hnGRwiC6mSv83vqHg+9qQBqtliR013BGVjpCz53gVCA==",
|
"integrity": "sha512-l43pon5wcM0n4e3gnRP7MWdvAXAa3M7BOF6aeNwEuITxgy6MU6ypej9tPeGmXxPicL/Hd6E/VRgiEipEKDqyCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clerk/shared": "^4.3.2",
|
"@clerk/shared": "^4.8.2",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -1637,9 +1637,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@clerk/shared": {
|
"node_modules/@clerk/shared": {
|
||||||
"version": "4.3.2",
|
"version": "4.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.8.2.tgz",
|
||||||
"integrity": "sha512-tYYzdY4Fxb02TO4RHoLRFzEjXJn0iFDfoKhWtGyqf2AaIgkprTksunQtX0hnVssHMr3XD/E2S00Vrb+PzX3jCQ==",
|
"integrity": "sha512-kBFDNeLdiNZkOHavTkOB00NMuqsmOGgVtDlzCS0/yivxsCUZXk3AT6+UsTfeSBGV7QD80YxjeFMNSrpqnSv4Gg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2164,9 +2164,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@hono/node-server": {
|
"node_modules/@hono/node-server": {
|
||||||
"version": "1.19.11",
|
"version": "1.19.14",
|
||||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
|
||||||
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
|
"integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.14.1"
|
"node": ">=18.14.1"
|
||||||
@@ -3518,9 +3518,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
|
||||||
"integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
|
"integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
@@ -3534,9 +3534,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
|
||||||
"integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
|
"integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3550,9 +3550,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
|
||||||
"integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
|
"integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3566,9 +3566,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
|
||||||
"integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
|
"integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3582,9 +3582,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
|
||||||
"integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
|
"integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3598,9 +3598,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
|
||||||
"integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
|
"integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3614,9 +3614,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
|
||||||
"integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
|
"integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3630,9 +3630,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
|
||||||
"integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
|
"integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -3646,9 +3646,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
|
||||||
"integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
|
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -11598,9 +11598,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hono": {
|
"node_modules/hono": {
|
||||||
"version": "4.12.9",
|
"version": "4.12.14",
|
||||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
|
||||||
"integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
|
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.9.0"
|
"node": ">=16.9.0"
|
||||||
@@ -15239,12 +15239,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.2.1",
|
"version": "16.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
|
||||||
"integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
|
"integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.2.1",
|
"@next/env": "16.2.4",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -15258,14 +15258,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.2.1",
|
"@next/swc-darwin-arm64": "16.2.4",
|
||||||
"@next/swc-darwin-x64": "16.2.1",
|
"@next/swc-darwin-x64": "16.2.4",
|
||||||
"@next/swc-linux-arm64-gnu": "16.2.1",
|
"@next/swc-linux-arm64-gnu": "16.2.4",
|
||||||
"@next/swc-linux-arm64-musl": "16.2.1",
|
"@next/swc-linux-arm64-musl": "16.2.4",
|
||||||
"@next/swc-linux-x64-gnu": "16.2.1",
|
"@next/swc-linux-x64-gnu": "16.2.4",
|
||||||
"@next/swc-linux-x64-musl": "16.2.1",
|
"@next/swc-linux-x64-musl": "16.2.4",
|
||||||
"@next/swc-win32-arm64-msvc": "16.2.1",
|
"@next/swc-win32-arm64-msvc": "16.2.4",
|
||||||
"@next/swc-win32-x64-msvc": "16.2.1",
|
"@next/swc-win32-x64-msvc": "16.2.4",
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -16513,9 +16513,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "7.5.4",
|
"version": "7.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
|
||||||
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
"integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
"iron-session": "^8.0.4",
|
"iron-session": "^8.0.4",
|
||||||
"jotai": "^2.19.0",
|
"jotai": "^2.19.0",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.1",
|
"next": "^16.2.4",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
|
|||||||
50
src/app/(pages)/admin/article/[externalId]/page.tsx
Normal file
50
src/app/(pages)/admin/article/[externalId]/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { getArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||||
|
import { UpdateArticleForm } from '@/ui/components/internal/update-article-form';
|
||||||
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
import { ArrowLeftIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
interface UpdateArticlePageProps {
|
||||||
|
params: Promise<{ externalId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ArticleFormContent = async ({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ externalId: string }>;
|
||||||
|
}) => {
|
||||||
|
const { externalId } = await params;
|
||||||
|
const result = await getArticleByExternalId(externalId as UUIDv4);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
|
const article = result.value;
|
||||||
|
if (!article) notFound();
|
||||||
|
|
||||||
|
return <UpdateArticleForm article={article} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UpdateArticlePage = ({ params }: UpdateArticlePageProps) => {
|
||||||
|
return (
|
||||||
|
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
||||||
|
<div className='mb-6'>
|
||||||
|
<Link
|
||||||
|
href='/admin'
|
||||||
|
className='inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors'
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className='size-4' />
|
||||||
|
Back to articles
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className='rounded-lg border border-border p-6'>
|
||||||
|
<h2 className='mb-6 text-2xl font-bold'>Edit Article</h2>
|
||||||
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ArticleFormContent params={params} />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateArticlePage;
|
||||||
14
src/app/(pages)/admin/article/create/page.tsx
Normal file
14
src/app/(pages)/admin/article/create/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { CreateArticleForm } from '@/ui/components/internal/create-article-form';
|
||||||
|
|
||||||
|
const CreateArticlePage = () => {
|
||||||
|
return (
|
||||||
|
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
||||||
|
<div className='rounded-lg border border-border p-6'>
|
||||||
|
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
|
||||||
|
<CreateArticleForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateArticlePage;
|
||||||
@@ -1,12 +1,36 @@
|
|||||||
import CreateArticleForm from '@/ui/components/internal/create-article-form';
|
import { AdminArticleList } from '@/ui/components/internal/article/admin-article-list';
|
||||||
|
import { AdminArticleListSkeleton } from '@/ui/components/internal/article/admin-article-list-skeleton';
|
||||||
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
const AdminPage = async () => {
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
|
interface AdminPageProps {
|
||||||
|
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminPage = async ({ searchParams }: AdminPageProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
||||||
<div className='rounded-lg border border-border p-6'>
|
<div className='mb-8 flex items-center justify-between border-b border-border pb-6'>
|
||||||
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
|
<h1 className='text-2xl font-bold'>Articles</h1>
|
||||||
<CreateArticleForm />
|
<Button asChild>
|
||||||
|
<Link href='/admin/article/create'>
|
||||||
|
<PlusIcon className='size-4' />
|
||||||
|
Create Article
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<Suspense
|
||||||
|
fallback={<AdminArticleListSkeleton skeletonSize={PAGE_SIZE} />}
|
||||||
|
>
|
||||||
|
<AdminArticleList
|
||||||
|
searchParams={searchParams}
|
||||||
|
defaultPageSize={PAGE_SIZE}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getArticleBySlug } from '@/lib/feature/article/article.external';
|
import { getArticleBySlug } from '@/lib/feature/article/article.external';
|
||||||
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
|
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
|
||||||
|
import { cacheTag } from 'next/cache';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
@@ -10,10 +11,6 @@ type ArticlePageProps = {
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArticleContentProps = {
|
|
||||||
params: Promise<{ slug: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function readingTime(text: string): number {
|
function readingTime(text: string): number {
|
||||||
const words = text.trim().split(/\s+/).length;
|
const words = text.trim().split(/\s+/).length;
|
||||||
return Math.max(1, Math.ceil(words / 200));
|
return Math.max(1, Math.ceil(words / 200));
|
||||||
@@ -68,9 +65,14 @@ const ArticleContentSkeleton = () => (
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ArticleContent = async ({ params }: ArticleContentProps) => {
|
const ArticleContent = async ({ slug }: { slug: string }) => {
|
||||||
const { slug } = await params;
|
'use cache';
|
||||||
const article = await getArticleBySlug(slug);
|
|
||||||
|
cacheTag(`article:slug:${slug}`);
|
||||||
|
|
||||||
|
const articleResult = await getArticleBySlug(slug);
|
||||||
|
if (!articleResult.ok) throw articleResult.error;
|
||||||
|
const article = articleResult.value;
|
||||||
|
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
@@ -142,10 +144,15 @@ const ArticleContent = async ({ params }: ArticleContentProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ArticlePage = ({ params }: ArticlePageProps) => {
|
const ArticleContentWrapper = async ({ params }: ArticlePageProps) => {
|
||||||
|
const { slug } = await params;
|
||||||
|
return <ArticleContent slug={slug} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ArticlePage = (props: ArticlePageProps) => {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<ArticleContentSkeleton />}>
|
<Suspense fallback={<ArticleContentSkeleton />}>
|
||||||
<ArticleContent params={params} />
|
<ArticleContentWrapper {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,102 +1,23 @@
|
|||||||
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
import { ArticleList } from '@/ui/components/internal/article/article-list';
|
||||||
import { ArticleCard } from '@/ui/components/internal/article-card';
|
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
||||||
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
|
||||||
import { FileTextIcon } from 'lucide-react';
|
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
const PAGE_SIZE = 9;
|
const DEFAULT_PAGE_SIZE = 4;
|
||||||
|
|
||||||
const ArticleCardSkeleton = () => (
|
|
||||||
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card'>
|
|
||||||
<div className='aspect-video w-full animate-pulse bg-muted' />
|
|
||||||
<div className='flex flex-1 flex-col gap-3 p-5'>
|
|
||||||
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='space-y-1.5'>
|
|
||||||
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
<div className='flex-1 space-y-1.5'>
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
<div className='mt-1 h-3 w-16 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const ArticleListSkeleton = () => (
|
|
||||||
<>
|
|
||||||
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
|
||||||
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
|
|
||||||
<ArticleCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
type ArticleListProps = {
|
|
||||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ArticleList = async ({ searchParams }: ArticleListProps) => {
|
|
||||||
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
|
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
|
||||||
const pageSize = Number(pageSizeParam) || PAGE_SIZE;
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: articles,
|
|
||||||
totalPages,
|
|
||||||
total,
|
|
||||||
} = await getArticlesPaginated(page, pageSize);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<p className='mb-10 text-muted-foreground'>
|
|
||||||
{total === 0
|
|
||||||
? 'No articles published yet.'
|
|
||||||
: `${total} article${total === 1 ? '' : 's'} published`}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{articles.length === 0 ? (
|
|
||||||
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
|
||||||
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
No articles yet. Check back soon!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
|
||||||
{articles.map((article) => (
|
|
||||||
<ArticleCard
|
|
||||||
key={article.externalId}
|
|
||||||
article={article}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className='mt-12 flex items-center justify-between gap-4'>
|
|
||||||
<p className='text-sm text-muted-foreground'>
|
|
||||||
Page {page} of {totalPages}
|
|
||||||
</p>
|
|
||||||
<ArticleListPagination
|
|
||||||
currentPage={page}
|
|
||||||
totalPages={totalPages}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type HomeProps = {
|
type HomeProps = {
|
||||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
searchParams?: Promise<{ page?: string; pageSize?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Home = async ({ searchParams }: HomeProps) => {
|
const ArticleListWrapper = async ({ searchParams }: HomeProps) => {
|
||||||
|
const params = await searchParams;
|
||||||
|
|
||||||
|
const page = Number(params?.page) || 1;
|
||||||
|
const pageSize = Number(params?.pageSize) || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
return <ArticleList page={page} pageSize={pageSize} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Home = async (props: HomeProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
|
<div className='container mx-auto w-full flex-1 px-4 py-12 md:py-16'>
|
||||||
<div className='mb-10 border-b border-border pb-8'>
|
<div className='mb-10 border-b border-border pb-8'>
|
||||||
@@ -107,8 +28,12 @@ const Home = async ({ searchParams }: HomeProps) => {
|
|||||||
Latest Articles
|
Latest Articles
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Suspense fallback={<ArticleListSkeleton />}>
|
<Suspense
|
||||||
<ArticleList searchParams={searchParams} />
|
fallback={
|
||||||
|
<ArticleListSkeleton skeletonSize={DEFAULT_PAGE_SIZE} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArticleListWrapper {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ export async function GET() {
|
|||||||
redirect('/');
|
redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncedUser = await syncUser(parsedClaims);
|
const syncResult = await syncUser(parsedClaims);
|
||||||
|
if (!syncResult.ok) {
|
||||||
await setSessionData('user', syncedUser);
|
console.error(syncResult.error);
|
||||||
|
redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
await setSessionData('user', syncResult.value);
|
||||||
redirect('/');
|
redirect('/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,54 +8,115 @@ import {
|
|||||||
} from '@/lib/feature/article/article.model';
|
} from '@/lib/feature/article/article.model';
|
||||||
import * as service from '@/lib/feature/article/article.service';
|
import * as service from '@/lib/feature/article/article.service';
|
||||||
import { getSessionData } from '@/lib/session/session-storage';
|
import { getSessionData } from '@/lib/session/session-storage';
|
||||||
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
import { UUIDv4 } from '@/utils/types/uuid';
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
import { revalidateTag } from 'next/cache';
|
||||||
|
|
||||||
export const getArticleByExternalId = async (
|
export const getArticleByExternalId: (
|
||||||
externalId: UUIDv4
|
externalId: UUIDv4
|
||||||
): Promise<ArticleModel | null> => {
|
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||||
return await service.getArticleByExternalId(externalId);
|
async (externalId: UUIDv4): Promise<ArticleModel | null> => {
|
||||||
};
|
const result = await service.getArticleByExternalId(externalId);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const getArticleBySlug = async (
|
export const getArticleBySlug: (
|
||||||
slug: string
|
slug: string
|
||||||
): Promise<ArticleModel | null> => {
|
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||||
return await service.getArticleBySlug(slug);
|
async (slug: string): Promise<ArticleModel | null> => {
|
||||||
};
|
const result = await service.getArticleBySlug(slug);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const getArticlesPaginated = async (
|
export const getArticlesPaginated: (
|
||||||
|
page?: number,
|
||||||
|
pageSize?: number
|
||||||
|
) => Promise<TypedResult<PaginatedArticlesResult>> = wrap(
|
||||||
|
async (
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
pageSize: number = 10
|
pageSize: number = 10
|
||||||
): Promise<PaginatedArticlesResult> => {
|
): Promise<PaginatedArticlesResult> => {
|
||||||
return await service.getArticlesPaginated(page, pageSize);
|
const result = await service.getArticlesPaginated(page, pageSize);
|
||||||
};
|
if (!result.ok) throw result.error;
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const saveArticle = async (
|
export const saveArticle: (
|
||||||
article: CreateArticleModel
|
article: CreateArticleModel
|
||||||
): Promise<ArticleModel> => {
|
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||||
|
async (article: CreateArticleModel): Promise<ArticleModel> => {
|
||||||
const session = await getSessionData();
|
const session = await getSessionData();
|
||||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||||
throw new Error('Unauthorized: Only admin users can save articles.');
|
throw new Error(
|
||||||
|
'Unauthorized: Only admin users can save articles.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
article.authorId = session.user.id;
|
article.authorId = session.user.id;
|
||||||
|
|
||||||
return await service.saveArticle(article);
|
const result = await service.saveArticle(article);
|
||||||
};
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
export const updateArticle = async (
|
revalidateTag('articles', 'max');
|
||||||
articleId: string,
|
return result.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const updateArticleByExternalId: (
|
||||||
|
externalId: UUIDv4,
|
||||||
|
article: UpdateArticleModel
|
||||||
|
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||||
|
async (
|
||||||
|
externalId: UUIDv4,
|
||||||
article: UpdateArticleModel
|
article: UpdateArticleModel
|
||||||
): Promise<ArticleModel> => {
|
): Promise<ArticleModel> => {
|
||||||
const session = await getSessionData();
|
const session = await getSessionData();
|
||||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||||
throw new Error('Unauthorized: Only admin users can save articles.');
|
throw new Error(
|
||||||
|
'Unauthorized: Only admin users can save articles.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return await service.updateArticle(articleId, article);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteArticle = async (articleId: string): Promise<void> => {
|
const result = await service.updateArticleByExternalId(
|
||||||
|
externalId,
|
||||||
|
article
|
||||||
|
);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
|
revalidateTag('articles', 'max');
|
||||||
|
revalidateTag(`article:${externalId}`, 'max');
|
||||||
|
revalidateTag(`article:slug:${result.value.slug}`, 'max');
|
||||||
|
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const deleteArticleByExternalId: (
|
||||||
|
externalId: UUIDv4
|
||||||
|
) => Promise<TypedResult<void>> = wrap(
|
||||||
|
async (externalId: UUIDv4): Promise<void> => {
|
||||||
const session = await getSessionData();
|
const session = await getSessionData();
|
||||||
if (!session || !session?.user || session?.user.role !== 'admin') {
|
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||||
throw new Error('Unauthorized: Only admin users can delete articles.');
|
throw new Error(
|
||||||
|
'Unauthorized: Only admin users can delete articles.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await service.deleteArticle(articleId);
|
|
||||||
};
|
const getResult = await service.getArticleByExternalId(externalId);
|
||||||
|
if (!getResult.ok) throw getResult.error;
|
||||||
|
const article = getResult.value;
|
||||||
|
|
||||||
|
if (!article) throw new Error('Article not found');
|
||||||
|
|
||||||
|
const result = await service.deleteArticleByExternalId(externalId);
|
||||||
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
|
revalidateTag('articles', 'max');
|
||||||
|
revalidateTag(`article:${externalId}`, 'max');
|
||||||
|
revalidateTag(`article:slug:${article.slug}`, 'max');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export const UpdateArticleModel = z.object({
|
|||||||
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
|
export type UpdateArticleModel = z.infer<typeof UpdateArticleModel>;
|
||||||
|
|
||||||
export const ArticleModel = z.object({
|
export const ArticleModel = z.object({
|
||||||
id: z.string(),
|
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
slug: z.string(),
|
slug: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
PaginatedArticlesResult,
|
PaginatedArticlesResult,
|
||||||
UpdateArticleModel,
|
UpdateArticleModel,
|
||||||
} from '@/lib/feature/article/article.model';
|
} from '@/lib/feature/article/article.model';
|
||||||
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
import { UUIDv4 } from '@/utils/types/uuid';
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
|
||||||
export const articleEntityToModel = (
|
export const articleEntityToModel = (
|
||||||
articleEntity: ArticleEntity
|
articleEntity: ArticleEntity
|
||||||
): ArticleModel => {
|
): ArticleModel => {
|
||||||
return {
|
return {
|
||||||
id: articleEntity.id,
|
|
||||||
title: articleEntity.title,
|
title: articleEntity.title,
|
||||||
slug: articleEntity.slug,
|
slug: articleEntity.slug,
|
||||||
description: articleEntity.description,
|
description: articleEntity.description,
|
||||||
@@ -25,13 +25,11 @@ export const articleEntityToModel = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Retrieves an artible by its external ID.
|
/** Retrieves an article by its external ID. */
|
||||||
* @param externalId - The external ID of the article to retrieve.
|
export const getArticleByExternalId: (
|
||||||
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
|
|
||||||
* */
|
|
||||||
export const getArticleByExternalId = async (
|
|
||||||
externalId: UUIDv4
|
externalId: UUIDv4
|
||||||
): Promise<ArticleModel | null> => {
|
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||||
|
async (externalId: UUIDv4): Promise<ArticleModel | null> => {
|
||||||
const articleRepository = await getRepository(ArticleEntity);
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
const articleEntity = await articleRepository.findOneBy({
|
const articleEntity = await articleRepository.findOneBy({
|
||||||
@@ -43,16 +41,14 @@ export const getArticleByExternalId = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return articleEntityToModel(articleEntity);
|
return articleEntityToModel(articleEntity);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/** Retrieves an article by its slug. */
|
||||||
* Retrieves an article by its slug.
|
export const getArticleBySlug: (
|
||||||
* @param slug - The slug of the article to retrieve.
|
|
||||||
* @returns {Promise<ArticleModel | null>} The article model if found, otherwise null.
|
|
||||||
*/
|
|
||||||
export const getArticleBySlug = async (
|
|
||||||
slug: string
|
slug: string
|
||||||
): Promise<ArticleModel | null> => {
|
) => Promise<TypedResult<ArticleModel | null>> = wrap(
|
||||||
|
async (slug: string): Promise<ArticleModel | null> => {
|
||||||
const articleRepository = await getRepository(ArticleEntity);
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
const articleEntity = await articleRepository.findOneBy({ slug });
|
const articleEntity = await articleRepository.findOneBy({ slug });
|
||||||
@@ -62,30 +58,28 @@ export const getArticleBySlug = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return articleEntityToModel(articleEntity);
|
return articleEntityToModel(articleEntity);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/** Retrieves all articles by a given author ID. */
|
||||||
* Retrieves all articles by a given author ID.
|
export const getArticlesByAuthorId: (
|
||||||
* @param authorId - The ID of the author.
|
|
||||||
* @returns {Promise<ArticleModel[]>} A list of article models.
|
|
||||||
*/
|
|
||||||
export const getArticlesByAuthorId = async (
|
|
||||||
authorId: string
|
authorId: string
|
||||||
): Promise<ArticleModel[]> => {
|
) => Promise<TypedResult<ArticleModel[]>> = wrap(
|
||||||
|
async (authorId: string): Promise<ArticleModel[]> => {
|
||||||
const articleRepository = await getRepository(ArticleEntity);
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
const articleEntities = await articleRepository.findBy({ authorId });
|
const articleEntities = await articleRepository.findBy({ authorId });
|
||||||
|
|
||||||
return articleEntities.map(articleEntityToModel);
|
return articleEntities.map(articleEntityToModel);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/** Retrieves a paginated list of articles ordered by creation date descending. */
|
||||||
* Retrieves a paginated list of articles ordered by creation date descending.
|
export const getArticlesPaginated: (
|
||||||
* @param page - The page number (1-based).
|
page?: number,
|
||||||
* @param pageSize - The number of articles per page.
|
pageSize?: number
|
||||||
* @returns {Promise<PaginatedArticlesResult>} The paginated result.
|
) => Promise<TypedResult<PaginatedArticlesResult>> = wrap(
|
||||||
*/
|
async (
|
||||||
export const getArticlesPaginated = async (
|
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
pageSize: number = 10
|
pageSize: number = 10
|
||||||
): Promise<PaginatedArticlesResult> => {
|
): Promise<PaginatedArticlesResult> => {
|
||||||
@@ -104,17 +98,14 @@ export const getArticlesPaginated = async (
|
|||||||
pageSize,
|
pageSize,
|
||||||
totalPages: Math.ceil(total / pageSize),
|
totalPages: Math.ceil(total / pageSize),
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/** Saves a new article to the database. */
|
||||||
* Saves a new article to the database.
|
export const saveArticle: (
|
||||||
* @param article - The article data to save.
|
|
||||||
* @returns {Promise<ArticleModel>} The saved article model.
|
|
||||||
* @throws {Error} If an article with the same slug already exists.
|
|
||||||
*/
|
|
||||||
export const saveArticle = async (
|
|
||||||
article: CreateArticleModel
|
article: CreateArticleModel
|
||||||
): Promise<ArticleModel> => {
|
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||||
|
async (article: CreateArticleModel): Promise<ArticleModel> => {
|
||||||
const articleRepository = await getRepository(ArticleEntity);
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
if (!article.authorId) {
|
if (!article.authorId) {
|
||||||
@@ -127,26 +118,25 @@ export const saveArticle = async (
|
|||||||
|
|
||||||
const newArticle = articleRepository.create(article);
|
const newArticle = articleRepository.create(article);
|
||||||
return articleEntityToModel(await articleRepository.save(newArticle));
|
return articleEntityToModel(await articleRepository.save(newArticle));
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/** Updates an existing article in the database. */
|
||||||
* Updates an existing article in the database.
|
export const updateArticleByExternalId: (
|
||||||
* @param articleId - The ID of the article to update.
|
externalId: string,
|
||||||
* @param article - The new article data.
|
article: UpdateArticleModel
|
||||||
* @returns {Promise<ArticleModel>} The updated article model.
|
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||||
* @throws {Error} If the article with the given ID does not exist.
|
async (
|
||||||
*/
|
externalId: string,
|
||||||
export const updateArticle = async (
|
|
||||||
articleId: string,
|
|
||||||
article: UpdateArticleModel
|
article: UpdateArticleModel
|
||||||
): Promise<ArticleModel> => {
|
): Promise<ArticleModel> => {
|
||||||
const articleRepository = await getRepository(ArticleEntity);
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
const existingArticle = await articleRepository.findOneBy({
|
const existingArticle = await articleRepository.findOneBy({
|
||||||
id: articleId,
|
externalId: externalId,
|
||||||
});
|
});
|
||||||
if (!existingArticle) {
|
if (!existingArticle) {
|
||||||
throw new Error(`Article with ID ${articleId} not found`);
|
throw new Error(`Article with ID ${externalId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!!article.title) existingArticle.title = article.title;
|
if (!!article.title) existingArticle.title = article.title;
|
||||||
@@ -157,23 +147,26 @@ export const updateArticle = async (
|
|||||||
existingArticle.coverImageUrl = article.coverImageUrl;
|
existingArticle.coverImageUrl = article.coverImageUrl;
|
||||||
if (!!article.content) existingArticle.content = article.content;
|
if (!!article.content) existingArticle.content = article.content;
|
||||||
|
|
||||||
return articleEntityToModel(await articleRepository.save(existingArticle));
|
return articleEntityToModel(
|
||||||
};
|
await articleRepository.save(existingArticle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/** Deletes an article from the database. */
|
||||||
* Deletes an article from the database.
|
export const deleteArticleByExternalId: (
|
||||||
* @param articleId - The ID of the article to delete.
|
externalId: UUIDv4
|
||||||
* @throws {Error} If the article with the given ID does not exist.
|
) => Promise<TypedResult<void>> = wrap(
|
||||||
*/
|
async (externalId: UUIDv4): Promise<void> => {
|
||||||
export const deleteArticle = async (articleId: string): Promise<void> => {
|
|
||||||
const articleRepository = await getRepository(ArticleEntity);
|
const articleRepository = await getRepository(ArticleEntity);
|
||||||
|
|
||||||
const existingArticle = await articleRepository.findOneBy({
|
const existingArticle = await articleRepository.findOneBy({
|
||||||
id: articleId,
|
externalId: externalId,
|
||||||
});
|
});
|
||||||
if (!existingArticle) {
|
if (!existingArticle) {
|
||||||
throw new Error(`Article with ID ${articleId} not found`);
|
throw new Error(`Article with ExternalID ${externalId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await articleRepository.remove(existingArticle);
|
await articleRepository.remove(existingArticle);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,11 +2,16 @@
|
|||||||
|
|
||||||
import { getRepository } from '@/lib/db/client';
|
import { getRepository } from '@/lib/db/client';
|
||||||
import { UserEntity } from '@/lib/db/entities';
|
import { UserEntity } from '@/lib/db/entities';
|
||||||
|
import { UserModel } from '@/lib/feature/user/user.model';
|
||||||
import { userEntityToModel } from '@/lib/feature/user/user.service';
|
import { userEntityToModel } from '@/lib/feature/user/user.service';
|
||||||
import { getSessionData } from '@/lib/session/session-storage';
|
import { getSessionData } from '@/lib/session/session-storage';
|
||||||
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
import { UUIDv4 } from '@/utils/types/uuid';
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
|
||||||
export const getUserByExternalId = async (externalId: UUIDv4) => {
|
export const getUserByExternalId: (
|
||||||
|
externalId: UUIDv4
|
||||||
|
) => Promise<TypedResult<UserModel | null>> = wrap(
|
||||||
|
async (externalId: UUIDv4): Promise<UserModel | null> => {
|
||||||
const sessionData = await getSessionData();
|
const sessionData = await getSessionData();
|
||||||
if (
|
if (
|
||||||
!sessionData ||
|
!sessionData ||
|
||||||
@@ -27,4 +32,5 @@ export const getUserByExternalId = async (externalId: UUIDv4) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return userEntityToModel(userEntity);
|
return userEntityToModel(userEntity);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
UpdateUserModel,
|
UpdateUserModel,
|
||||||
UserModel,
|
UserModel,
|
||||||
} from '@/lib/feature/user/user.model';
|
} from '@/lib/feature/user/user.model';
|
||||||
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
|
|
||||||
export const userEntityToModel = (userEntity: UserEntity): UserModel => {
|
export const userEntityToModel = (userEntity: UserEntity): UserModel => {
|
||||||
return {
|
return {
|
||||||
@@ -20,11 +21,10 @@ export const userEntityToModel = (userEntity: UserEntity): UserModel => {
|
|||||||
/**
|
/**
|
||||||
* Retrieves a user by their email address.
|
* Retrieves a user by their email address.
|
||||||
* @param email - The email address of the user to retrieve.
|
* @param email - The email address of the user to retrieve.
|
||||||
* @returns {Promise<UserModel | null>} The user model if found, otherwise null.
|
* @returns {Promise<TypedResult<UserModel | null>>} The user model if found, otherwise null.
|
||||||
*/
|
*/
|
||||||
export const getUserByEmail = async (
|
export const getUserByEmail = wrap(
|
||||||
email: string
|
async (email: string): Promise<UserModel | null> => {
|
||||||
): Promise<UserModel | null> => {
|
|
||||||
const userRepository = await getRepository(UserEntity);
|
const userRepository = await getRepository(UserEntity);
|
||||||
|
|
||||||
const userEntity = await userRepository.findOneBy({ email });
|
const userEntity = await userRepository.findOneBy({ email });
|
||||||
@@ -34,15 +34,16 @@ export const getUserByEmail = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return userEntityToModel(userEntity);
|
return userEntityToModel(userEntity);
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves a new user to the database.
|
* Saves a new user to the database.
|
||||||
* @param user - The user data to save.
|
* @param user - The user data to save.
|
||||||
* @returns {Promise<UserModel>} The saved user model.
|
* @returns {Promise<TypedResult<UserModel>>} The saved user model.
|
||||||
* @throws {Error} If a user with the same email already exists.
|
|
||||||
*/
|
*/
|
||||||
export const saveUser = async (user: CreateUserModel): Promise<UserModel> => {
|
export const saveUser = wrap(
|
||||||
|
async (user: CreateUserModel): Promise<UserModel> => {
|
||||||
const userRepository = await getRepository(UserEntity);
|
const userRepository = await getRepository(UserEntity);
|
||||||
|
|
||||||
if (!!(await userRepository.findOneBy({ email: user.email }))) {
|
if (!!(await userRepository.findOneBy({ email: user.email }))) {
|
||||||
@@ -51,19 +52,17 @@ export const saveUser = async (user: CreateUserModel): Promise<UserModel> => {
|
|||||||
|
|
||||||
const newUser = userRepository.create(user);
|
const newUser = userRepository.create(user);
|
||||||
return userEntityToModel(await userRepository.save(newUser));
|
return userEntityToModel(await userRepository.save(newUser));
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing user in the database.
|
* Updates an existing user in the database.
|
||||||
* @param userId - The ID of the user to update.
|
* @param userId - The ID of the user to update.
|
||||||
* @param user - The new user data.
|
* @param user - The new user data.
|
||||||
* @returns {Promise<UserModel>} The updated user model.
|
* @returns {Promise<TypedResult<UserModel>>} The updated user model.
|
||||||
* @throws {Error} If the user with the given ID does not exist.
|
|
||||||
*/
|
*/
|
||||||
export const updateUser = async (
|
export const updateUser = wrap(
|
||||||
userId: string,
|
async (userId: string, user: UpdateUserModel): Promise<UserModel> => {
|
||||||
user: UpdateUserModel
|
|
||||||
): Promise<UserModel> => {
|
|
||||||
const userRepository = await getRepository(UserEntity);
|
const userRepository = await getRepository(UserEntity);
|
||||||
|
|
||||||
const existingUser = await userRepository.findOneBy({ id: userId });
|
const existingUser = await userRepository.findOneBy({ id: userId });
|
||||||
@@ -76,35 +75,47 @@ export const updateUser = async (
|
|||||||
if (!!user.role) existingUser.role = user.role;
|
if (!!user.role) existingUser.role = user.role;
|
||||||
|
|
||||||
return userEntityToModel(await userRepository.save(existingUser));
|
return userEntityToModel(await userRepository.save(existingUser));
|
||||||
};
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synchronizes a user with the database.
|
* Synchronizes a user with the database.
|
||||||
* If the user already exists, it skips saving and returns the existing user.
|
* If the user already exists, updates it; if not, creates a new record.
|
||||||
* If the user does not exist, it creates a new user record.
|
|
||||||
* @returns {Promise<UserModel>} The synchronized user model.
|
|
||||||
* @throws {Error} If the user email is not provided or if there is an issue
|
|
||||||
* saving the user.
|
|
||||||
* @param sessionClaims Session Claims from the Auth Provider
|
* @param sessionClaims Session Claims from the Auth Provider
|
||||||
|
* @returns {Promise<TypedResult<UserModel>>} The synchronized user model.
|
||||||
*/
|
*/
|
||||||
export const syncUser = async (
|
export const syncUser = wrap(
|
||||||
sessionClaims: SessionClaims
|
async (sessionClaims: SessionClaims): Promise<UserModel> => {
|
||||||
): Promise<UserModel> => {
|
|
||||||
const { full_name, email } = sessionClaims.user;
|
const { full_name, email } = sessionClaims.user;
|
||||||
const role = sessionClaims.user.public_metadata.role;
|
const role = sessionClaims.user.public_metadata.role;
|
||||||
|
|
||||||
const existingUser = await getUserByEmail(email);
|
const existingUserResult = await getUserByEmail(email);
|
||||||
if (!existingUser) {
|
if (!existingUserResult.ok || !existingUserResult.value) {
|
||||||
return await saveUser({
|
const saveResult = await saveUser({
|
||||||
name: full_name,
|
name: full_name,
|
||||||
email: email,
|
email: email,
|
||||||
role: role,
|
role: role,
|
||||||
});
|
});
|
||||||
|
if (!saveResult.ok) {
|
||||||
|
throw new Error(`User with email ${email} already exists`);
|
||||||
}
|
}
|
||||||
|
return saveResult.value;
|
||||||
|
}
|
||||||
|
const existingUser = existingUserResult.value;
|
||||||
|
|
||||||
return await updateUser(existingUser.id, {
|
const updateResult = await updateUser(existingUser.id, {
|
||||||
name: full_name,
|
name: full_name,
|
||||||
email: existingUser.email,
|
email: existingUser.email,
|
||||||
role: role,
|
role: role,
|
||||||
});
|
});
|
||||||
};
|
if (!updateResult.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to update user with email ${email}: ${updateResult.error}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return updateResult.value;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Explicit re-export for TypeScript consumers who need the result type
|
||||||
|
export type { TypedResult };
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { TypedResult, wrap } from '@/utils/types/results';
|
|||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
HeadObjectCommand,
|
HeadObjectCommand,
|
||||||
ObjectCannedACL,
|
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
} from '@aws-sdk/client-s3';
|
} from '@aws-sdk/client-s3';
|
||||||
@@ -19,6 +18,7 @@ export const S3StorageConfig = z.object({
|
|||||||
region: z.string(),
|
region: z.string(),
|
||||||
accessKey: z.string(),
|
accessKey: z.string(),
|
||||||
secretKey: z.string(),
|
secretKey: z.string(),
|
||||||
|
publicUrl: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
|
export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ export type S3StorageConfig = z.infer<typeof S3StorageConfig>;
|
|||||||
*/
|
*/
|
||||||
export class S3StorageAdapter implements StorageProvider {
|
export class S3StorageAdapter implements StorageProvider {
|
||||||
private readonly s3Client: S3Client;
|
private readonly s3Client: S3Client;
|
||||||
private readonly endpoint: string;
|
private readonly publicUrl: string;
|
||||||
private readonly bucketName: string;
|
private readonly bucketName: string;
|
||||||
|
|
||||||
readonly get: (
|
readonly get: (
|
||||||
@@ -45,8 +45,9 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
) => Promise<TypedResult<void>>;
|
) => Promise<TypedResult<void>>;
|
||||||
|
|
||||||
constructor(config: S3StorageConfig, s3Client?: S3Client) {
|
constructor(config: S3StorageConfig, s3Client?: S3Client) {
|
||||||
this.endpoint = config.endpoint;
|
|
||||||
this.bucketName = config.bucket;
|
this.bucketName = config.bucket;
|
||||||
|
this.publicUrl = config.publicUrl || config.endpoint;
|
||||||
|
|
||||||
this.s3Client =
|
this.s3Client =
|
||||||
s3Client ||
|
s3Client ||
|
||||||
new S3Client({
|
new S3Client({
|
||||||
@@ -57,6 +58,8 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
accessKeyId: config.accessKey,
|
accessKeyId: config.accessKey,
|
||||||
secretAccessKey: config.secretKey,
|
secretAccessKey: config.secretKey,
|
||||||
},
|
},
|
||||||
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
||||||
|
responseChecksumValidation: 'WHEN_REQUIRED',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.get = wrap(this._get.bind(this));
|
this.get = wrap(this._get.bind(this));
|
||||||
@@ -66,7 +69,7 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async _get(key: string): Promise<string> {
|
private async _get(key: string): Promise<string> {
|
||||||
return `${this.endpoint}/${this.bucketName}/${key}`;
|
return `${this.publicUrl}/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _put(key: string, contentType: string): Promise<string> {
|
private async _put(key: string, contentType: string): Promise<string> {
|
||||||
@@ -74,7 +77,6 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
Bucket: this.bucketName,
|
Bucket: this.bucketName,
|
||||||
Key: key,
|
Key: key,
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
ACL: ObjectCannedACL.public_read,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
|
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
|
||||||
@@ -111,6 +113,7 @@ export const new_s3_storage_adapter = (): S3StorageAdapter => {
|
|||||||
region: process.env.S3_REGION,
|
region: process.env.S3_REGION,
|
||||||
accessKey: process.env.S3_ACCESS_KEY,
|
accessKey: process.env.S3_ACCESS_KEY,
|
||||||
secretKey: process.env.S3_SECRET_KEY,
|
secretKey: process.env.S3_SECRET_KEY,
|
||||||
|
publicUrl: process.env.S3_PUBLIC_URL,
|
||||||
});
|
});
|
||||||
return new S3StorageAdapter(config);
|
return new S3StorageAdapter(config);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
|
import { getSessionData } from '@/lib/session/session-storage';
|
||||||
import { createStorageProvider } from '@/lib/storage/storage.factory';
|
import { createStorageProvider } from '@/lib/storage/storage.factory';
|
||||||
import { StorageProvider } from '@/lib/storage/storage.interface';
|
import { StorageProvider } from '@/lib/storage/storage.interface';
|
||||||
import { TypedResult } from '@/utils/types/results';
|
import { TypedResult } from '@/utils/types/results';
|
||||||
|
|
||||||
const storage: StorageProvider = createStorageProvider();
|
const storage: StorageProvider = createStorageProvider();
|
||||||
|
|
||||||
export const getSignedUrl = async (
|
export const getPublicUrl = async (
|
||||||
key: string,
|
key: string,
|
||||||
storageProvider?: StorageProvider
|
storageProvider?: StorageProvider
|
||||||
): Promise<TypedResult<string>> => {
|
): Promise<TypedResult<string>> => {
|
||||||
@@ -23,6 +24,10 @@ export const checkExists = async (
|
|||||||
if (!storageProvider) {
|
if (!storageProvider) {
|
||||||
storageProvider = storage;
|
storageProvider = storage;
|
||||||
}
|
}
|
||||||
|
const session = await getSessionData();
|
||||||
|
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||||
|
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||||
|
}
|
||||||
return await storageProvider.exists(key);
|
return await storageProvider.exists(key);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,6 +39,10 @@ export const getPutUrl = async (
|
|||||||
if (!storageProvider) {
|
if (!storageProvider) {
|
||||||
storageProvider = storage;
|
storageProvider = storage;
|
||||||
}
|
}
|
||||||
|
const session = await getSessionData();
|
||||||
|
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||||
|
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||||
|
}
|
||||||
return await storageProvider.put(key, contentType);
|
return await storageProvider.put(key, contentType);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,5 +53,9 @@ export const deleteByKey = async (
|
|||||||
if (!storageProvider) {
|
if (!storageProvider) {
|
||||||
storageProvider = storage;
|
storageProvider = storage;
|
||||||
}
|
}
|
||||||
|
const session = await getSessionData();
|
||||||
|
if (!session || !session?.user || session?.user.role !== 'admin') {
|
||||||
|
throw new Error('Unauthorized: Only admin users can delete articles.');
|
||||||
|
}
|
||||||
return await storageProvider.delete(key);
|
return await storageProvider.delete(key);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const uploadFile = wrap(async (file: File) => {
|
|||||||
|
|
||||||
const existsResult = await storage.checkExists(fileKey);
|
const existsResult = await storage.checkExists(fileKey);
|
||||||
if (existsResult) {
|
if (existsResult) {
|
||||||
const presignedUrl = await storage.getSignedUrl(fileKey);
|
const presignedUrl = await storage.getPublicUrl(fileKey);
|
||||||
if (!presignedUrl.ok) {
|
if (!presignedUrl.ok) {
|
||||||
throw new Error('Failed to retrieve file URL');
|
throw new Error('Failed to retrieve file URL');
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ export const uploadFile = wrap(async (file: File) => {
|
|||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
throw new Error('File upload failed');
|
throw new Error('File upload failed');
|
||||||
}
|
}
|
||||||
|
console.log(result.value);
|
||||||
|
|
||||||
const response = await fetch(result.value, {
|
const response = await fetch(result.value, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -48,7 +49,7 @@ export const uploadFile = wrap(async (file: File) => {
|
|||||||
throw new Error('Failed to upload file');
|
throw new Error('Failed to upload file');
|
||||||
}
|
}
|
||||||
|
|
||||||
const presignedUrl = await storage.getSignedUrl(fileKey);
|
const presignedUrl = await storage.getPublicUrl(fileKey);
|
||||||
if (!presignedUrl.ok) {
|
if (!presignedUrl.ok) {
|
||||||
throw new Error('Failed to retrieve file URL');
|
throw new Error('Failed to retrieve file URL');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { NextResponse } from 'next/server';
|
|||||||
const isPublic = createRouteMatcher([
|
const isPublic = createRouteMatcher([
|
||||||
'/home(.*)?',
|
'/home(.*)?',
|
||||||
'/about(.*)?',
|
'/about(.*)?',
|
||||||
|
'/article(.*)?',
|
||||||
'/api/user(.*)?',
|
'/api/user(.*)?',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
72
src/ui/components/internal/article/admin-article-card.tsx
Normal file
72
src/ui/components/internal/article/admin-article-card.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { ArticleModel } from '@/lib/feature/article/article.model';
|
||||||
|
import { DeleteArticleButton } from '@/ui/components/internal/article/delete-article-button';
|
||||||
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import { CalendarIcon, PencilIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface AdminArticleCardProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (date: Date) =>
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
|
||||||
|
export const AdminArticleCard = ({ article }: AdminArticleCardProps) => {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card text-card-foreground'>
|
||||||
|
<div className='relative aspect-video w-full overflow-hidden bg-muted'>
|
||||||
|
{article.coverImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={article.coverImageUrl}
|
||||||
|
alt={article.title}
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-full items-center justify-center bg-gradient-to-br from-muted to-muted/60'>
|
||||||
|
<span className='font-mono text-5xl font-bold text-muted-foreground/20'>
|
||||||
|
{'{ }'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-1 flex-col gap-3 p-5'>
|
||||||
|
<div className='flex items-center gap-1.5 text-xs text-muted-foreground'>
|
||||||
|
<CalendarIcon className='size-3 shrink-0' />
|
||||||
|
<time dateTime={article.createdAt.toISOString()}>
|
||||||
|
{formatDate(article.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/article/${article.slug}`}
|
||||||
|
className='line-clamp-2 text-base font-bold leading-snug tracking-tight hover:text-primary hover:underline underline-offset-4'
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p className='line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground'>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='mt-2 flex items-center gap-2'>
|
||||||
|
<Button asChild variant='outline' size='sm'>
|
||||||
|
<Link href={`/admin/article/${article.externalId}`}>
|
||||||
|
<PencilIcon className='size-4' />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<DeleteArticleButton
|
||||||
|
externalID={article.externalId}
|
||||||
|
title={article.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/ui/components/shadcn/table';
|
||||||
|
|
||||||
|
const AdminArticleRowSkeleton = () => (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div className='size-12 animate-pulse rounded-md bg-muted' />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<div className='h-4 w-40 animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-3 w-3/4 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='h-3 w-20 animate-pulse rounded bg-muted' />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex justify-end gap-2'>
|
||||||
|
<div className='h-7 w-14 animate-pulse rounded-lg bg-muted' />
|
||||||
|
<div className='h-7 w-16 animate-pulse rounded-lg bg-muted' />
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const AdminArticleListSkeleton = ({
|
||||||
|
skeletonSize,
|
||||||
|
}: {
|
||||||
|
skeletonSize: number;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<div className='mb-4 h-4 w-32 animate-pulse rounded bg-muted' />
|
||||||
|
<div className='rounded-lg border border-border'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className='hover:bg-transparent'>
|
||||||
|
<TableHead className='w-16'>Cover</TableHead>
|
||||||
|
<TableHead className='w-56'>Title</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className='w-32'>Published</TableHead>
|
||||||
|
<TableHead className='w-32 text-right'>
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{Array.from({ length: skeletonSize }).map((_, i) => (
|
||||||
|
<AdminArticleRowSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
172
src/ui/components/internal/article/admin-article-list.tsx
Normal file
172
src/ui/components/internal/article/admin-article-list.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||||
|
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||||
|
import { DeleteArticleButton } from '@/ui/components/internal/article/delete-article-button';
|
||||||
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/ui/components/shadcn/table';
|
||||||
|
import { FileTextIcon, PencilIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type AdminArticleListProps = {
|
||||||
|
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
||||||
|
defaultPageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date: Date) =>
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
|
||||||
|
export const AdminArticleList = async ({
|
||||||
|
searchParams,
|
||||||
|
defaultPageSize,
|
||||||
|
}: AdminArticleListProps) => {
|
||||||
|
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
|
||||||
|
const page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
const pageSize = Number(pageSizeParam) || defaultPageSize;
|
||||||
|
|
||||||
|
const paginationResult = await getArticlesPaginated(page, pageSize);
|
||||||
|
|
||||||
|
if (!paginationResult.ok) {
|
||||||
|
return (
|
||||||
|
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
||||||
|
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
Failed to load articles. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: articles,
|
||||||
|
totalPages,
|
||||||
|
total,
|
||||||
|
page: currentPage,
|
||||||
|
} = paginationResult.value;
|
||||||
|
|
||||||
|
if (articles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
||||||
|
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
||||||
|
<p className='text-muted-foreground'>No articles yet.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className='mb-4 text-sm text-muted-foreground'>
|
||||||
|
{total} article{total === 1 ? '' : 's'} published
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className='rounded-lg border border-border'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className='hover:bg-transparent'>
|
||||||
|
<TableHead className='w-16'>Cover</TableHead>
|
||||||
|
<TableHead className='w-56'>Title</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className='w-32'>Published</TableHead>
|
||||||
|
<TableHead className='w-32 text-right'>
|
||||||
|
Actions
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{articles.map((article) => (
|
||||||
|
<TableRow key={article.externalId}>
|
||||||
|
<TableCell>
|
||||||
|
<div className='size-12 shrink-0 overflow-hidden rounded-md bg-muted'>
|
||||||
|
{article.coverImageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={article.coverImageUrl}
|
||||||
|
alt=''
|
||||||
|
className='h-full w-full object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-full items-center justify-center'>
|
||||||
|
<span className='font-mono text-xs font-bold text-muted-foreground/30'>
|
||||||
|
{'{}'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className='whitespace-normal'>
|
||||||
|
<Link
|
||||||
|
href={`/article/${article.slug}`}
|
||||||
|
className='font-medium leading-snug hover:text-primary hover:underline underline-offset-4'
|
||||||
|
>
|
||||||
|
{article.title}
|
||||||
|
</Link>
|
||||||
|
<p className='mt-0.5 text-xs text-muted-foreground'>
|
||||||
|
/{article.slug}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className='whitespace-normal'>
|
||||||
|
<p className='line-clamp-2 text-sm text-muted-foreground'>
|
||||||
|
{article.description}
|
||||||
|
</p>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className='text-sm text-muted-foreground'>
|
||||||
|
<time
|
||||||
|
dateTime={article.createdAt.toISOString()}
|
||||||
|
>
|
||||||
|
{formatDate(article.createdAt)}
|
||||||
|
</time>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/admin/article/${article.externalId}`}
|
||||||
|
>
|
||||||
|
<PencilIcon className='size-3.5' />
|
||||||
|
Edit
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
<DeleteArticleButton
|
||||||
|
externalID={article.externalId}
|
||||||
|
title={article.title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className='mt-6 flex items-center justify-between gap-4'>
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
Page {currentPage} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<ArticleListPagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
baseUrl='/admin'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
src/ui/components/internal/article/article-list-skeleton.tsx
Normal file
33
src/ui/components/internal/article/article-list-skeleton.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
export const ArticleCardSkeleton = () => (
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-xl border border-border bg-card'>
|
||||||
|
<div className='aspect-video w-full animate-pulse bg-muted' />
|
||||||
|
<div className='flex flex-1 flex-col gap-3 p-5'>
|
||||||
|
<div className='h-3 w-24 animate-pulse rounded bg-muted' />
|
||||||
|
<div className='space-y-1.5'>
|
||||||
|
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 space-y-1.5'>
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
||||||
|
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
<div className='mt-1 h-3 w-16 animate-pulse rounded bg-muted' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArticleListSkeleton = ({
|
||||||
|
skeletonSize,
|
||||||
|
}: {
|
||||||
|
skeletonSize: number;
|
||||||
|
}) => (
|
||||||
|
<>
|
||||||
|
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
|
||||||
|
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
{Array.from({ length: skeletonSize }).map((_, i) => (
|
||||||
|
<ArticleCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
69
src/ui/components/internal/article/article-list.tsx
Normal file
69
src/ui/components/internal/article/article-list.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||||
|
import { ArticleCard } from '@/ui/components/internal/article-card';
|
||||||
|
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||||
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import { cacheTag } from 'next/cache';
|
||||||
|
|
||||||
|
type ArticleListProps = {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ArticleList = async ({ page, pageSize }: ArticleListProps) => {
|
||||||
|
'use cache';
|
||||||
|
|
||||||
|
cacheTag('articles', `articles:page:${page}-${pageSize}`);
|
||||||
|
const paginationResult = await getArticlesPaginated(page, pageSize);
|
||||||
|
|
||||||
|
if (!paginationResult.ok) {
|
||||||
|
return (
|
||||||
|
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
||||||
|
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
Failed to load articles. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { data: articles, totalPages, total } = paginationResult.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className='mb-10 text-muted-foreground'>
|
||||||
|
{total === 0
|
||||||
|
? 'No articles published yet.'
|
||||||
|
: `${total} article${total === 1 ? '' : 's'} published`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{articles.length === 0 ? (
|
||||||
|
<div className='flex min-h-64 flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-border text-center'>
|
||||||
|
<FileTextIcon className='size-10 text-muted-foreground/40' />
|
||||||
|
<p className='text-muted-foreground'>
|
||||||
|
No articles yet. Check back soon!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
|
||||||
|
{articles.map((article) => (
|
||||||
|
<ArticleCard
|
||||||
|
key={article.externalId}
|
||||||
|
article={article}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className='mt-12 flex items-center justify-between gap-4'>
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
Page {page} of {totalPages}
|
||||||
|
</p>
|
||||||
|
<ArticleListPagination
|
||||||
|
currentPage={page}
|
||||||
|
totalPages={totalPages}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
src/ui/components/internal/article/delete-article-button.tsx
Normal file
85
src/ui/components/internal/article/delete-article-button.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { deleteArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/ui/components/shadcn/alert-dialog';
|
||||||
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
|
import { Trash2Icon } from 'lucide-react';
|
||||||
|
import { useTransition } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface DeleteArticleButtonProps {
|
||||||
|
externalID: UUIDv4;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DeleteArticleButton = ({
|
||||||
|
externalID,
|
||||||
|
title,
|
||||||
|
}: DeleteArticleButtonProps) => {
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
const result = await deleteArticleByExternalId(externalID);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error('Failed to delete article', {
|
||||||
|
description: result.error.message,
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Article deleted', {
|
||||||
|
description: `"${title}" has been removed.`,
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant='destructive' size='sm' disabled={isPending}>
|
||||||
|
<Trash2Icon className='size-4' />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete article?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will permanently delete “{title}
|
||||||
|
”. This action cannot be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel asChild>
|
||||||
|
<Button variant='outline' size='sm'>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction asChild>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
{isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -101,23 +101,20 @@ export const CreateArticleForm = () => {
|
|||||||
|
|
||||||
const handleFormSubmit = useCallback(
|
const handleFormSubmit = useCallback(
|
||||||
async (data: z.infer<typeof formSchema>) => {
|
async (data: z.infer<typeof formSchema>) => {
|
||||||
try {
|
|
||||||
const result = await saveArticle({ ...data });
|
const result = await saveArticle({ ...data });
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error('Failed to create article', {
|
||||||
|
description: result.error.message,
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
toast.success('Article created successfully!', {
|
toast.success('Article created successfully!', {
|
||||||
description: `Article "${result.title}" has been created.`,
|
description: `Article "${result.value.title}" has been created.`,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
resetFiles();
|
resetFiles();
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to create article', {
|
|
||||||
description:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'An error occurred',
|
|
||||||
position: 'bottom-right',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[form, resetFiles]
|
[form, resetFiles]
|
||||||
);
|
);
|
||||||
|
|||||||
298
src/ui/components/internal/update-article-form.tsx
Normal file
298
src/ui/components/internal/update-article-form.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { updateArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||||
|
import { ArticleModel } from '@/lib/feature/article/article.model';
|
||||||
|
import { uploadFile } from '@/lib/storage/storage.utils';
|
||||||
|
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
|
||||||
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldError,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
} from '@/ui/components/shadcn/field';
|
||||||
|
import { Input } from '@/ui/components/shadcn/input';
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupTextarea,
|
||||||
|
} from '@/ui/components/shadcn/input-group';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
|
import slugify from 'slugify';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import ImageLogo from '~/public/img/icons/cover-image.svg';
|
||||||
|
import MarkdownLogo from '~/public/img/icons/markdown-content.svg';
|
||||||
|
|
||||||
|
function isImageFile(file: File): boolean {
|
||||||
|
return file.type.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentFile(file: File): boolean {
|
||||||
|
const extension = file.name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
return (
|
||||||
|
file.type === 'text/markdown' ||
|
||||||
|
file.type === 'text/plain' ||
|
||||||
|
extension === 'md' ||
|
||||||
|
extension === 'markdown' ||
|
||||||
|
extension === 'txt'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImageFile(file: File): string | null {
|
||||||
|
return isImageFile(file)
|
||||||
|
? null
|
||||||
|
: 'Only image files are allowed for cover image';
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateContentFile(file: File): string | null {
|
||||||
|
return isContentFile(file)
|
||||||
|
? null
|
||||||
|
: 'Only markdown or text files are allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateArticleFormProps {
|
||||||
|
article: ArticleModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => {
|
||||||
|
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
|
||||||
|
const [coverImageUploading, setCoverImageUploading] = useState(false);
|
||||||
|
const [contentFile, setContentFile] = useState<File | null>(null);
|
||||||
|
const coverImageUrlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
title: z.string().min(3).max(255),
|
||||||
|
slug: z.string().min(3),
|
||||||
|
description: z.string().min(10),
|
||||||
|
coverImageUrl: z.url('Cover image URL must be a valid URL'),
|
||||||
|
content: z
|
||||||
|
.string()
|
||||||
|
.min(10, 'Article content must have at least 10 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
title: article.title,
|
||||||
|
slug: article.slug,
|
||||||
|
description: article.description,
|
||||||
|
coverImageUrl: article.coverImageUrl,
|
||||||
|
content: article.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const title = useWatch({ control: form.control, name: 'title' });
|
||||||
|
const coverImageUrl = useWatch({
|
||||||
|
control: form.control,
|
||||||
|
name: 'coverImageUrl',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!title) return;
|
||||||
|
form.setValue('slug', slugify(title).toLowerCase());
|
||||||
|
}, [form, title]);
|
||||||
|
|
||||||
|
const handleFormSubmit = useCallback(
|
||||||
|
async (data: z.infer<typeof formSchema>) => {
|
||||||
|
const result = await updateArticleByExternalId(
|
||||||
|
article.externalId,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error('Failed to update article', {
|
||||||
|
description: result.error.message,
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success('Article updated', {
|
||||||
|
description: `"${result.value.title}" has been saved.`,
|
||||||
|
position: 'bottom-right',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[article.externalId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCoverImageFileChange = useCallback(
|
||||||
|
async (file: File | null) => {
|
||||||
|
if (coverImageUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(coverImageUrlRef.current);
|
||||||
|
coverImageUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setCoverImageFile(file);
|
||||||
|
if (!file) {
|
||||||
|
setCoverImageUploading(false);
|
||||||
|
form.setValue('coverImageUrl', article.coverImageUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCoverImageUploading(true);
|
||||||
|
const fileMetadataResult = await uploadFile(file);
|
||||||
|
setCoverImageUploading(false);
|
||||||
|
if (!fileMetadataResult.ok) {
|
||||||
|
setCoverImageFile(null);
|
||||||
|
form.setValue('coverImageUrl', article.coverImageUrl);
|
||||||
|
toast((fileMetadataResult.error as Error).message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileMetadata = fileMetadataResult.value;
|
||||||
|
coverImageUrlRef.current = fileMetadata.signedUrl;
|
||||||
|
form.setValue('coverImageUrl', fileMetadata.signedUrl);
|
||||||
|
},
|
||||||
|
[form, article.coverImageUrl]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContentFileChange = useCallback(
|
||||||
|
async (file: File | null) => {
|
||||||
|
setContentFile(file);
|
||||||
|
if (file) {
|
||||||
|
const content = await file.text();
|
||||||
|
form.setValue('content', content);
|
||||||
|
} else {
|
||||||
|
form.setValue('content', article.content);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[form, article.content]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCoverImageReject = useCallback(
|
||||||
|
(_file: File, message: string) => {
|
||||||
|
toast.error(`Cover image rejected: ${message}`);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleContentFileReject = useCallback(
|
||||||
|
(_file: File, message: string) => {
|
||||||
|
toast.error(`Content file rejected: ${message}`);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
id='form-update-article'
|
||||||
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
|
>
|
||||||
|
<FieldGroup className='gap-7'>
|
||||||
|
<Controller
|
||||||
|
name='title'
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor='form-update-article-title'>
|
||||||
|
Title
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id='form-update-article-title'
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder='Article title'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
{fieldState.invalid && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name='slug'
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor='form-update-article-slug'>
|
||||||
|
Slug
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id='form-update-article-slug'
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
placeholder='article-slug'
|
||||||
|
autoComplete='off'
|
||||||
|
/>
|
||||||
|
{fieldState.invalid && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name='description'
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<Field data-invalid={fieldState.invalid}>
|
||||||
|
<FieldLabel htmlFor='form-update-article-description'>
|
||||||
|
Description
|
||||||
|
</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupTextarea
|
||||||
|
{...field}
|
||||||
|
id='form-update-article-description'
|
||||||
|
placeholder='A simple but nice description of the article here.'
|
||||||
|
rows={3}
|
||||||
|
className='min-h-24 resize-none'
|
||||||
|
aria-invalid={fieldState.invalid}
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
{fieldState.invalid && (
|
||||||
|
<FieldError errors={[fieldState.error]} />
|
||||||
|
)}
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||||
|
<FileUploadField
|
||||||
|
file={coverImageFile}
|
||||||
|
onFileChange={handleCoverImageFileChange}
|
||||||
|
accept='image/*'
|
||||||
|
validate={validateImageFile}
|
||||||
|
onFileReject={handleCoverImageReject}
|
||||||
|
label='Cover image'
|
||||||
|
description='PNG, JPG, GIF, WebP accepted'
|
||||||
|
error={form.formState.errors.coverImageUrl?.message}
|
||||||
|
previewUrl={
|
||||||
|
coverImageUrl || article.coverImageUrl || undefined
|
||||||
|
}
|
||||||
|
isUploading={coverImageUploading}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
src={ImageLogo}
|
||||||
|
alt=''
|
||||||
|
aria-hidden='true'
|
||||||
|
className='size-6 shrink-0 opacity-60'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FileUploadField
|
||||||
|
file={contentFile}
|
||||||
|
onFileChange={handleContentFileChange}
|
||||||
|
accept='.md,.markdown,.txt'
|
||||||
|
validate={validateContentFile}
|
||||||
|
onFileReject={handleContentFileReject}
|
||||||
|
label='Markdown content'
|
||||||
|
description='.md / .markdown / .txt accepted'
|
||||||
|
error={form.formState.errors.content?.message}
|
||||||
|
icon={
|
||||||
|
<Image
|
||||||
|
src={MarkdownLogo}
|
||||||
|
alt=''
|
||||||
|
aria-hidden='true'
|
||||||
|
className='size-6 shrink-0 opacity-60'
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FieldGroup>
|
||||||
|
<div className='flex w-full justify-end'>
|
||||||
|
<Button type='submit' className='mt-6'>
|
||||||
|
Update Article
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UpdateArticleForm;
|
||||||
163
src/ui/components/shadcn/alert-dialog.tsx
Normal file
163
src/ui/components/shadcn/alert-dialog.tsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||||
|
import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function AlertDialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||||
|
return <AlertDialogPrimitive.Root data-slot='alert-dialog' {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Trigger
|
||||||
|
data-slot='alert-dialog-trigger'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Portal
|
||||||
|
data-slot='alert-dialog-portal'
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
data-slot='alert-dialog-overlay'
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
data-slot='alert-dialog-content'
|
||||||
|
className={cn(
|
||||||
|
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AlertDialogPrimitive.Content>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogHeader({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='alert-dialog-header'
|
||||||
|
className={cn('flex flex-col gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogFooter({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'div'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='alert-dialog-footer'
|
||||||
|
className={cn('flex justify-end gap-2 pt-4', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
data-slot='alert-dialog-title'
|
||||||
|
className={cn('text-base font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
data-slot='alert-dialog-description'
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogAction({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
data-slot='alert-dialog-action'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDialogCancel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||||
|
return (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
data-slot='alert-dialog-cancel'
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
115
src/ui/components/shadcn/table.tsx
Normal file
115
src/ui/components/shadcn/table.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { cn } from '@/ui/components/shadcn/lib/utils';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<'table'>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot='table-container'
|
||||||
|
className='relative w-full overflow-x-auto'
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot='table'
|
||||||
|
className={cn('w-full caption-bottom text-sm', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot='table-header'
|
||||||
|
className={cn('[&_tr]:border-b', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot='table-body'
|
||||||
|
className={cn('[&_tr:last-child]:border-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot='table-footer'
|
||||||
|
className={cn(
|
||||||
|
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot='table-row'
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot='table-head'
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot='table-cell'
|
||||||
|
className={cn(
|
||||||
|
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'caption'>) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot='table-caption'
|
||||||
|
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
};
|
||||||
@@ -1,22 +1,8 @@
|
|||||||
|
import { unstable_cache } from 'next/cache';
|
||||||
|
|
||||||
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
export type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
|
||||||
export type TypedResult<T> = Result<T, Error>;
|
export type TypedResult<T> = Result<T, Error>;
|
||||||
|
|
||||||
export function wrapBlocking<
|
|
||||||
F extends (...args: never[]) => unknown,
|
|
||||||
E = unknown,
|
|
||||||
>(
|
|
||||||
fn: F,
|
|
||||||
mapError: (e: unknown) => E = (e) => e as E
|
|
||||||
): (...args: Parameters<F>) => Result<ReturnType<F>, E> {
|
|
||||||
return (...args) => {
|
|
||||||
try {
|
|
||||||
return { ok: true, value: fn(...args) as ReturnType<F> };
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, error: mapError(e) };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function wrap<
|
export function wrap<
|
||||||
F extends (...args: never[]) => Promise<unknown>,
|
F extends (...args: never[]) => Promise<unknown>,
|
||||||
E = unknown,
|
E = unknown,
|
||||||
@@ -35,3 +21,43 @@ export function wrap<
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wrapCached<
|
||||||
|
F extends (...args: never[]) => Promise<unknown>,
|
||||||
|
E = unknown,
|
||||||
|
>(
|
||||||
|
fn: F,
|
||||||
|
options: {
|
||||||
|
key: (...args: Parameters<F>) => string[];
|
||||||
|
tags?: (...args: Parameters<F>) => string[];
|
||||||
|
revalidate?: number;
|
||||||
|
},
|
||||||
|
mapError: (e: unknown) => E = (e) => e as E
|
||||||
|
): (...args: Parameters<F>) => Promise<Result<Awaited<ReturnType<F>>, E>> {
|
||||||
|
return async (...args: Parameters<F>) => {
|
||||||
|
try {
|
||||||
|
const cachedFn = unstable_cache(
|
||||||
|
async (...innerArgs: Parameters<F>) => {
|
||||||
|
return await fn(...innerArgs);
|
||||||
|
},
|
||||||
|
options.key(...args),
|
||||||
|
{
|
||||||
|
tags: options.tags?.(...args),
|
||||||
|
revalidate: options.revalidate,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = await cachedFn(...args);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
value: value as Awaited<ReturnType<F>>,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: mapError(e),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,22 +1,4 @@
|
|||||||
type HexChar =
|
import { z } from 'zod';
|
||||||
| '0'
|
|
||||||
| '1'
|
|
||||||
| '2'
|
|
||||||
| '3'
|
|
||||||
| '4'
|
|
||||||
| '5'
|
|
||||||
| '6'
|
|
||||||
| '7'
|
|
||||||
| '8'
|
|
||||||
| '9'
|
|
||||||
| 'a'
|
|
||||||
| 'b'
|
|
||||||
| 'c'
|
|
||||||
| 'd'
|
|
||||||
| 'e'
|
|
||||||
| 'f';
|
|
||||||
type UUIDv4Segment<Length extends number> =
|
|
||||||
`${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity
|
|
||||||
|
|
||||||
export type UUIDv4 =
|
export const UUIDv4 = z.uuid();
|
||||||
`${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`;
|
export type UUIDv4 = z.infer<typeof UUIDv4>;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
|
||||||
import {
|
import {
|
||||||
CreateArticleModel,
|
CreateArticleModel,
|
||||||
UpdateArticleModel,
|
UpdateArticleModel,
|
||||||
} from '@/lib/feature/article/article.model';
|
} from '@/lib/feature/article/article.model';
|
||||||
import {
|
import {
|
||||||
deleteArticle,
|
deleteArticleByExternalId,
|
||||||
getArticleByExternalId,
|
getArticleByExternalId,
|
||||||
getArticleBySlug,
|
getArticleBySlug,
|
||||||
getArticlesByAuthorId,
|
getArticlesByAuthorId,
|
||||||
|
getArticlesPaginated,
|
||||||
saveArticle,
|
saveArticle,
|
||||||
updateArticle,
|
updateArticleByExternalId,
|
||||||
} from '@/lib/feature/article/article.service';
|
} from '@/lib/feature/article/article.service';
|
||||||
import { CreateUserModel } from '@/lib/feature/user/user.model';
|
import { CreateUserModel } from '@/lib/feature/user/user.model';
|
||||||
import { saveUser } from '@/lib/feature/user/user.service';
|
import { saveUser } from '@/lib/feature/user/user.service';
|
||||||
@@ -34,7 +34,8 @@ describe('ArticleService', () => {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
};
|
};
|
||||||
const savedAuthor = await saveUser(author);
|
const savedAuthor = await saveUser(author);
|
||||||
authorId = savedAuthor.id;
|
if (!savedAuthor.ok) throw savedAuthor.error;
|
||||||
|
authorId = savedAuthor.value.id;
|
||||||
}, 1_000_000);
|
}, 1_000_000);
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -51,16 +52,17 @@ describe('ArticleService', () => {
|
|||||||
authorId: authorId,
|
authorId: authorId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const savedArticle = await saveArticle(articleToSave);
|
const result = await saveArticle(articleToSave);
|
||||||
|
|
||||||
expect(savedArticle.id).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(savedArticle.title).toBe(articleToSave.title);
|
if (!result.ok) return;
|
||||||
expect(savedArticle.slug).toBe(articleToSave.slug);
|
expect(result.value.title).toBe(articleToSave.title);
|
||||||
expect(savedArticle.description).toBe(articleToSave.description);
|
expect(result.value.slug).toBe(articleToSave.slug);
|
||||||
expect(savedArticle.coverImageUrl).toBe(articleToSave.coverImageUrl);
|
expect(result.value.description).toBe(articleToSave.description);
|
||||||
expect(savedArticle.content).toBe(articleToSave.content);
|
expect(result.value.coverImageUrl).toBe(articleToSave.coverImageUrl);
|
||||||
expect(savedArticle.authorId).toBe(authorId);
|
expect(result.value.content).toBe(articleToSave.content);
|
||||||
expect(savedArticle.externalId).toBeDefined();
|
expect(result.value.authorId).toBe(authorId);
|
||||||
|
expect(result.value.externalId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot save article with existing slug', async () => {
|
test('cannot save article with existing slug', async () => {
|
||||||
@@ -72,77 +74,98 @@ describe('ArticleService', () => {
|
|||||||
content: 'Duplicate content.',
|
content: 'Duplicate content.',
|
||||||
authorId: authorId,
|
authorId: authorId,
|
||||||
};
|
};
|
||||||
await expect(saveArticle(articleToSave)).rejects.toThrow(
|
|
||||||
`Article with slug ${articleToSave.slug} already exists`
|
const result = await saveArticle(articleToSave);
|
||||||
);
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.message).toContain(articleToSave.slug);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getArticleBySlug', async () => {
|
test('can getArticleBySlug', async () => {
|
||||||
const article = await getArticleBySlug('test-article');
|
const result = await getArticleBySlug('test-article');
|
||||||
|
|
||||||
expect(article).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(article?.slug).toBe('test-article');
|
if (!result.ok) return;
|
||||||
expect(article?.title).toBe('Test Article');
|
expect(result.value).toBeDefined();
|
||||||
expect(article?.authorId).toBe(authorId);
|
expect(result.value?.slug).toBe('test-article');
|
||||||
expect(article?.externalId).toBeDefined();
|
expect(result.value?.title).toBe('Test Article');
|
||||||
|
expect(result.value?.authorId).toBe(authorId);
|
||||||
|
expect(result.value?.externalId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot getArticleBySlug with non-existing slug', async () => {
|
test('cannot getArticleBySlug with non-existing slug', async () => {
|
||||||
await expect(getArticleBySlug('non-existing-slug')).resolves.toBeNull();
|
const result = await getArticleBySlug('non-existing-slug');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getArticlesByAuthorId', async () => {
|
test('can getArticlesByAuthorId', async () => {
|
||||||
const articles = await getArticlesByAuthorId(authorId);
|
const result = await getArticlesByAuthorId(authorId);
|
||||||
|
|
||||||
expect(articles).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(articles.length).toBeGreaterThanOrEqual(1);
|
if (!result.ok) return;
|
||||||
expect(articles[0].authorId).toBe(authorId);
|
expect(result.value).toBeDefined();
|
||||||
|
expect(result.value.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.value[0].authorId).toBe(authorId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getArticlesByAuthorId returns empty for non-existing author', async () => {
|
test('getArticlesByAuthorId returns empty for non-existing author', async () => {
|
||||||
const articles = await getArticlesByAuthorId('9999');
|
const result = await getArticlesByAuthorId('9999');
|
||||||
|
|
||||||
expect(articles).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(articles.length).toBe(0);
|
if (!result.ok) return;
|
||||||
|
expect(result.value).toBeDefined();
|
||||||
|
expect(result.value.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getArticleByExternalId', async () => {
|
test('can getArticleByExternalId', async () => {
|
||||||
const article = await getArticleBySlug('test-article');
|
const slugResult = await getArticleBySlug('test-article');
|
||||||
|
expect(slugResult.ok).toBe(true);
|
||||||
|
if (!slugResult.ok) return;
|
||||||
|
const article = slugResult.value;
|
||||||
expect(article).toBeDefined();
|
expect(article).toBeDefined();
|
||||||
|
|
||||||
const foundArticle = await getArticleByExternalId(
|
const result = await getArticleByExternalId(
|
||||||
article!.externalId as UUIDv4
|
article!.externalId as UUIDv4
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(foundArticle).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(foundArticle?.id).toBe(article!.id);
|
if (!result.ok) return;
|
||||||
expect(foundArticle?.title).toBe(article!.title);
|
expect(result.value).toBeDefined();
|
||||||
expect(foundArticle?.slug).toBe(article!.slug);
|
expect(result.value?.title).toBe(article!.title);
|
||||||
expect(foundArticle?.externalId).toBe(article!.externalId);
|
expect(result.value?.slug).toBe(article!.slug);
|
||||||
|
expect(result.value?.externalId).toBe(article!.externalId);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getArticleByExternalId returns null for non-existing id', async () => {
|
test('getArticleByExternalId returns null for non-existing id', async () => {
|
||||||
const result = await getArticleByExternalId(
|
const result = await getArticleByExternalId(
|
||||||
'00000000-0000-4000-a000-000000000000' as UUIDv4
|
'00000000-0000-4000-a000-000000000000' as UUIDv4
|
||||||
);
|
);
|
||||||
expect(result).toBeNull();
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getArticlesPaginated with defaults', async () => {
|
test('can getArticlesPaginated with defaults', async () => {
|
||||||
const result = await getArticlesPaginated();
|
const result = await getArticlesPaginated();
|
||||||
|
|
||||||
expect(result).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(result.data.length).toBeGreaterThanOrEqual(1);
|
if (!result.ok) return;
|
||||||
expect(result.page).toBe(1);
|
expect(result.value.data.length).toBeGreaterThanOrEqual(1);
|
||||||
expect(result.pageSize).toBe(10);
|
expect(result.value.page).toBe(1);
|
||||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
expect(result.value.pageSize).toBe(10);
|
||||||
expect(result.totalPages).toBeGreaterThanOrEqual(1);
|
expect(result.value.total).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(result.value.totalPages).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getArticlesPaginated with custom page size', async () => {
|
test('can getArticlesPaginated with custom page size', async () => {
|
||||||
// Save extra articles to test pagination
|
// Save extra articles to test pagination
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
await saveArticle({
|
const r = await saveArticle({
|
||||||
title: `Paginated Article ${i}`,
|
title: `Paginated Article ${i}`,
|
||||||
slug: `paginated-article-${i}`,
|
slug: `paginated-article-${i}`,
|
||||||
description: `Description ${i}`,
|
description: `Description ${i}`,
|
||||||
@@ -150,29 +173,36 @@ describe('ArticleService', () => {
|
|||||||
content: `Content ${i}`,
|
content: `Content ${i}`,
|
||||||
authorId: authorId,
|
authorId: authorId,
|
||||||
});
|
});
|
||||||
|
if (!r.ok) throw r.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstPage = await getArticlesPaginated(1, 2);
|
const firstPageResult = await getArticlesPaginated(1, 2);
|
||||||
|
|
||||||
expect(firstPage.data.length).toBe(2);
|
expect(firstPageResult.ok).toBe(true);
|
||||||
expect(firstPage.page).toBe(1);
|
if (!firstPageResult.ok) return;
|
||||||
expect(firstPage.pageSize).toBe(2);
|
expect(firstPageResult.value.data.length).toBe(2);
|
||||||
expect(firstPage.total).toBeGreaterThanOrEqual(4);
|
expect(firstPageResult.value.page).toBe(1);
|
||||||
expect(firstPage.totalPages).toBeGreaterThanOrEqual(2);
|
expect(firstPageResult.value.pageSize).toBe(2);
|
||||||
|
expect(firstPageResult.value.total).toBeGreaterThanOrEqual(4);
|
||||||
|
expect(firstPageResult.value.totalPages).toBeGreaterThanOrEqual(2);
|
||||||
|
|
||||||
const secondPage = await getArticlesPaginated(2, 2);
|
const secondPageResult = await getArticlesPaginated(2, 2);
|
||||||
|
|
||||||
expect(secondPage.data.length).toBe(2);
|
expect(secondPageResult.ok).toBe(true);
|
||||||
expect(secondPage.page).toBe(2);
|
if (!secondPageResult.ok) return;
|
||||||
expect(secondPage.pageSize).toBe(2);
|
expect(secondPageResult.value.data.length).toBe(2);
|
||||||
|
expect(secondPageResult.value.page).toBe(2);
|
||||||
|
expect(secondPageResult.value.pageSize).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getArticlesPaginated returns empty on out-of-range page', async () => {
|
test('can getArticlesPaginated returns empty on out-of-range page', async () => {
|
||||||
const result = await getArticlesPaginated(999, 10);
|
const result = await getArticlesPaginated(999, 10);
|
||||||
|
|
||||||
expect(result.data.length).toBe(0);
|
expect(result.ok).toBe(true);
|
||||||
expect(result.page).toBe(999);
|
if (!result.ok) return;
|
||||||
expect(result.total).toBeGreaterThanOrEqual(1);
|
expect(result.value.data.length).toBe(0);
|
||||||
|
expect(result.value.page).toBe(999);
|
||||||
|
expect(result.value.total).toBeGreaterThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can update article', async () => {
|
test('can update article', async () => {
|
||||||
@@ -181,18 +211,25 @@ describe('ArticleService', () => {
|
|||||||
description: 'Updated description',
|
description: 'Updated description',
|
||||||
};
|
};
|
||||||
|
|
||||||
const article = await getArticleBySlug('test-article');
|
const slugResult = await getArticleBySlug('test-article');
|
||||||
|
expect(slugResult.ok).toBe(true);
|
||||||
|
if (!slugResult.ok) return;
|
||||||
|
const article = slugResult.value;
|
||||||
expect(article).toBeDefined();
|
expect(article).toBeDefined();
|
||||||
|
|
||||||
const updatedArticle = await updateArticle(article!.id, dataToUpdate);
|
const result = await updateArticleByExternalId(
|
||||||
|
article!.externalId,
|
||||||
|
dataToUpdate
|
||||||
|
);
|
||||||
|
|
||||||
expect(updatedArticle).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(updatedArticle.id).toBe(article!.id);
|
if (!result.ok) return;
|
||||||
expect(updatedArticle.title).toBe(dataToUpdate.title);
|
expect(result.value).toBeDefined();
|
||||||
expect(updatedArticle.description).toBe(dataToUpdate.description);
|
expect(result.value.title).toBe(dataToUpdate.title);
|
||||||
expect(updatedArticle.slug).toBe(article!.slug);
|
expect(result.value.description).toBe(dataToUpdate.description);
|
||||||
expect(updatedArticle.content).toBe(article!.content);
|
expect(result.value.slug).toBe(article!.slug);
|
||||||
expect(updatedArticle.externalId).toBeDefined();
|
expect(result.value.content).toBe(article!.content);
|
||||||
|
expect(result.value.externalId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot update non-existing article', async () => {
|
test('cannot update non-existing article', async () => {
|
||||||
@@ -200,9 +237,11 @@ describe('ArticleService', () => {
|
|||||||
title: 'Updated Article Title',
|
title: 'Updated Article Title',
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(updateArticle('9999', dataToUpdate)).rejects.toThrow(
|
const result = await updateArticleByExternalId('9999', dataToUpdate);
|
||||||
`Article with ID 9999 not found`
|
|
||||||
);
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.message).toContain('9999');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can delete article', async () => {
|
test('can delete article', async () => {
|
||||||
@@ -215,18 +254,27 @@ describe('ArticleService', () => {
|
|||||||
authorId: authorId,
|
authorId: authorId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const savedArticle = await saveArticle(articleToSave);
|
const saveResult = await saveArticle(articleToSave);
|
||||||
expect(savedArticle.id).toBeDefined();
|
expect(saveResult.ok).toBe(true);
|
||||||
|
if (!saveResult.ok) return;
|
||||||
|
expect(saveResult.value.externalId).toBeDefined();
|
||||||
|
|
||||||
await deleteArticle(savedArticle.id);
|
const deleteResult = await deleteArticleByExternalId(
|
||||||
|
saveResult.value.externalId
|
||||||
|
);
|
||||||
|
expect(deleteResult.ok).toBe(true);
|
||||||
|
|
||||||
const deletedArticle = await getArticleBySlug('article-to-delete');
|
const getResult = await getArticleBySlug('article-to-delete');
|
||||||
expect(deletedArticle).toBeNull();
|
expect(getResult.ok).toBe(true);
|
||||||
|
if (!getResult.ok) return;
|
||||||
|
expect(getResult.value).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot delete non-existing article', async () => {
|
test('cannot delete non-existing article', async () => {
|
||||||
await expect(deleteArticle('9999')).rejects.toThrow(
|
const result = await deleteArticleByExternalId('9999');
|
||||||
`Article with ID 9999 not found`
|
|
||||||
);
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.message).toContain('9999');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,14 +35,15 @@ describe('UserService', () => {
|
|||||||
role: 'user',
|
role: 'user',
|
||||||
};
|
};
|
||||||
|
|
||||||
const savedUser = await saveUser(userToSave);
|
const result = await saveUser(userToSave);
|
||||||
|
|
||||||
expect(savedUser.id).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(savedUser.name).toBe(userToSave.name);
|
if (!result.ok) return;
|
||||||
expect(savedUser.email).toBe(userToSave.email);
|
expect(result.value.id).toBeDefined();
|
||||||
expect(savedUser.role).toBe(userToSave.role);
|
expect(result.value.name).toBe(userToSave.name);
|
||||||
expect(savedUser.role).toBe(userToSave.role);
|
expect(result.value.email).toBe(userToSave.email);
|
||||||
expect(savedUser.externalId).toBeDefined(); // Default to true if not set
|
expect(result.value.role).toBe(userToSave.role);
|
||||||
|
expect(result.value.externalId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot save user with existing email', async () => {
|
test('cannot save user with existing email', async () => {
|
||||||
@@ -51,23 +52,31 @@ describe('UserService', () => {
|
|||||||
email: 'test@email.com',
|
email: 'test@email.com',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
};
|
};
|
||||||
await expect(saveUser(userToSave)).rejects.toThrow(
|
|
||||||
`User with email ${userToSave.email} already exists`
|
const result = await saveUser(userToSave);
|
||||||
);
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.message).toContain(userToSave.email);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can getUserByEmail', async () => {
|
test('can getUserByEmail', async () => {
|
||||||
const user = await getUserByEmail('test@email.com');
|
const result = await getUserByEmail('test@email.com');
|
||||||
|
|
||||||
expect(user).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(user?.email).toBe('test@email.com');
|
if (!result.ok) return;
|
||||||
expect(user?.name).toBe('Test User');
|
expect(result.value?.email).toBe('test@email.com');
|
||||||
expect(user?.role).toBe('user');
|
expect(result.value?.name).toBe('Test User');
|
||||||
expect(user?.externalId).toBeDefined(); // Default to true if not set
|
expect(result.value?.role).toBe('user');
|
||||||
|
expect(result.value?.externalId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot getUserByEmail with non-existing email', async () => {
|
test('cannot getUserByEmail with non-existing email', async () => {
|
||||||
await expect(getUserByEmail('missing@email.com')).resolves.toBeNull();
|
const result = await getUserByEmail('missing@email.com');
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
expect(result.value).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can update user', async () => {
|
test('can update user', async () => {
|
||||||
@@ -76,17 +85,21 @@ describe('UserService', () => {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
const user = await getUserByEmail('test@email.com');
|
const userResult = await getUserByEmail('test@email.com');
|
||||||
|
expect(userResult.ok).toBe(true);
|
||||||
|
if (!userResult.ok) return;
|
||||||
|
const user = userResult.value;
|
||||||
expect(user).toBeDefined();
|
expect(user).toBeDefined();
|
||||||
|
|
||||||
const updatedUser = await updateUser(user!.id, dataToUpdate);
|
const result = await updateUser(user!.id, dataToUpdate);
|
||||||
|
|
||||||
expect(updatedUser).toBeDefined();
|
expect(result.ok).toBe(true);
|
||||||
expect(updatedUser.id).toBe(user!.id);
|
if (!result.ok) return;
|
||||||
expect(updatedUser.name).toBe(dataToUpdate.name);
|
expect(result.value.id).toBe(user!.id);
|
||||||
expect(updatedUser.role).toBe(dataToUpdate.role);
|
expect(result.value.name).toBe(dataToUpdate.name);
|
||||||
expect(updatedUser.email).toBe(user!.email);
|
expect(result.value.role).toBe(dataToUpdate.role);
|
||||||
expect(updatedUser.externalId).toBeDefined(); // Default to true if not set
|
expect(result.value.email).toBe(user!.email);
|
||||||
|
expect(result.value.externalId).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot update non-existing user', async () => {
|
test('cannot update non-existing user', async () => {
|
||||||
@@ -95,9 +108,11 @@ describe('UserService', () => {
|
|||||||
role: 'admin',
|
role: 'admin',
|
||||||
};
|
};
|
||||||
|
|
||||||
await expect(updateUser('9999', dataToUpdate)).rejects.toThrow(
|
const result = await updateUser('9999', dataToUpdate);
|
||||||
`User with ID 9999 not found`
|
|
||||||
);
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.message).toContain('9999');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can sync admin user', async () => {
|
test('can sync admin user', async () => {
|
||||||
@@ -131,12 +146,14 @@ describe('UserService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as SessionClaims;
|
} as SessionClaims;
|
||||||
const syncedUser = await syncUser(sessionClaims);
|
|
||||||
|
|
||||||
expect(syncedUser).toBeDefined();
|
const result = await syncUser(sessionClaims);
|
||||||
expect(syncedUser.name).toBe('Updated Name');
|
|
||||||
expect(syncedUser.email).toBe('test@email.com');
|
expect(result.ok).toBe(true);
|
||||||
expect(syncedUser.role).toBe('admin');
|
if (!result.ok) return;
|
||||||
|
expect(result.value.name).toBe('Updated Name');
|
||||||
|
expect(result.value.email).toBe('test@email.com');
|
||||||
|
expect(result.value.role).toBe('admin');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can sync internal user', async () => {
|
test('can sync internal user', async () => {
|
||||||
@@ -170,12 +187,14 @@ describe('UserService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as SessionClaims;
|
} as SessionClaims;
|
||||||
const syncedUser = await syncUser(sessionClaims);
|
|
||||||
|
|
||||||
expect(syncedUser).toBeDefined();
|
const result = await syncUser(sessionClaims);
|
||||||
expect(syncedUser.name).toBe('Updated Name');
|
|
||||||
expect(syncedUser.email).toBe('test@email.com');
|
expect(result.ok).toBe(true);
|
||||||
expect(syncedUser.role).toBe('internal');
|
if (!result.ok) return;
|
||||||
|
expect(result.value.name).toBe('Updated Name');
|
||||||
|
expect(result.value.email).toBe('test@email.com');
|
||||||
|
expect(result.value.role).toBe('internal');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can sync user', async () => {
|
test('can sync user', async () => {
|
||||||
@@ -202,12 +221,14 @@ describe('UserService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as SessionClaims;
|
} as SessionClaims;
|
||||||
const syncedUser = await syncUser(sessionClaims);
|
|
||||||
|
|
||||||
expect(syncedUser).toBeDefined();
|
const result = await syncUser(sessionClaims);
|
||||||
expect(syncedUser.name).toBe('Updated Name');
|
|
||||||
expect(syncedUser.email).toBe('test@email.com');
|
expect(result.ok).toBe(true);
|
||||||
expect(syncedUser.role).toBe('user');
|
if (!result.ok) return;
|
||||||
|
expect(result.value.name).toBe('Updated Name');
|
||||||
|
expect(result.value.email).toBe('test@email.com');
|
||||||
|
expect(result.value.role).toBe('user');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can sync saving new user', async () => {
|
test('can sync saving new user', async () => {
|
||||||
@@ -234,11 +255,13 @@ describe('UserService', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as SessionClaims;
|
} as SessionClaims;
|
||||||
const syncedUser = await syncUser(sessionClaims);
|
|
||||||
|
|
||||||
expect(syncedUser).toBeDefined();
|
const result = await syncUser(sessionClaims);
|
||||||
expect(syncedUser.name).toBe('Updated Name');
|
|
||||||
expect(syncedUser.email).toBe('new@email.com');
|
expect(result.ok).toBe(true);
|
||||||
expect(syncedUser.role).toBe('user');
|
if (!result.ok) return;
|
||||||
|
expect(result.value.name).toBe('Updated Name');
|
||||||
|
expect(result.value.email).toBe('new@email.com');
|
||||||
|
expect(result.value.role).toBe('user');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ describe('S3StorageAdapter', () => {
|
|||||||
region: 'us-east-1',
|
region: 'us-east-1',
|
||||||
accessKey: 'test-access-key',
|
accessKey: 'test-access-key',
|
||||||
secretKey: 'test-secret-key',
|
secretKey: 'test-secret-key',
|
||||||
|
publicUrl: 'http://test.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -44,7 +45,7 @@ describe('S3StorageAdapter', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: `http://localhost:9000/test-bucket/${key}`,
|
value: `http://test.com/${key}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -55,7 +56,7 @@ describe('S3StorageAdapter', () => {
|
|||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
value: `http://localhost:9000/test-bucket/${key}`,
|
value: `http://test.com/${key}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user