diff --git a/package-lock.json b/package-lock.json
index b315651..2547b46 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,7 +20,7 @@
"iron-session": "^8.0.4",
"jotai": "^2.19.0",
"lucide-react": "^1.7.0",
- "next": "16.2.1",
+ "next": "^16.2.4",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"radix-ui": "^1.4.3",
@@ -1585,12 +1585,12 @@
"license": "MIT"
},
"node_modules/@clerk/backend": {
- "version": "3.2.3",
- "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.3.tgz",
- "integrity": "sha512-I3YLnSioYFG+EVFBYm0ilN28+FC8H+hkqMgB5Pdl7AcotQOn3JhiZMqLel2H0P390p8FEJKQNnrvXk3BemeKKQ==",
+ "version": "3.2.12",
+ "resolved": "https://registry.npmjs.org/@clerk/backend/-/backend-3.2.12.tgz",
+ "integrity": "sha512-pTuD3+3IvLrjx9XMYdbqpttTltrWc7npxkOIDzhtID5/+IOomxZAzsnxU1G1hvOeBoR1Jl68ZgKQw66aZ+DRFQ==",
"license": "MIT",
"dependencies": {
- "@clerk/shared": "^4.3.2",
+ "@clerk/shared": "^4.8.2",
"standardwebhooks": "^1.0.0",
"tslib": "2.8.1"
},
@@ -1599,14 +1599,14 @@
}
},
"node_modules/@clerk/nextjs": {
- "version": "7.0.7",
- "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.0.7.tgz",
- "integrity": "sha512-Iqg4q0ns1LZZrAdC66r/QUFMY+Rs3HAJcAb/IR0uFBj7ZAZusxdVKMmNkZP9UP6sk3OOorCsJTdE0rTMoXD2YQ==",
+ "version": "7.2.2",
+ "resolved": "https://registry.npmjs.org/@clerk/nextjs/-/nextjs-7.2.2.tgz",
+ "integrity": "sha512-kFQ+sXD5qAxL8C53hDgpKJT+KithlOFDlkMK5Bwv/YNFy/Sf5Tzkxdh4y/AfAgJlDU0ksvu0Hn7mtB2QT8t9bg==",
"license": "MIT",
"dependencies": {
- "@clerk/backend": "^3.2.3",
- "@clerk/react": "^6.1.3",
- "@clerk/shared": "^4.3.2",
+ "@clerk/backend": "^3.2.12",
+ "@clerk/react": "^6.4.2",
+ "@clerk/shared": "^4.8.2",
"server-only": "0.0.1",
"tslib": "2.8.1"
},
@@ -1620,12 +1620,12 @@
}
},
"node_modules/@clerk/react": {
- "version": "6.1.3",
- "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.1.3.tgz",
- "integrity": "sha512-9t5C8eM5cTmOmpBO5nb8FDA40biQqeQLUW+cVwAE0t5hnGRwiC6mSv83vqHg+9qQBqtliR013BGVjpCz53gVCA==",
+ "version": "6.4.2",
+ "resolved": "https://registry.npmjs.org/@clerk/react/-/react-6.4.2.tgz",
+ "integrity": "sha512-l43pon5wcM0n4e3gnRP7MWdvAXAa3M7BOF6aeNwEuITxgy6MU6ypej9tPeGmXxPicL/Hd6E/VRgiEipEKDqyCQ==",
"license": "MIT",
"dependencies": {
- "@clerk/shared": "^4.3.2",
+ "@clerk/shared": "^4.8.2",
"tslib": "2.8.1"
},
"engines": {
@@ -1637,9 +1637,9 @@
}
},
"node_modules/@clerk/shared": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.3.2.tgz",
- "integrity": "sha512-tYYzdY4Fxb02TO4RHoLRFzEjXJn0iFDfoKhWtGyqf2AaIgkprTksunQtX0hnVssHMr3XD/E2S00Vrb+PzX3jCQ==",
+ "version": "4.8.2",
+ "resolved": "https://registry.npmjs.org/@clerk/shared/-/shared-4.8.2.tgz",
+ "integrity": "sha512-kBFDNeLdiNZkOHavTkOB00NMuqsmOGgVtDlzCS0/yivxsCUZXk3AT6+UsTfeSBGV7QD80YxjeFMNSrpqnSv4Gg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -2164,9 +2164,9 @@
}
},
"node_modules/@hono/node-server": {
- "version": "1.19.11",
- "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
- "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
+ "version": "1.19.14",
+ "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz",
+ "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -3518,9 +3518,9 @@
}
},
"node_modules/@next/env": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
- "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.4.tgz",
+ "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -3534,9 +3534,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
- "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz",
+ "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==",
"cpu": [
"arm64"
],
@@ -3550,9 +3550,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
- "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz",
+ "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==",
"cpu": [
"x64"
],
@@ -3566,9 +3566,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
- "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz",
+ "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==",
"cpu": [
"arm64"
],
@@ -3582,9 +3582,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
- "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz",
+ "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==",
"cpu": [
"arm64"
],
@@ -3598,9 +3598,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
- "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz",
+ "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==",
"cpu": [
"x64"
],
@@ -3614,9 +3614,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
- "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz",
+ "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==",
"cpu": [
"x64"
],
@@ -3630,9 +3630,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
- "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz",
+ "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==",
"cpu": [
"arm64"
],
@@ -3646,9 +3646,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
- "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
+ "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
@@ -11598,9 +11598,9 @@
}
},
"node_modules/hono": {
- "version": "4.12.9",
- "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.9.tgz",
- "integrity": "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==",
+ "version": "4.12.14",
+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
+ "integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@@ -15239,12 +15239,12 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
- "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
+ "version": "16.2.4",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.4.tgz",
+ "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.2.1",
+ "@next/env": "16.2.4",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -15258,14 +15258,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.2.1",
- "@next/swc-darwin-x64": "16.2.1",
- "@next/swc-linux-arm64-gnu": "16.2.1",
- "@next/swc-linux-arm64-musl": "16.2.1",
- "@next/swc-linux-x64-gnu": "16.2.1",
- "@next/swc-linux-x64-musl": "16.2.1",
- "@next/swc-win32-arm64-msvc": "16.2.1",
- "@next/swc-win32-x64-msvc": "16.2.1",
+ "@next/swc-darwin-arm64": "16.2.4",
+ "@next/swc-darwin-x64": "16.2.4",
+ "@next/swc-linux-arm64-gnu": "16.2.4",
+ "@next/swc-linux-arm64-musl": "16.2.4",
+ "@next/swc-linux-x64-gnu": "16.2.4",
+ "@next/swc-linux-x64-musl": "16.2.4",
+ "@next/swc-win32-arm64-msvc": "16.2.4",
+ "@next/swc-win32-x64-msvc": "16.2.4",
"sharp": "^0.34.5"
},
"peerDependencies": {
@@ -16513,9 +16513,9 @@
}
},
"node_modules/protobufjs": {
- "version": "7.5.4",
- "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
- "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
+ "version": "7.5.5",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.5.tgz",
+ "integrity": "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg==",
"dev": true,
"hasInstallScript": true,
"license": "BSD-3-Clause",
diff --git a/package.json b/package.json
index f33dccd..c99859c 100644
--- a/package.json
+++ b/package.json
@@ -29,7 +29,7 @@
"iron-session": "^8.0.4",
"jotai": "^2.19.0",
"lucide-react": "^1.7.0",
- "next": "16.2.1",
+ "next": "^16.2.4",
"next-themes": "^0.4.6",
"pg": "^8.20.0",
"radix-ui": "^1.4.3",
diff --git a/src/app/(pages)/admin/article/[externalId]/page.tsx b/src/app/(pages)/admin/article/[externalId]/page.tsx
new file mode 100644
index 0000000..f88eff4
--- /dev/null
+++ b/src/app/(pages)/admin/article/[externalId]/page.tsx
@@ -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 ;
+};
+
+const UpdateArticlePage = ({ params }: UpdateArticlePageProps) => {
+ return (
+
+
+
+
+ Back to articles
+
+
+
+
Edit Article
+ Loading...}>
+
+
+
+
+ );
+};
+
+export default UpdateArticlePage;
diff --git a/src/app/(pages)/admin/article/create/page.tsx b/src/app/(pages)/admin/article/create/page.tsx
new file mode 100644
index 0000000..ca05024
--- /dev/null
+++ b/src/app/(pages)/admin/article/create/page.tsx
@@ -0,0 +1,14 @@
+import { CreateArticleForm } from '@/ui/components/internal/create-article-form';
+
+const CreateArticlePage = () => {
+ return (
+
+
+
Create New Article
+
+
+
+ );
+};
+
+export default CreateArticlePage;
diff --git a/src/app/(pages)/admin/page.tsx b/src/app/(pages)/admin/page.tsx
index e517a7e..2d4d295 100644
--- a/src/app/(pages)/admin/page.tsx
+++ b/src/app/(pages)/admin/page.tsx
@@ -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 (
-
-
Create New Article
-
+
+
Articles
+
+
}
+ >
+
+
);
};
diff --git a/src/app/(pages)/article/[slug]/page.tsx b/src/app/(pages)/article/[slug]/page.tsx
index d5867dd..773f7f6 100644
--- a/src/app/(pages)/article/[slug]/page.tsx
+++ b/src/app/(pages)/article/[slug]/page.tsx
@@ -1,5 +1,6 @@
import { getArticleBySlug } from '@/lib/feature/article/article.external';
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
+import { cacheTag } from 'next/cache';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
@@ -10,10 +11,6 @@ type ArticlePageProps = {
params: Promise<{ slug: string }>;
};
-type ArticleContentProps = {
- params: Promise<{ slug: string }>;
-};
-
function readingTime(text: string): number {
const words = text.trim().split(/\s+/).length;
return Math.max(1, Math.ceil(words / 200));
@@ -68,8 +65,11 @@ const ArticleContentSkeleton = () => (
);
-const ArticleContent = async ({ params }: ArticleContentProps) => {
- const { slug } = await params;
+const ArticleContent = async ({ slug }: { slug: string }) => {
+ 'use cache';
+
+ cacheTag(`article:slug:${slug}`);
+
const articleResult = await getArticleBySlug(slug);
if (!articleResult.ok) throw articleResult.error;
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
;
+};
+
+const ArticlePage = (props: ArticlePageProps) => {
return (
}>
-
+
);
};
diff --git a/src/app/(pages)/home/page.tsx b/src/app/(pages)/home/page.tsx
index 39556c8..b101b94 100644
--- a/src/app/(pages)/home/page.tsx
+++ b/src/app/(pages)/home/page.tsx
@@ -1,100 +1,23 @@
-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 { ArticleList } from '@/ui/components/internal/article/article-list';
+import { ArticleListSkeleton } from '@/ui/components/internal/article/article-list-skeleton';
import { Suspense } from 'react';
-const PAGE_SIZE = 9;
-
-const ArticleCardSkeleton = () => (
-
-);
-
-const ArticleListSkeleton = () => (
- <>
-
-
- {Array.from({ length: PAGE_SIZE }).map((_, i) => (
-
- ))}
-
- >
-);
-
-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 (
- <>
-
- {total === 0
- ? 'No articles published yet.'
- : `${total} article${total === 1 ? '' : 's'} published`}
-
-
- {articles.length === 0 ? (
-
-
-
- No articles yet. Check back soon!
-
-
- ) : (
-
- {articles.map((article) => (
-
- ))}
-
- )}
-
- {totalPages > 1 && (
-
-
- Page {page} of {totalPages}
-
-
-
- )}
- >
- );
-};
+const DEFAULT_PAGE_SIZE = 4;
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
;
+};
+
+const Home = async (props: HomeProps) => {
return (
@@ -105,8 +28,12 @@ const Home = async ({ searchParams }: HomeProps) => {
Latest Articles
-
}>
-
+
+ }
+ >
+
);
diff --git a/src/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts
index fb377f6..2e5cea2 100644
--- a/src/lib/feature/article/article.external.ts
+++ b/src/lib/feature/article/article.external.ts
@@ -10,6 +10,7 @@ import * as service from '@/lib/feature/article/article.service';
import { getSessionData } from '@/lib/session/session-storage';
import { TypedResult, wrap } from '@/utils/types/results';
import { UUIDv4 } from '@/utils/types/uuid';
+import { revalidateTag } from 'next/cache';
export const getArticleByExternalId: (
externalId: UUIDv4
@@ -59,16 +60,18 @@ export const saveArticle: (
const result = await service.saveArticle(article);
if (!result.ok) throw result.error;
+
+ revalidateTag('articles', 'max');
return result.value;
}
);
-export const updateArticle: (
- articleId: string,
+export const updateArticleByExternalId: (
+ externalId: UUIDv4,
article: UpdateArticleModel
) => Promise
> = wrap(
async (
- articleId: string,
+ externalId: UUIDv4,
article: UpdateArticleModel
): Promise => {
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;
+
+ revalidateTag('articles', 'max');
+ revalidateTag(`article:${externalId}`, 'max');
+ revalidateTag(`article:slug:${result.value.slug}`, 'max');
+
return result.value;
}
);
-export const deleteArticle: (articleId: string) => Promise> =
- wrap(async (articleId: string): Promise => {
+export const deleteArticleByExternalId: (
+ externalId: UUIDv4
+) => Promise> = wrap(
+ async (externalId: UUIDv4): Promise => {
const session = await getSessionData();
if (!session || !session?.user || session?.user.role !== 'admin') {
throw new Error(
@@ -93,6 +106,17 @@ export const deleteArticle: (articleId: string) => Promise> =
);
}
- 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;
- });
+
+ revalidateTag('articles', 'max');
+ revalidateTag(`article:${externalId}`, 'max');
+ revalidateTag(`article:slug:${article.slug}`, 'max');
+ }
+);
diff --git a/src/lib/feature/article/article.model.ts b/src/lib/feature/article/article.model.ts
index 7851c97..5c5d1dc 100644
--- a/src/lib/feature/article/article.model.ts
+++ b/src/lib/feature/article/article.model.ts
@@ -21,7 +21,6 @@ export const UpdateArticleModel = z.object({
export type UpdateArticleModel = z.infer;
export const ArticleModel = z.object({
- id: z.string(),
title: z.string(),
slug: z.string(),
description: z.string(),
diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts
index 6eaa5ae..9acc668 100644
--- a/src/lib/feature/article/article.service.ts
+++ b/src/lib/feature/article/article.service.ts
@@ -13,7 +13,6 @@ export const articleEntityToModel = (
articleEntity: ArticleEntity
): ArticleModel => {
return {
- id: articleEntity.id,
title: articleEntity.title,
slug: articleEntity.slug,
description: articleEntity.description,
@@ -123,21 +122,21 @@ export const saveArticle: (
);
/** Updates an existing article in the database. */
-export const updateArticle: (
- articleId: string,
+export const updateArticleByExternalId: (
+ externalId: string,
article: UpdateArticleModel
) => Promise> = wrap(
async (
- articleId: string,
+ externalId: string,
article: UpdateArticleModel
): Promise => {
const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({
- id: articleId,
+ externalId: externalId,
});
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;
@@ -155,16 +154,19 @@ export const updateArticle: (
);
/** Deletes an article from the database. */
-export const deleteArticle: (articleId: string) => Promise> =
- wrap(async (articleId: string): Promise => {
+export const deleteArticleByExternalId: (
+ externalId: UUIDv4
+) => Promise> = wrap(
+ async (externalId: UUIDv4): Promise => {
const articleRepository = await getRepository(ArticleEntity);
const existingArticle = await articleRepository.findOneBy({
- id: articleId,
+ externalId: externalId,
});
if (!existingArticle) {
- throw new Error(`Article with ID ${articleId} not found`);
+ throw new Error(`Article with ExternalID ${externalId} not found`);
}
await articleRepository.remove(existingArticle);
- });
+ }
+);
diff --git a/src/lib/storage/storage.adapter.ts b/src/lib/storage/storage.adapter.ts
index 02626ae..6a6ad85 100644
--- a/src/lib/storage/storage.adapter.ts
+++ b/src/lib/storage/storage.adapter.ts
@@ -3,7 +3,6 @@ import { TypedResult, wrap } from '@/utils/types/results';
import {
DeleteObjectCommand,
HeadObjectCommand,
- ObjectCannedACL,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
@@ -19,6 +18,7 @@ export const S3StorageConfig = z.object({
region: z.string(),
accessKey: z.string(),
secretKey: z.string(),
+ publicUrl: z.string().optional(),
});
export type S3StorageConfig = z.infer;
@@ -28,7 +28,7 @@ export type S3StorageConfig = z.infer;
*/
export class S3StorageAdapter implements StorageProvider {
private readonly s3Client: S3Client;
- private readonly endpoint: string;
+ private readonly publicUrl: string;
private readonly bucketName: string;
readonly get: (
@@ -45,8 +45,9 @@ export class S3StorageAdapter implements StorageProvider {
) => Promise>;
constructor(config: S3StorageConfig, s3Client?: S3Client) {
- this.endpoint = config.endpoint;
this.bucketName = config.bucket;
+ this.publicUrl = config.publicUrl || config.endpoint;
+
this.s3Client =
s3Client ||
new S3Client({
@@ -57,6 +58,8 @@ export class S3StorageAdapter implements StorageProvider {
accessKeyId: config.accessKey,
secretAccessKey: config.secretKey,
},
+ requestChecksumCalculation: 'WHEN_REQUIRED',
+ responseChecksumValidation: 'WHEN_REQUIRED',
});
this.get = wrap(this._get.bind(this));
@@ -66,7 +69,7 @@ export class S3StorageAdapter implements StorageProvider {
}
private async _get(key: string): Promise {
- return `${this.endpoint}/${this.bucketName}/${key}`;
+ return `${this.publicUrl}/${key}`;
}
private async _put(key: string, contentType: string): Promise {
@@ -74,7 +77,6 @@ export class S3StorageAdapter implements StorageProvider {
Bucket: this.bucketName,
Key: key,
ContentType: contentType,
- ACL: ObjectCannedACL.public_read,
});
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
@@ -111,6 +113,7 @@ export const new_s3_storage_adapter = (): S3StorageAdapter => {
region: process.env.S3_REGION,
accessKey: process.env.S3_ACCESS_KEY,
secretKey: process.env.S3_SECRET_KEY,
+ publicUrl: process.env.S3_PUBLIC_URL,
});
return new S3StorageAdapter(config);
};
diff --git a/src/lib/storage/storage.external.ts b/src/lib/storage/storage.external.ts
index a97ebeb..81d4638 100644
--- a/src/lib/storage/storage.external.ts
+++ b/src/lib/storage/storage.external.ts
@@ -7,17 +7,13 @@ import { TypedResult } from '@/utils/types/results';
const storage: StorageProvider = createStorageProvider();
-export const getSignedUrl = async (
+export const getPublicUrl = async (
key: string,
storageProvider?: StorageProvider
): Promise> => {
if (!storageProvider) {
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);
};
diff --git a/src/lib/storage/storage.utils.ts b/src/lib/storage/storage.utils.ts
index 17461f8..ec88f6f 100644
--- a/src/lib/storage/storage.utils.ts
+++ b/src/lib/storage/storage.utils.ts
@@ -26,7 +26,7 @@ export const uploadFile = wrap(async (file: File) => {
const existsResult = await storage.checkExists(fileKey);
if (existsResult) {
- const presignedUrl = await storage.getSignedUrl(fileKey);
+ const presignedUrl = await storage.getPublicUrl(fileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
@@ -37,6 +37,7 @@ export const uploadFile = wrap(async (file: File) => {
if (!result.ok) {
throw new Error('File upload failed');
}
+ console.log(result.value);
const response = await fetch(result.value, {
method: 'PUT',
@@ -48,7 +49,7 @@ export const uploadFile = wrap(async (file: File) => {
throw new Error('Failed to upload file');
}
- const presignedUrl = await storage.getSignedUrl(fileKey);
+ const presignedUrl = await storage.getPublicUrl(fileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
diff --git a/src/proxy.ts b/src/proxy.ts
index daeadd5..ebc2257 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -5,6 +5,7 @@ import { NextResponse } from 'next/server';
const isPublic = createRouteMatcher([
'/home(.*)?',
'/about(.*)?',
+ '/article(.*)?',
'/api/user(.*)?',
]);
diff --git a/src/ui/components/internal/article/admin-article-card.tsx b/src/ui/components/internal/article/admin-article-card.tsx
new file mode 100644
index 0000000..52ab98e
--- /dev/null
+++ b/src/ui/components/internal/article/admin-article-card.tsx
@@ -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 (
+
+
+ {article.coverImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+ {'{ }'}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {article.title}
+
+
+
+ {article.description}
+
+
+
+
+
+ );
+};
diff --git a/src/ui/components/internal/article/admin-article-list-skeleton.tsx b/src/ui/components/internal/article/admin-article-list-skeleton.tsx
new file mode 100644
index 0000000..a3f1562
--- /dev/null
+++ b/src/ui/components/internal/article/admin-article-list-skeleton.tsx
@@ -0,0 +1,67 @@
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/ui/components/shadcn/table';
+
+const AdminArticleRowSkeleton = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+export const AdminArticleListSkeleton = ({
+ skeletonSize,
+}: {
+ skeletonSize: number;
+}) => (
+ <>
+
+
+
+
+
+ Cover
+ Title
+ Description
+ Published
+
+ Actions
+
+
+
+
+ {Array.from({ length: skeletonSize }).map((_, i) => (
+
+ ))}
+
+
+
+ >
+);
diff --git a/src/ui/components/internal/article/admin-article-list.tsx b/src/ui/components/internal/article/admin-article-list.tsx
new file mode 100644
index 0000000..4d0ba1b
--- /dev/null
+++ b/src/ui/components/internal/article/admin-article-list.tsx
@@ -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 (
+
+
+
+ Failed to load articles. Please try again later.
+
+
+ );
+ }
+
+ const {
+ data: articles,
+ totalPages,
+ total,
+ page: currentPage,
+ } = paginationResult.value;
+
+ if (articles.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ {total} article{total === 1 ? '' : 's'} published
+
+
+
+
+
+
+ Cover
+ Title
+ Description
+ Published
+
+ Actions
+
+
+
+
+ {articles.map((article) => (
+
+
+
+ {article.coverImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+ {'{}'}
+
+
+ )}
+
+
+
+
+
+ {article.title}
+
+
+ /{article.slug}
+
+
+
+
+
+ {article.description}
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+ {totalPages > 1 && (
+
+
+ Page {currentPage} of {totalPages}
+
+
+
+ )}
+ >
+ );
+};
diff --git a/src/ui/components/internal/article/article-list-skeleton.tsx b/src/ui/components/internal/article/article-list-skeleton.tsx
new file mode 100644
index 0000000..e9a74d0
--- /dev/null
+++ b/src/ui/components/internal/article/article-list-skeleton.tsx
@@ -0,0 +1,33 @@
+export const ArticleCardSkeleton = () => (
+
+);
+
+export const ArticleListSkeleton = ({
+ skeletonSize,
+}: {
+ skeletonSize: number;
+}) => (
+ <>
+
+
+ {Array.from({ length: skeletonSize }).map((_, i) => (
+
+ ))}
+
+ >
+);
diff --git a/src/ui/components/internal/article/article-list.tsx b/src/ui/components/internal/article/article-list.tsx
new file mode 100644
index 0000000..303d25e
--- /dev/null
+++ b/src/ui/components/internal/article/article-list.tsx
@@ -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 (
+
+
+
+ Failed to load articles. Please try again later.
+
+
+ );
+ }
+ const { data: articles, totalPages, total } = paginationResult.value;
+
+ return (
+ <>
+
+ {total === 0
+ ? 'No articles published yet.'
+ : `${total} article${total === 1 ? '' : 's'} published`}
+
+
+ {articles.length === 0 ? (
+
+
+
+ No articles yet. Check back soon!
+
+
+ ) : (
+
+ {articles.map((article) => (
+
+ ))}
+
+ )}
+
+ {totalPages > 1 && (
+
+
+ Page {page} of {totalPages}
+
+
+
+ )}
+ >
+ );
+};
diff --git a/src/ui/components/internal/article/delete-article-button.tsx b/src/ui/components/internal/article/delete-article-button.tsx
new file mode 100644
index 0000000..cd74c32
--- /dev/null
+++ b/src/ui/components/internal/article/delete-article-button.tsx
@@ -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 (
+
+
+
+
+
+
+ Delete article?
+
+ This will permanently delete “{title}
+ ”. This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/ui/components/internal/update-article-form.tsx b/src/ui/components/internal/update-article-form.tsx
new file mode 100644
index 0000000..33ff894
--- /dev/null
+++ b/src/ui/components/internal/update-article-form.tsx
@@ -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(null);
+ const [coverImageUploading, setCoverImageUploading] = useState(false);
+ const [contentFile, setContentFile] = useState(null);
+ const coverImageUrlRef = useRef(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>({
+ 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) => {
+ 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 (
+
+ );
+};
+
+export default UpdateArticleForm;
diff --git a/src/ui/components/shadcn/alert-dialog.tsx b/src/ui/components/shadcn/alert-dialog.tsx
new file mode 100644
index 0000000..6ca0bbb
--- /dev/null
+++ b/src/ui/components/shadcn/alert-dialog.tsx
@@ -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) {
+ return ;
+}
+
+function AlertDialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogContent({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+function AlertDialogHeader({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogFooter({
+ className,
+ ...props
+}: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function AlertDialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogAction({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function AlertDialogCancel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ AlertDialog,
+ AlertDialogTrigger,
+ AlertDialogPortal,
+ AlertDialogOverlay,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogFooter,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogAction,
+ AlertDialogCancel,
+};
diff --git a/src/ui/components/shadcn/table.tsx b/src/ui/components/shadcn/table.tsx
new file mode 100644
index 0000000..2056a71
--- /dev/null
+++ b/src/ui/components/shadcn/table.tsx
@@ -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 (
+
+ );
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
+ return (
+
+ );
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
+ return (
+
+ );
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
+ return (
+ tr]:last:border-b-0',
+ className
+ )}
+ {...props}
+ />
+ );
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+ return (
+
+ );
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+ return (
+ |
+ );
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+ return (
+ |
+ );
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<'caption'>) {
+ return (
+
+ );
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+};
diff --git a/src/utils/types/results.ts b/src/utils/types/results.ts
index c93eca8..61150c0 100644
--- a/src/utils/types/results.ts
+++ b/src/utils/types/results.ts
@@ -1,22 +1,8 @@
+import { unstable_cache } from 'next/cache';
+
export type Result = { ok: true; value: T } | { ok: false; error: E };
export type TypedResult = Result;
-export function wrapBlocking<
- F extends (...args: never[]) => unknown,
- E = unknown,
->(
- fn: F,
- mapError: (e: unknown) => E = (e) => e as E
-): (...args: Parameters) => Result, E> {
- return (...args) => {
- try {
- return { ok: true, value: fn(...args) as ReturnType };
- } catch (e) {
- return { ok: false, error: mapError(e) };
- }
- };
-}
-
export function wrap<
F extends (...args: never[]) => Promise,
E = unknown,
@@ -35,3 +21,43 @@ export function wrap<
}
};
}
+
+export function wrapCached<
+ F extends (...args: never[]) => Promise,
+ E = unknown,
+>(
+ fn: F,
+ options: {
+ key: (...args: Parameters) => string[];
+ tags?: (...args: Parameters) => string[];
+ revalidate?: number;
+ },
+ mapError: (e: unknown) => E = (e) => e as E
+): (...args: Parameters) => Promise>, E>> {
+ return async (...args: Parameters) => {
+ try {
+ const cachedFn = unstable_cache(
+ async (...innerArgs: Parameters) => {
+ 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>,
+ };
+ } catch (e) {
+ return {
+ ok: false,
+ error: mapError(e),
+ };
+ }
+ };
+}
diff --git a/src/utils/types/uuid.ts b/src/utils/types/uuid.ts
index bfd9b9f..5838c7a 100644
--- a/src/utils/types/uuid.ts
+++ b/src/utils/types/uuid.ts
@@ -1,22 +1,4 @@
-type HexChar =
- | '0'
- | '1'
- | '2'
- | '3'
- | '4'
- | '5'
- | '6'
- | '7'
- | '8'
- | '9'
- | 'a'
- | 'b'
- | 'c'
- | 'd'
- | 'e'
- | 'f';
-type UUIDv4Segment =
- `${HexChar extends string ? HexChar : never}${string extends `${Length}` ? never : never}`; // Simplified for brevity
+import { z } from 'zod';
-export type UUIDv4 =
- `${UUIDv4Segment<8>}-${UUIDv4Segment<4>}-4${UUIDv4Segment<3>}-${'8' | '9' | 'a' | 'b'}${UUIDv4Segment<3>}-${UUIDv4Segment<12>}`;
+export const UUIDv4 = z.uuid();
+export type UUIDv4 = z.infer;
diff --git a/tests/lib/feature/article/article.service.test.ts b/tests/lib/feature/article/article.service.test.ts
index 8d5ea16..13aceb4 100644
--- a/tests/lib/feature/article/article.service.test.ts
+++ b/tests/lib/feature/article/article.service.test.ts
@@ -1,15 +1,15 @@
-import { getArticlesPaginated } from '@/lib/feature/article/article.external';
import {
CreateArticleModel,
UpdateArticleModel,
} from '@/lib/feature/article/article.model';
import {
- deleteArticle,
+ deleteArticleByExternalId,
getArticleByExternalId,
getArticleBySlug,
getArticlesByAuthorId,
+ getArticlesPaginated,
saveArticle,
- updateArticle,
+ updateArticleByExternalId,
} from '@/lib/feature/article/article.service';
import { CreateUserModel } from '@/lib/feature/user/user.model';
import { saveUser } from '@/lib/feature/user/user.service';
@@ -56,7 +56,6 @@ describe('ArticleService', () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
- expect(result.value.id).toBeDefined();
expect(result.value.title).toBe(articleToSave.title);
expect(result.value.slug).toBe(articleToSave.slug);
expect(result.value.description).toBe(articleToSave.description);
@@ -136,7 +135,6 @@ describe('ArticleService', () => {
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBeDefined();
- expect(result.value?.id).toBe(article!.id);
expect(result.value?.title).toBe(article!.title);
expect(result.value?.slug).toBe(article!.slug);
expect(result.value?.externalId).toBe(article!.externalId);
@@ -219,12 +217,14 @@ describe('ArticleService', () => {
const article = slugResult.value;
expect(article).toBeDefined();
- const result = await updateArticle(article!.id, dataToUpdate);
+ const result = await updateArticleByExternalId(
+ article!.externalId,
+ dataToUpdate
+ );
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.value).toBeDefined();
- expect(result.value.id).toBe(article!.id);
expect(result.value.title).toBe(dataToUpdate.title);
expect(result.value.description).toBe(dataToUpdate.description);
expect(result.value.slug).toBe(article!.slug);
@@ -237,7 +237,7 @@ describe('ArticleService', () => {
title: 'Updated Article Title',
};
- const result = await updateArticle('9999', dataToUpdate);
+ const result = await updateArticleByExternalId('9999', dataToUpdate);
expect(result.ok).toBe(false);
if (result.ok) return;
@@ -257,9 +257,11 @@ describe('ArticleService', () => {
const saveResult = await saveArticle(articleToSave);
expect(saveResult.ok).toBe(true);
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);
const getResult = await getArticleBySlug('article-to-delete');
@@ -269,7 +271,7 @@ describe('ArticleService', () => {
});
test('cannot delete non-existing article', async () => {
- const result = await deleteArticle('9999');
+ const result = await deleteArticleByExternalId('9999');
expect(result.ok).toBe(false);
if (result.ok) return;
diff --git a/tests/lib/storage/storage.s3.test.ts b/tests/lib/storage/storage.s3.test.ts
index fbba29d..08819d8 100644
--- a/tests/lib/storage/storage.s3.test.ts
+++ b/tests/lib/storage/storage.s3.test.ts
@@ -23,6 +23,7 @@ describe('S3StorageAdapter', () => {
region: 'us-east-1',
accessKey: 'test-access-key',
secretKey: 'test-secret-key',
+ publicUrl: 'http://test.com',
};
beforeEach(() => {
@@ -44,7 +45,7 @@ describe('S3StorageAdapter', () => {
expect(result).toEqual({
ok: true,
- value: `http://localhost:9000/test-bucket/${key}`,
+ value: `http://test.com/${key}`,
});
});
@@ -55,7 +56,7 @@ describe('S3StorageAdapter', () => {
expect(result).toEqual({
ok: true,
- value: `http://localhost:9000/test-bucket/${key}`,
+ value: `http://test.com/${key}`,
});
});
});