Compare commits

...

10 Commits

Author SHA1 Message Date
386affeac3 Merge pull request 'refactor/better-suspend-model' (#3) from refactor/better-suspend-model into main
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 1m54s
Reviewed-on: http://gitea.hideyoshi.com.br/HideyoshiNakazone/hideyoshi-blog/pulls/3
2026-04-17 04:50:46 +00:00
e2960027f2 refactor: update article service methods to use external ID and improve caching
All checks were successful
Build and Test / run-test (20.x) (push) Successful in 2m5s
2026-04-17 01:43:31 -03:00
93d66315a1 refactor: update Next.js and Clerk dependencies to latest versions 2026-04-17 01:01:21 -03:00
26bdb65346 refactor: update S3StorageAdapter tests to use public URL for object retrieval
Some checks failed
Build and Test / run-test (20.x) (push) Failing after 1m41s
2026-04-17 00:59:13 -03:00
b9e34e590d refactor: update article deletion and retrieval methods to use external ID 2026-04-17 00:12:44 -03:00
873e372bad refactor: update S3StorageAdapter to use publicUrl for object retrieval
Some checks failed
Build and Test / run-test (20.x) (push) Failing after 1m51s
2026-04-16 20:21:22 -03:00
4e61e4bff5 refactor: restructure UpdateArticlePage to use Suspense for loading state 2026-04-16 19:26:23 -03:00
c938974d2b feat: add table components and admin article list with skeleton loading
Some checks failed
Build and Test / run-test (20.x) (push) Failing after 1m30s
2026-04-13 20:51:20 -03:00
79e6fae0f9 feat: implement ArticleList component with pagination and loading skeleton 2026-04-11 21:37:39 -03:00
56a5d77c6c refactor: rename getSignedUrl to getPublicUrl for clarity in storage functions 2026-04-11 20:52:35 -03:00
27 changed files with 1386 additions and 255 deletions

132
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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;

View 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;

View File

@@ -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>
); );
}; };

View File

@@ -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>
); );
}; };

View File

@@ -1,100 +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 paginationResult = await getArticlesPaginated(page, pageSize);
if (!paginationResult.ok) throw paginationResult.error;
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>
)}
</>
);
};
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'>
@@ -105,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>
); );

View File

@@ -10,6 +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 { revalidateTag } from 'next/cache';
export const getArticleByExternalId: ( export const getArticleByExternalId: (
externalId: UUIDv4 externalId: UUIDv4
@@ -59,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();
@@ -78,14 +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;
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(
@@ -93,6 +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;
});
revalidateTag('articles', 'max');
revalidateTag(`article:${externalId}`, 'max');
revalidateTag(`article:slug:${article.slug}`, 'max');
}
);

View File

@@ -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(),

View File

@@ -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);
}); }
);

View File

@@ -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);
}; };

View File

@@ -7,17 +7,13 @@ 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>> => {
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.get(key); return await storageProvider.get(key);
}; };

View File

@@ -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');
} }

View File

@@ -5,6 +5,7 @@ import { NextResponse } from 'next/server';
const isPublic = createRouteMatcher([ const isPublic = createRouteMatcher([
'/home(.*)?', '/home(.*)?',
'/about(.*)?', '/about(.*)?',
'/article(.*)?',
'/api/user(.*)?', '/api/user(.*)?',
]); ]);

View 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>
);
};

View File

@@ -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>
</>
);

View 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>
)}
</>
);
};

View 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>
</>
);

View 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>
)}
</>
);
};

View 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 &ldquo;{title}
&rdquo;. 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>
);
};

View 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;

View 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,
};

View 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,
};

View File

@@ -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),
};
}
};
}

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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}`,
}); });
}); });
}); });