Compare commits

..

10 Commits

12 changed files with 240 additions and 49 deletions

View File

@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
},
];
},
cacheComponents: true,
};
export default nextConfig;

View File

@@ -2,8 +2,8 @@ import CreateArticleForm from '@/ui/components/internal/create-article-form';
const AdminPage = async () => {
return (
<div className='container mx-auto py-10 min-h-3/4'>
<div className='h-full rounded-lg border border-border bg-card p-6'>
<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>

View File

@@ -2,12 +2,17 @@ import { getArticleBySlug } from '@/lib/feature/article/article.external';
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { Suspense } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
interface ArticlePageProps {
type ArticlePageProps = {
params: Promise<{ slug: string }>;
}
};
type ArticleContentProps = {
params: Promise<{ slug: string }>;
};
function readingTime(text: string): number {
const words = text.trim().split(/\s+/).length;
@@ -22,7 +27,48 @@ function formatDate(date: Date): string {
}).format(date);
}
const ArticlePage = async ({ params }: ArticlePageProps) => {
const ArticleContentSkeleton = () => (
<article className='w-full'>
<div className='h-64 w-full animate-pulse bg-muted md:h-96' />
<div className='container mx-auto max-w-3xl px-4'>
<div className='-mt-16 relative z-10'>
<div className='mb-6 h-4 w-24 animate-pulse rounded bg-muted' />
<header className='mb-10'>
<div className='mb-4 space-y-2'>
<div className='h-8 w-full animate-pulse rounded bg-muted' />
<div className='h-8 w-3/4 animate-pulse rounded bg-muted' />
</div>
<div className='mb-5 space-y-2'>
<div className='h-5 w-full animate-pulse rounded bg-muted' />
<div className='h-5 w-2/3 animate-pulse rounded bg-muted' />
</div>
<div className='flex gap-4'>
<div className='h-3 w-28 animate-pulse rounded bg-muted' />
<div className='h-3 w-16 animate-pulse rounded bg-muted' />
</div>
</header>
<hr className='mb-10 border-border' />
<div className='space-y-3 pb-16'>
{[...Array(4)].map((_, i) => (
<div key={i} className='space-y-2'>
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-full animate-pulse rounded bg-muted' />
<div className='h-4 w-5/6 animate-pulse rounded bg-muted' />
</div>
))}
</div>
</div>
</div>
</article>
);
const ArticleContent = async ({ params }: ArticleContentProps) => {
const { slug } = await params;
const article = await getArticleBySlug(slug);
@@ -96,4 +142,12 @@ const ArticlePage = async ({ params }: ArticlePageProps) => {
);
};
const ArticlePage = ({ params }: ArticlePageProps) => {
return (
<Suspense fallback={<ArticleContentSkeleton />}>
<ArticleContent params={params} />
</Suspense>
);
};
export default ArticlePage;

View File

@@ -2,38 +2,62 @@ 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 { Suspense } from 'react';
const PAGE_SIZE = 9;
interface HomeProps {
searchParams: Promise<{ page?: string }>;
}
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 Home = async ({ searchParams }: HomeProps) => {
const { page: pageParam } = await searchParams;
const ArticleListSkeleton = () => (
<>
<div className='mb-10 h-4 w-32 animate-pulse rounded bg-muted' />
<div className='grid gap-6 sm:grid-cols-2 xl:grid-cols-3'>
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<ArticleCardSkeleton key={i} />
))}
</div>
</>
);
type ArticleListProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>;
};
const ArticleList = async ({ searchParams }: ArticleListProps) => {
const { page: pageParam, pageSize: pageSizeParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
const pageSize = Number(pageSizeParam) || PAGE_SIZE;
const {
data: articles,
totalPages,
total,
} = await getArticlesPaginated(page, PAGE_SIZE);
} = await getArticlesPaginated(page, pageSize);
return (
<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'>
<p className='mb-1 font-mono text-xs font-medium uppercase tracking-widest text-muted-foreground'>
Dev Blog
</p>
<h1 className='text-3xl font-bold tracking-tight md:text-4xl'>
Latest Articles
</h1>
<p className='mt-2 text-muted-foreground'>
{total === 0
? 'No articles published yet.'
: `${total} article${total === 1 ? '' : 's'} published`}
</p>
</div>
<>
<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'>
@@ -64,6 +88,28 @@ const Home = async ({ searchParams }: HomeProps) => {
/>
</div>
)}
</>
);
};
type HomeProps = {
searchParams: Promise<{ page?: string; pageSize?: string }>;
};
const Home = async ({ searchParams }: HomeProps) => {
return (
<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'>
<p className='mb-1 font-mono text-xs font-medium uppercase tracking-widest text-muted-foreground'>
Dev Blog
</p>
<h1 className='text-3xl font-bold tracking-tight md:text-4xl'>
Latest Articles
</h1>
</div>
<Suspense fallback={<ArticleListSkeleton />}>
<ArticleList searchParams={searchParams} />
</Suspense>
</div>
);
};

