Compare commits
25 Commits
c938974d2b
...
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
|
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",
|
||||||
|
|||||||
@@ -4,20 +4,28 @@ import { UUIDv4 } from '@/utils/types/uuid';
|
|||||||
import { ArrowLeftIcon } from 'lucide-react';
|
import { ArrowLeftIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
interface UpdateArticlePageProps {
|
interface UpdateArticlePageProps {
|
||||||
params: Promise<{ externalId: string }>;
|
params: Promise<{ externalId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UpdateArticlePage = async ({ params }: UpdateArticlePageProps) => {
|
const ArticleFormContent = async ({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ externalId: string }>;
|
||||||
|
}) => {
|
||||||
const { externalId } = await params;
|
const { externalId } = await params;
|
||||||
|
|
||||||
const result = await getArticleByExternalId(externalId as UUIDv4);
|
const result = await getArticleByExternalId(externalId as UUIDv4);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
const article = result.value;
|
const article = result.value;
|
||||||
if (!article) notFound();
|
if (!article) notFound();
|
||||||
|
|
||||||
|
return <UpdateArticleForm article={article} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UpdateArticlePage = ({ params }: UpdateArticlePageProps) => {
|
||||||
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='mb-6'>
|
<div className='mb-6'>
|
||||||
@@ -31,7 +39,9 @@ const UpdateArticlePage = async ({ params }: UpdateArticlePageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='rounded-lg border border-border p-6'>
|
<div className='rounded-lg border border-border p-6'>
|
||||||
<h2 className='mb-6 text-2xl font-bold'>Edit Article</h2>
|
<h2 className='mb-6 text-2xl font-bold'>Edit Article</h2>
|
||||||
<UpdateArticleForm article={article} />
|
<Suspense fallback={<div>Loading...</div>}>
|
||||||
|
<ArticleFormContent params={params} />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</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,8 +65,11 @@ const ArticleContentSkeleton = () => (
|
|||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ArticleContent = async ({ params }: ArticleContentProps) => {
|
const ArticleContent = async ({ slug }: { slug: string }) => {
|
||||||
const { slug } = await params;
|
'use cache';
|
||||||
|
|
||||||
|
cacheTag(`article:slug:${slug}`);
|
||||||
|
|
||||||
const articleResult = await getArticleBySlug(slug);
|
const articleResult = await getArticleBySlug(slug);
|
||||||
if (!articleResult.ok) throw articleResult.error;
|
if (!articleResult.ok) throw articleResult.error;
|
||||||
const article = articleResult.value;
|
const article = articleResult.value;
|
||||||
@@ -144,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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ import { ArticleList } from '@/ui/components/internal/article/article-list';
|
|||||||
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
|
||||||
import { Suspense } from 'react';
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
const PAGE_SIZE = 4;
|
const DEFAULT_PAGE_SIZE = 4;
|
||||||
|
|
||||||
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'>
|
||||||
@@ -20,12 +29,11 @@ const Home = async ({ searchParams }: HomeProps) => {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={<ArticleListSkeleton skeletonSize={PAGE_SIZE} />}
|
fallback={
|
||||||
|
<ArticleListSkeleton skeletonSize={DEFAULT_PAGE_SIZE} />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ArticleList
|
<ArticleListWrapper {...props} />
|
||||||
searchParams={searchParams}
|
|
||||||
defaultPageSize={PAGE_SIZE}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ 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 { TypedResult, wrap } from '@/utils/types/results';
|
||||||
import { UUIDv4 } from '@/utils/types/uuid';
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
import { revalidatePath } from 'next/cache';
|
import { revalidateTag } from 'next/cache';
|
||||||
|
|
||||||
export const getArticleByExternalId: (
|
export const getArticleByExternalId: (
|
||||||
externalId: UUIDv4
|
externalId: UUIDv4
|
||||||
@@ -40,8 +40,6 @@ export const getArticlesPaginated: (
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
pageSize: number = 10
|
pageSize: number = 10
|
||||||
): Promise<PaginatedArticlesResult> => {
|
): Promise<PaginatedArticlesResult> => {
|
||||||
// await new Promise((r) => setTimeout(r, 1000));
|
|
||||||
|
|
||||||
const result = await service.getArticlesPaginated(page, pageSize);
|
const result = await service.getArticlesPaginated(page, pageSize);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
return result.value;
|
return result.value;
|
||||||
@@ -62,16 +60,18 @@ export const saveArticle: (
|
|||||||
|
|
||||||
const result = await service.saveArticle(article);
|
const result = await service.saveArticle(article);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
|
|
||||||
|
revalidateTag('articles', 'max');
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateArticle: (
|
export const updateArticleByExternalId: (
|
||||||
articleId: string,
|
externalId: UUIDv4,
|
||||||
article: UpdateArticleModel
|
article: UpdateArticleModel
|
||||||
) => Promise<TypedResult<ArticleModel>> = wrap(
|
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||||
async (
|
async (
|
||||||
articleId: string,
|
externalId: UUIDv4,
|
||||||
article: UpdateArticleModel
|
article: UpdateArticleModel
|
||||||
): Promise<ArticleModel> => {
|
): Promise<ArticleModel> => {
|
||||||
const session = await getSessionData();
|
const session = await getSessionData();
|
||||||
@@ -81,15 +81,24 @@ export const updateArticle: (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await service.updateArticle(articleId, article);
|
const result = await service.updateArticleByExternalId(
|
||||||
|
externalId,
|
||||||
|
article
|
||||||
|
);
|
||||||
if (!result.ok) throw result.error;
|
if (!result.ok) throw result.error;
|
||||||
revalidatePath('/admin');
|
|
||||||
|
revalidateTag('articles', 'max');
|
||||||
|
revalidateTag(`article:${externalId}`, 'max');
|
||||||
|
revalidateTag(`article:slug:${result.value.slug}`, 'max');
|
||||||
|
|
||||||
return result.value;
|
return result.value;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> =
|
export const deleteArticleByExternalId: (
|
||||||
wrap(async (articleId: string): Promise<void> => {
|
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(
|
throw new Error(
|
||||||
@@ -97,7 +106,17 @@ export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = 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;
|
if (!result.ok) throw result.error;
|
||||||
revalidatePath('/admin');
|
|
||||||
});
|
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(),
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ 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,
|
||||||
@@ -123,21 +122,21 @@ export const saveArticle: (
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Updates an existing article in the database. */
|
/** Updates an existing article in the database. */
|
||||||
export const updateArticle: (
|
export const updateArticleByExternalId: (
|
||||||
articleId: string,
|
externalId: string,
|
||||||
article: UpdateArticleModel
|
article: UpdateArticleModel
|
||||||
) => Promise<TypedResult<ArticleModel>> = wrap(
|
) => Promise<TypedResult<ArticleModel>> = wrap(
|
||||||
async (
|
async (
|
||||||
articleId: string,
|
externalId: 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;
|
||||||
@@ -155,16 +154,19 @@ export const updateArticle: (
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Deletes an article from the database. */
|
/** Deletes an article from the database. */
|
||||||
export const deleteArticle: (articleId: string) => Promise<TypedResult<void>> =
|
export const deleteArticleByExternalId: (
|
||||||
wrap(async (articleId: string): Promise<void> => {
|
externalId: UUIDv4
|
||||||
|
) => Promise<TypedResult<void>> = wrap(
|
||||||
|
async (externalId: UUIDv4): 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);
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|||||||
@@ -18,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>;
|
||||||
|
|
||||||
@@ -27,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: (
|
||||||
@@ -44,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({
|
||||||
@@ -56,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));
|
||||||
@@ -65,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> {
|
||||||
@@ -109,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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -62,8 +62,8 @@ export const AdminArticleCard = ({ article }: AdminArticleCardProps) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteArticleButton
|
<DeleteArticleButton
|
||||||
articleId={article.id}
|
externalID={article.externalId}
|
||||||
articleTitle={article.title}
|
title={article.title}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export const AdminArticleList = async ({
|
|||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<DeleteArticleButton
|
<DeleteArticleButton
|
||||||
articleId={article.id}
|
externalID={article.externalId}
|
||||||
articleTitle={article.title}
|
title={article.title}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -1,22 +1,18 @@
|
|||||||
// 'use client';
|
|
||||||
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
||||||
import { ArticleCard } from '@/ui/components/internal/article-card';
|
import { ArticleCard } from '@/ui/components/internal/article-card';
|
||||||
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import { cacheTag } from 'next/cache';
|
||||||
|
|
||||||
type ArticleListProps = {
|
type ArticleListProps = {
|
||||||
searchParams: Promise<{ page?: string; pageSize?: string }>;
|
page: number;
|
||||||
defaultPageSize: number;
|
pageSize: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ArticleList = async ({
|
export const ArticleList = async ({ page, pageSize }: ArticleListProps) => {
|
||||||
searchParams,
|
'use cache';
|
||||||
defaultPageSize,
|
|
||||||
}: ArticleListProps) => {
|
|
||||||
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
|
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
|
||||||
const pageSize = Number(pageSizeParam) || defaultPageSize;
|
|
||||||
|
|
||||||
|
cacheTag('articles', `articles:page:${page}-${pageSize}`);
|
||||||
const paginationResult = await getArticlesPaginated(page, pageSize);
|
const paginationResult = await getArticlesPaginated(page, pageSize);
|
||||||
|
|
||||||
if (!paginationResult.ok) {
|
if (!paginationResult.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { deleteArticle } from '@/lib/feature/article/article.external';
|
import { deleteArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -13,24 +13,25 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/ui/components/shadcn/alert-dialog';
|
} from '@/ui/components/shadcn/alert-dialog';
|
||||||
import { Button } from '@/ui/components/shadcn/button';
|
import { Button } from '@/ui/components/shadcn/button';
|
||||||
|
import { UUIDv4 } from '@/utils/types/uuid';
|
||||||
import { Trash2Icon } from 'lucide-react';
|
import { Trash2Icon } from 'lucide-react';
|
||||||
import { useTransition } from 'react';
|
import { useTransition } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface DeleteArticleButtonProps {
|
interface DeleteArticleButtonProps {
|
||||||
articleId: string;
|
externalID: UUIDv4;
|
||||||
articleTitle: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DeleteArticleButton = ({
|
export const DeleteArticleButton = ({
|
||||||
articleId,
|
externalID,
|
||||||
articleTitle,
|
title,
|
||||||
}: DeleteArticleButtonProps) => {
|
}: DeleteArticleButtonProps) => {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await deleteArticle(articleId);
|
const result = await deleteArticleByExternalId(externalID);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error('Failed to delete article', {
|
toast.error('Failed to delete article', {
|
||||||
description: result.error.message,
|
description: result.error.message,
|
||||||
@@ -39,7 +40,7 @@ export const DeleteArticleButton = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success('Article deleted', {
|
toast.success('Article deleted', {
|
||||||
description: `"${articleTitle}" has been removed.`,
|
description: `"${title}" has been removed.`,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -57,7 +58,7 @@ export const DeleteArticleButton = ({
|
|||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete article?</AlertDialogTitle>
|
<AlertDialogTitle>Delete article?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete “{articleTitle}
|
This will permanently delete “{title}
|
||||||
”. This action cannot be undone.
|
”. This action cannot be undone.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { updateArticle } from '@/lib/feature/article/article.external';
|
import { updateArticleByExternalId } from '@/lib/feature/article/article.external';
|
||||||
import { ArticleModel } from '@/lib/feature/article/article.model';
|
import { ArticleModel } from '@/lib/feature/article/article.model';
|
||||||
import { uploadFile } from '@/lib/storage/storage.utils';
|
import { uploadFile } from '@/lib/storage/storage.utils';
|
||||||
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
|
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
|
||||||
@@ -97,7 +97,10 @@ export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => {
|
|||||||
|
|
||||||
const handleFormSubmit = useCallback(
|
const handleFormSubmit = useCallback(
|
||||||
async (data: z.infer<typeof formSchema>) => {
|
async (data: z.infer<typeof formSchema>) => {
|
||||||
const result = await updateArticle(article.id, data);
|
const result = await updateArticleByExternalId(
|
||||||
|
article.externalId,
|
||||||
|
data
|
||||||
|
);
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
toast.error('Failed to update article', {
|
toast.error('Failed to update article', {
|
||||||
description: result.error.message,
|
description: result.error.message,
|
||||||
@@ -110,7 +113,7 @@ export const UpdateArticleForm = ({ article }: UpdateArticleFormProps) => {
|
|||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[article.id]
|
[article.externalId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCoverImageFileChange = useCallback(
|
const handleCoverImageFileChange = useCallback(
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -56,7 +56,6 @@ describe('ArticleService', () => {
|
|||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.value.id).toBeDefined();
|
|
||||||
expect(result.value.title).toBe(articleToSave.title);
|
expect(result.value.title).toBe(articleToSave.title);
|
||||||
expect(result.value.slug).toBe(articleToSave.slug);
|
expect(result.value.slug).toBe(articleToSave.slug);
|
||||||
expect(result.value.description).toBe(articleToSave.description);
|
expect(result.value.description).toBe(articleToSave.description);
|
||||||
@@ -136,7 +135,6 @@ describe('ArticleService', () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.value).toBeDefined();
|
expect(result.value).toBeDefined();
|
||||||
expect(result.value?.id).toBe(article!.id);
|
|
||||||
expect(result.value?.title).toBe(article!.title);
|
expect(result.value?.title).toBe(article!.title);
|
||||||
expect(result.value?.slug).toBe(article!.slug);
|
expect(result.value?.slug).toBe(article!.slug);
|
||||||
expect(result.value?.externalId).toBe(article!.externalId);
|
expect(result.value?.externalId).toBe(article!.externalId);
|
||||||
@@ -219,12 +217,14 @@ describe('ArticleService', () => {
|
|||||||
const article = slugResult.value;
|
const article = slugResult.value;
|
||||||
expect(article).toBeDefined();
|
expect(article).toBeDefined();
|
||||||
|
|
||||||
const result = await updateArticle(article!.id, dataToUpdate);
|
const result = await updateArticleByExternalId(
|
||||||
|
article!.externalId,
|
||||||
|
dataToUpdate
|
||||||
|
);
|
||||||
|
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (!result.ok) return;
|
if (!result.ok) return;
|
||||||
expect(result.value).toBeDefined();
|
expect(result.value).toBeDefined();
|
||||||
expect(result.value.id).toBe(article!.id);
|
|
||||||
expect(result.value.title).toBe(dataToUpdate.title);
|
expect(result.value.title).toBe(dataToUpdate.title);
|
||||||
expect(result.value.description).toBe(dataToUpdate.description);
|
expect(result.value.description).toBe(dataToUpdate.description);
|
||||||
expect(result.value.slug).toBe(article!.slug);
|
expect(result.value.slug).toBe(article!.slug);
|
||||||
@@ -237,7 +237,7 @@ describe('ArticleService', () => {
|
|||||||
title: 'Updated Article Title',
|
title: 'Updated Article Title',
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await updateArticle('9999', dataToUpdate);
|
const result = await updateArticleByExternalId('9999', dataToUpdate);
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
@@ -257,9 +257,11 @@ describe('ArticleService', () => {
|
|||||||
const saveResult = await saveArticle(articleToSave);
|
const saveResult = await saveArticle(articleToSave);
|
||||||
expect(saveResult.ok).toBe(true);
|
expect(saveResult.ok).toBe(true);
|
||||||
if (!saveResult.ok) return;
|
if (!saveResult.ok) return;
|
||||||
expect(saveResult.value.id).toBeDefined();
|
expect(saveResult.value.externalId).toBeDefined();
|
||||||
|
|
||||||
const deleteResult = await deleteArticle(saveResult.value.id);
|
const deleteResult = await deleteArticleByExternalId(
|
||||||
|
saveResult.value.externalId
|
||||||
|
);
|
||||||
expect(deleteResult.ok).toBe(true);
|
expect(deleteResult.ok).toBe(true);
|
||||||
|
|
||||||
const getResult = await getArticleBySlug('article-to-delete');
|
const getResult = await getArticleBySlug('article-to-delete');
|
||||||
@@ -269,7 +271,7 @@ describe('ArticleService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('cannot delete non-existing article', async () => {
|
test('cannot delete non-existing article', async () => {
|
||||||
const result = await deleteArticle('9999');
|
const result = await deleteArticleByExternalId('9999');
|
||||||
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (result.ok) return;
|
if (result.ok) return;
|
||||||
|
|||||||
@@ -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