Compare commits
10 Commits
242395a739
...
12f2976837
| Author | SHA1 | Date | |
|---|---|---|---|
|
12f2976837
|
|||
|
f62e1c4180
|
|||
|
34a662fb30
|
|||
|
452470161d
|
|||
|
d7036557d0
|
|||
|
dbb459a3a5
|
|||
|
e1c6e9d923
|
|||
|
c4a22a7583
|
|||
|
d99ab44ec2
|
|||
|
7943527106
|
@@ -10,6 +10,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
cacheComponents: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import CreateArticleForm from '@/ui/components/internal/create-article-form';
|
|||||||
|
|
||||||
const AdminPage = async () => {
|
const AdminPage = async () => {
|
||||||
return (
|
return (
|
||||||
<div className='container mx-auto py-10 min-h-3/4'>
|
<div className='container mx-auto px-4 py-10 min-h-3/4'>
|
||||||
<div className='h-full rounded-lg border border-border bg-card p-6'>
|
<div className='rounded-lg border border-border p-6'>
|
||||||
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
|
<h2 className='mb-6 text-2xl font-bold'>Create New Article</h2>
|
||||||
<CreateArticleForm />
|
<CreateArticleForm />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import { getArticleBySlug } from '@/lib/feature/article/article.external';
|
|||||||
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
|
import { ArrowLeftIcon, CalendarIcon, ClockIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Suspense } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
interface ArticlePageProps {
|
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;
|
||||||
@@ -22,7 +27,48 @@ function formatDate(date: Date): string {
|
|||||||
}).format(date);
|
}).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 { slug } = await params;
|
||||||
const article = await getArticleBySlug(slug);
|
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;
|
export default ArticlePage;
|
||||||
|
|||||||
@@ -2,38 +2,62 @@ import { getArticlesPaginated } from '@/lib/feature/article/article.external';
|
|||||||
import { ArticleCard } from '@/ui/components/internal/article-card';
|
import { ArticleCard } from '@/ui/components/internal/article-card';
|
||||||
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
import { ArticleListPagination } from '@/ui/components/internal/article-list-pagination';
|
||||||
import { FileTextIcon } from 'lucide-react';
|
import { FileTextIcon } from 'lucide-react';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
const PAGE_SIZE = 9;
|
const PAGE_SIZE = 9;
|
||||||
|
|
||||||
interface HomeProps {
|
const ArticleCardSkeleton = () => (
|
||||||
searchParams: Promise<{ page?: string }>;
|
<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 ArticleListSkeleton = () => (
|
||||||
const { page: pageParam } = await searchParams;
|
<>
|
||||||
|
<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 page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
const pageSize = Number(pageSizeParam) || PAGE_SIZE;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: articles,
|
data: articles,
|
||||||
totalPages,
|
totalPages,
|
||||||
total,
|
total,
|
||||||
} = await getArticlesPaginated(page, PAGE_SIZE);
|
} = await getArticlesPaginated(page, pageSize);
|
||||||
|
|
||||||
return (
|
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-10 text-muted-foreground'>
|
||||||
<p className='mb-1 font-mono text-xs font-medium uppercase tracking-widest text-muted-foreground'>
|
{total === 0
|
||||||
Dev Blog
|
? 'No articles published yet.'
|
||||||
</p>
|
: `${total} article${total === 1 ? '' : 's'} published`}
|
||||||
<h1 className='text-3xl font-bold tracking-tight md:text-4xl'>
|
</p>
|
||||||
Latest Articles
|
|
||||||
</h1>
|
|
||||||
<p className='mt-2 text-muted-foreground'>
|
|
||||||
{total === 0
|
|
||||||
? 'No articles published yet.'
|
|
||||||
: `${total} article${total === 1 ? '' : 's'} published`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{articles.length === 0 ? (
|
{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'>
|
<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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { StorageProvider } from '@/lib/storage/storage.interface';
|
|||||||
import { TypedResult, wrap } from '@/utils/types/results';
|
import { TypedResult, wrap } from '@/utils/types/results';
|
||||||
import {
|
import {
|
||||||
DeleteObjectCommand,
|
DeleteObjectCommand,
|
||||||
|
HeadObjectCommand,
|
||||||
ObjectCannedACL,
|
ObjectCannedACL,
|
||||||
PutObjectCommand,
|
PutObjectCommand,
|
||||||
S3Client,
|
S3Client,
|
||||||
@@ -36,6 +37,9 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
readonly put: (
|
readonly put: (
|
||||||
...args: Parameters<StorageProvider['put']>
|
...args: Parameters<StorageProvider['put']>
|
||||||
) => Promise<TypedResult<string>>;
|
) => Promise<TypedResult<string>>;
|
||||||
|
readonly exists: (
|
||||||
|
...args: Parameters<StorageProvider['exists']>
|
||||||
|
) => Promise<boolean>;
|
||||||
readonly delete: (
|
readonly delete: (
|
||||||
...args: Parameters<StorageProvider['delete']>
|
...args: Parameters<StorageProvider['delete']>
|
||||||
) => Promise<TypedResult<void>>;
|
) => Promise<TypedResult<void>>;
|
||||||
@@ -58,6 +62,7 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
this.get = wrap(this._get.bind(this));
|
this.get = wrap(this._get.bind(this));
|
||||||
this.put = wrap(this._put.bind(this));
|
this.put = wrap(this._put.bind(this));
|
||||||
this.delete = wrap(this._delete.bind(this));
|
this.delete = wrap(this._delete.bind(this));
|
||||||
|
this.exists = this._exists.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _get(key: string): Promise<string> {
|
private async _get(key: string): Promise<string> {
|
||||||
@@ -75,6 +80,20 @@ export class S3StorageAdapter implements StorageProvider {
|
|||||||
return await getSignedUrl(this.s3Client, command, { expiresIn: 3600 });
|
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> {
|
private async _delete(key: string): Promise<void> {
|
||||||
await this.s3Client.send(
|
await this.s3Client.send(
|
||||||
new DeleteObjectCommand({
|
new DeleteObjectCommand({
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ export const getSignedUrl = async (
|
|||||||
return await storageProvider.get(key);
|
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 (
|
export const getPutUrl = async (
|
||||||
key: string,
|
key: string,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export interface StorageProvider {
|
|||||||
*/
|
*/
|
||||||
put(key: string, contentType: string): Promise<TypedResult<string>>;
|
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
|
* Deletes a file from storage
|
||||||
* @param key - The unique key/path of the file to delete
|
* @param key - The unique key/path of the file to delete
|
||||||
|
|||||||
@@ -11,18 +11,36 @@ export const FileUploadResp = z.object({
|
|||||||
});
|
});
|
||||||
export type FileUploadResp = z.infer<StorageProvider>;
|
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) => {
|
export const uploadFile = wrap(async (file: File) => {
|
||||||
const uniqueFileKey = crypto.randomUUID();
|
const fileKey = await hashFile(file);
|
||||||
const result = await storage.getPutUrl(uniqueFileKey, file.type);
|
|
||||||
|
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) {
|
if (!result.ok) {
|
||||||
throw new Error('File upload failed');
|
throw new Error('File upload failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(result.value, {
|
const response = await fetch(result.value, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: { 'Content-Type': file.type },
|
||||||
'Content-Type': file.type,
|
|
||||||
},
|
|
||||||
body: file,
|
body: file,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,12 +48,9 @@ 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(uniqueFileKey);
|
const presignedUrl = await storage.getSignedUrl(fileKey);
|
||||||
if (!presignedUrl.ok) {
|
if (!presignedUrl.ok) {
|
||||||
throw new Error('Failed to retrieve file URL');
|
throw new Error('Failed to retrieve file URL');
|
||||||
}
|
}
|
||||||
return {
|
return { signedUrl: presignedUrl.value, key: fileKey };
|
||||||
signedUrl: presignedUrl.value,
|
|
||||||
key: uniqueFileKey,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ function validateContentFile(file: File): string | null {
|
|||||||
|
|
||||||
export const CreateArticleForm = () => {
|
export const CreateArticleForm = () => {
|
||||||
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
|
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
|
||||||
|
const [coverImageUploading, setCoverImageUploading] = useState(false);
|
||||||
const [contentFile, setContentFile] = useState<File | null>(null);
|
const [contentFile, setContentFile] = useState<File | null>(null);
|
||||||
const coverImageUrlRef = useRef<string | 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,
|
control: form.control,
|
||||||
name: 'title',
|
name: 'coverImageUrl',
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
@@ -93,6 +95,7 @@ export const CreateArticleForm = () => {
|
|||||||
coverImageUrlRef.current = null;
|
coverImageUrlRef.current = null;
|
||||||
}
|
}
|
||||||
setCoverImageFile(null);
|
setCoverImageFile(null);
|
||||||
|
setCoverImageUploading(false);
|
||||||
setContentFile(null);
|
setContentFile(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -128,10 +131,13 @@ export const CreateArticleForm = () => {
|
|||||||
setCoverImageFile(file);
|
setCoverImageFile(file);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setCoverImageFile(null);
|
setCoverImageFile(null);
|
||||||
|
setCoverImageUploading(false);
|
||||||
form.setValue('coverImageUrl', '');
|
form.setValue('coverImageUrl', '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
setCoverImageUploading(true);
|
||||||
const fileMetadataResult = await uploadFile(file);
|
const fileMetadataResult = await uploadFile(file);
|
||||||
|
setCoverImageUploading(false);
|
||||||
if (!fileMetadataResult.ok) {
|
if (!fileMetadataResult.ok) {
|
||||||
setCoverImageFile(null);
|
setCoverImageFile(null);
|
||||||
form.setValue('coverImageUrl', '');
|
form.setValue('coverImageUrl', '');
|
||||||
@@ -255,6 +261,8 @@ export const CreateArticleForm = () => {
|
|||||||
label='Cover image'
|
label='Cover image'
|
||||||
description='PNG, JPG, GIF, WebP accepted'
|
description='PNG, JPG, GIF, WebP accepted'
|
||||||
error={form.formState.errors.coverImageUrl?.message}
|
error={form.formState.errors.coverImageUrl?.message}
|
||||||
|
previewUrl={coverImageUrl || undefined}
|
||||||
|
isUploading={coverImageUploading}
|
||||||
icon={
|
icon={
|
||||||
<Image
|
<Image
|
||||||
src={ImageLogo}
|
src={ImageLogo}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
FileUploadList,
|
FileUploadList,
|
||||||
FileUploadTrigger,
|
FileUploadTrigger,
|
||||||
} from '@/ui/components/shadcn/file-upload';
|
} from '@/ui/components/shadcn/file-upload';
|
||||||
|
import { Spinner } from '@/ui/components/shadcn/spinner';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ export interface FileUploadFieldProps {
|
|||||||
description?: string;
|
description?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
previewUrl?: string;
|
||||||
|
isUploading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileUploadField: React.FC<FileUploadFieldProps> = ({
|
export const FileUploadField: React.FC<FileUploadFieldProps> = ({
|
||||||
@@ -41,6 +44,8 @@ export const FileUploadField: React.FC<FileUploadFieldProps> = ({
|
|||||||
description,
|
description,
|
||||||
error,
|
error,
|
||||||
icon,
|
icon,
|
||||||
|
previewUrl,
|
||||||
|
isUploading,
|
||||||
}) => {
|
}) => {
|
||||||
const handleAccept = useCallback(
|
const handleAccept = useCallback(
|
||||||
(files: File[]) => {
|
(files: File[]) => {
|
||||||
@@ -90,7 +95,24 @@ export const FileUploadField: React.FC<FileUploadFieldProps> = ({
|
|||||||
<FileUploadList>
|
<FileUploadList>
|
||||||
{file && (
|
{file && (
|
||||||
<FileUploadItem value={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' />
|
<FileUploadItemMetadata size='sm' />
|
||||||
<FileUploadItemDelete asChild>
|
<FileUploadItemDelete asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -18,9 +18,24 @@ export const DynamicDesktopHeader = () => {
|
|||||||
const user = sessionData?.user;
|
const user = sessionData?.user;
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: '/home', label: 'Home', condition: true, active: pathname === '/home' },
|
{
|
||||||
{ href: '/about', label: 'About', condition: true, active: pathname === '/about' },
|
href: '/home',
|
||||||
{ href: '/admin', label: 'Admin', condition: !!user, active: pathname === '/admin' },
|
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 />;
|
const userButton = !!user ? <UserButton user={user} /> : <div />;
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { siteConfig } from '@/site.config';
|
import { siteConfig } from '@/site.config';
|
||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
const buildCopyrightYear = (): string => {
|
const copyrightHolder =
|
||||||
if (siteConfig.copyright.initialYear == new Date().getFullYear()) {
|
siteConfig.copyright.company || siteConfig.author.name;
|
||||||
return `(${new Date().getFullYear()})`;
|
|
||||||
}
|
|
||||||
return `(${siteConfig.copyright.initialYear}-${new Date().getFullYear()})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className='w-full'>
|
<footer className='w-full'>
|
||||||
<div className='footer-height container mx-auto flex flex-col items-center justify-between gap-4 md:flex-row'>
|
<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}
|
{siteConfig.author.name}
|
||||||
</a>
|
</a>
|
||||||
. ©{buildCopyrightYear()} {siteConfig.copyright.company}.
|
. © {siteConfig.copyright.initialYear} {copyrightHolder}.
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user