View File

@@ -2,6 +2,7 @@ import { StorageProvider } from '@/lib/storage/storage.interface';
import { TypedResult, wrap } from '@/utils/types/results';
import {
DeleteObjectCommand,
HeadObjectCommand,
ObjectCannedACL,
PutObjectCommand,
S3Client,
@@ -36,6 +37,9 @@ export class S3StorageAdapter implements StorageProvider {
readonly put: (
...args: Parameters<StorageProvider['put']>
) => Promise<TypedResult<string>>;
readonly exists: (
...args: Parameters<StorageProvider['exists']>
) => Promise<boolean>;
readonly delete: (
...args: Parameters<StorageProvider['delete']>
) => Promise<TypedResult<void>>;
@@ -58,6 +62,7 @@ export class S3StorageAdapter implements StorageProvider {
this.get = wrap(this._get.bind(this));
this.put = wrap(this._put.bind(this));
this.delete = wrap(this._delete.bind(this));
this.exists = this._exists.bind(this);
}
private async _get(key: string): Promise<string> {
@@ -75,6 +80,20 @@ export class S3StorageAdapter implements StorageProvider {
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
}
private async _exists(key: string): Promise<boolean> {
try {
await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucketName,
Key: key,
})
);
return true;
} catch {
return false;
}
}
private async _delete(key: string): Promise<void> {
await this.s3Client.send(
new DeleteObjectCommand({

View File

@@ -16,6 +16,16 @@ export const getSignedUrl = async (
return await storageProvider.get(key);
};
export const checkExists = async (
key: string,
storageProvider?: StorageProvider
): Promise<boolean> => {
if (!storageProvider) {
storageProvider = storage;
}
return await storageProvider.exists(key);
};
export const getPutUrl = async (
key: string,
contentType: string,

View File

@@ -28,6 +28,12 @@ export interface StorageProvider {
*/
put(key: string, contentType: string): Promise<TypedResult<string>>;
/**
* Checks whether a file exists in storage
* @param key - The unique key/path of the file to check
*/
exists(key: string): Promise<boolean>;
/**
* Deletes a file from storage
* @param key - The unique key/path of the file to delete

View File

@@ -11,18 +11,36 @@ export const FileUploadResp = z.object({
});
export type FileUploadResp = z.infer<StorageProvider>;
async function hashFile(file: File): Promise<string> {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hex = Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
const ext = file.name.split('.').pop();
return ext ? `${hex}.${ext}` : hex;
}
export const uploadFile = wrap(async (file: File) => {
const uniqueFileKey = crypto.randomUUID();
const result = await storage.getPutUrl(uniqueFileKey, file.type);
const fileKey = await hashFile(file);
const existsResult = await storage.checkExists(fileKey);
if (existsResult) {
const presignedUrl = await storage.getSignedUrl(fileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
return { signedUrl: presignedUrl.value, key: fileKey };
}
const result = await storage.getPutUrl(fileKey, file.type);
if (!result.ok) {
throw new Error('File upload failed');
}
const response = await fetch(result.value, {
method: 'PUT',
headers: {
'Content-Type': file.type,
},
headers: { 'Content-Type': file.type },
body: file,
});
@@ -30,12 +48,9 @@ export const uploadFile = wrap(async (file: File) => {
throw new Error('Failed to upload file');
}
const presignedUrl = await storage.getSignedUrl(uniqueFileKey);
const presignedUrl = await storage.getSignedUrl(fileKey);
if (!presignedUrl.ok) {
throw new Error('Failed to retrieve file URL');
}
return {
signedUrl: presignedUrl.value,
key: uniqueFileKey,
};
return { signedUrl: presignedUrl.value, key: fileKey };
});

View File

@@ -54,6 +54,7 @@ function validateContentFile(file: File): string | null {
export const CreateArticleForm = () => {
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);
@@ -78,9 +79,10 @@ export const CreateArticleForm = () => {
},
});
const title = useWatch({
const title = useWatch({ control: form.control, name: 'title' });
const coverImageUrl = useWatch({
control: form.control,
name: 'title',
name: 'coverImageUrl',
});
useEffect(() => {
if (!title) return;
@@ -93,6 +95,7 @@ export const CreateArticleForm = () => {
coverImageUrlRef.current = null;
}
setCoverImageFile(null);
setCoverImageUploading(false);
setContentFile(null);
}, []);
@@ -128,10 +131,13 @@ export const CreateArticleForm = () => {
setCoverImageFile(file);
if (!file) {
setCoverImageFile(null);
setCoverImageUploading(false);
form.setValue('coverImageUrl', '');
return;
}
setCoverImageUploading(true);
const fileMetadataResult = await uploadFile(file);
setCoverImageUploading(false);
if (!fileMetadataResult.ok) {
setCoverImageFile(null);
form.setValue('coverImageUrl', '');
@@ -255,6 +261,8 @@ export const CreateArticleForm = () => {
label='Cover image'
description='PNG, JPG, GIF, WebP accepted'
error={form.formState.errors.coverImageUrl?.message}
previewUrl={coverImageUrl || undefined}
isUploading={coverImageUploading}
icon={
<Image
src={ImageLogo}

View File

@@ -16,6 +16,7 @@ import {
FileUploadList,
FileUploadTrigger,
} from '@/ui/components/shadcn/file-upload';
import { Spinner } from '@/ui/components/shadcn/spinner';
import { X } from 'lucide-react';
import React, { useCallback } from 'react';
@@ -29,6 +30,8 @@ export interface FileUploadFieldProps {
description?: string;
error?: string;
icon?: React.ReactNode;
previewUrl?: string;
isUploading?: boolean;
}
export const FileUploadField: React.FC<FileUploadFieldProps> = ({
@@ -41,6 +44,8 @@ export const FileUploadField: React.FC<FileUploadFieldProps> = ({
description,
error,
icon,
previewUrl,
isUploading,
}) => {
const handleAccept = useCallback(
(files: File[]) => {
@@ -90,7 +95,24 @@ export const FileUploadField: React.FC<FileUploadFieldProps> = ({
<FileUploadList>
{file && (
<FileUploadItem value={file}>
<FileUploadItemPreview />
<FileUploadItemPreview
render={
isUploading
? () => (
<Spinner className='size-5 text-muted-foreground' />
)
: previewUrl
? () => (
// eslint-disable-next-line @next/next/no-img-element
<img
src={previewUrl}
alt='Uploaded image'
className='size-full object-cover'
/>
)
: undefined
}
/>
<FileUploadItemMetadata size='sm' />
<FileUploadItemDelete asChild>
<Button

View File

@@ -18,9 +18,24 @@ export const DynamicDesktopHeader = () => {
const user = sessionData?.user;
const links = [
{ href: '/home', label: 'Home', condition: true, active: pathname === '/home' },
{ href: '/about', label: 'About', condition: true, active: pathname === '/about' },
{ href: '/admin', label: 'Admin', condition: !!user, active: pathname === '/admin' },
{
href: '/home',
label: 'Home',
condition: true,
active: pathname === '/home',
},
{
href: '/about',
label: 'About',
condition: true,
active: pathname === '/about',
},
{
href: '/admin',
label: 'Admin',
condition: !!user,
active: pathname === '/admin',
},
];
const userButton = !!user ? <UserButton user={user} /> : <div />;

View File

@@ -1,13 +1,8 @@
import { siteConfig } from '@/site.config';
export function SiteFooter() {
const buildCopyrightYear = (): string => {
if (siteConfig.copyright.initialYear == new Date().getFullYear()) {
return `(${new Date().getFullYear()})`;
}
return `(${siteConfig.copyright.initialYear}-${new Date().getFullYear()})`;
};
const copyrightHolder =
siteConfig.copyright.company || siteConfig.author.name;
return (
<footer className='w-full'>
<div className='footer-height container mx-auto flex flex-col items-center justify-between gap-4 md:flex-row'>
@@ -21,7 +16,7 @@ export function SiteFooter() {
>
{siteConfig.author.name}
</a>
. ©{buildCopyrightYear()} {siteConfig.copyright.company}.
. © {siteConfig.copyright.initialYear} {copyrightHolder}.
All rights reserved.
</p>
</div>