feat: image previews

This commit is contained in:
2025-01-05 18:04:37 +01:00
parent 6dd9cefac4
commit 92837fb74f
11 changed files with 65 additions and 42 deletions

View File

@@ -1,24 +1,5 @@
# Sr Izan Stack
# nextbooru
Sr Izan Stack is a next.js template which runs on modern technologies, with a focus on developer experience and ease-of-use.
Nextbooru is a simple image board software written with modern web technologies. It is designed to be fast and clean.
## The stack
- Framework: [Next.js](https://nextjs.org/)
- Language: [TypeScript](https://www.typescriptlang.org/)
- Styling: [Tailwind CSS](https://tailwindcss.com/)
- UI Library: [shadcn/ui](https://ui.shadcn.com)
- Authentication: [Lucia](https://lucia-auth.com)
- Deployment: [Vercel](https://vercel.com)
- Database: [Supabase Postgres](https://supabase.com) with [Prisma](https://www.prisma.io/)
## Why (insert tool here)?
- **Next.js**: I like the next.js app router because it has a very good developer experience and it's very easy to use.
- **TypeScript**: Don't even need to explain why
- **Tailwind CSS**: I like the utility-first approach and the speed of development it provides
- **shadcn/ui**: Copy-pasting components is so fire (also is Radix UI)
- **Lucia**: The DevEX is amazing and it's very easy to use
- **Vercel**: Next.js and Vercel are like bread and butter, but it's a bit slow with the free tier.
- **MongoDB Atlas**: It has a very generous free tier, and I'm choosing NoSQL because Postgres hates me.
- **Prisma**: Even though there are solid competitors like Drizzle, Prisma is easy to use, understand, and fast enough for my use case.
The software is very unstable and is not recommended for production use. For now, a testing server is available at [nextbooru.srizan.dev](https://nextbooru.srizan.dev).

View File

@@ -3,6 +3,11 @@ const nextConfig = {
webpack: (config) => {
config.externals.push("@node-rs/argon2");
return config;
},
experimental: {
serverActions: {
bodySizeLimit: '20mb'
}
}
};

View File

@@ -31,6 +31,7 @@
"react": "19",
"react-dom": "19",
"react-hook-form": "^7.54.2",
"sharp": "^0.33.5",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `previewHash` to the `Post` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "Post" ADD COLUMN "previewHash" TEXT NOT NULL;

View File

@@ -36,4 +36,5 @@ model Post {
tags String[]
authorId String
author User @relation(references: [id], fields: [authorId], onDelete: Cascade)
previewHash String
}

View File

@@ -10,7 +10,7 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
const { id } = await params;
const post = await prisma.post.findUnique({ where: { id }, include: { author: true } });
if (!post) {
return <div>Post not found</div>;
return <h1>Post not found</h1>;
}
return (
@@ -23,9 +23,10 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<Image
src={post.imageUrl}
alt="Post image"
layout="fill"
objectFit="contain"
className="rounded-t-lg"
fill
className="rounded-t-lg object-contain"
blurDataURL={`data:image/jpeg;base64,${post.previewHash}`}
placeholder="blur"
/>
</div>
<div className="p-4 flex justify-between items-center">
@@ -61,7 +62,12 @@ export default async function Page({ params }: { params: Promise<{ id: string }>
<Calendar className="h-6 w-6" />
<span>Uploaded on: {post.createdAt.toLocaleString()}</span>
</div>
<Separator className='my-6' />
<Separator className="my-6" />
<div className="grid gap-4">
<h3 className="text-xl font-bold">Description</h3>
<p>{post.caption}</p>
</div>
<Separator className="my-6" />
<div className="grid gap-4">
<h3 className="text-xl font-bold">Tags</h3>
<div className="flex gap-2">

View File

@@ -38,7 +38,7 @@
--mantle: 220 22% 92%;
--radius: 0.5rem;
--radius: 0;
}
.dark {
@@ -76,7 +76,7 @@
--mantle: 240 21.311% 11.961%;
--radius: 0.5rem;
--radius: 0;
}
}

View File

@@ -24,8 +24,7 @@ import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher';
export const links = [
{ href: '/', name: 'Home' },
{ href: 'https://github.com/SrIzan10/stack', name: 'Github' },
{ href: '/protected', name: 'Protected route' },
{ href: '/upload', name: 'Upload' },
];
function NavbarLinks() {

View File

@@ -5,6 +5,7 @@ import zodVerify from '../zodVerify';
import { uploadSchema } from './zod';
import prisma from '../db';
import { validateRequest } from '../auth';
import hashImage from '../hashImage';
export async function create(prev: any, formData: FormData) {
const { user } = await validateRequest();
@@ -18,18 +19,23 @@ export async function create(prev: any, formData: FormData) {
const file = zod.data.file as File;
const buffer = new Uint8Array(await file.arrayBuffer());
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const uint = new Uint8Array(arrayBuffer);
const filename = `${Date.now()}-${file.name.replaceAll(' ', '_')}`;
await writeFile(`./public/uploads/${filename}`, buffer).catch((e) => {
await writeFile(`./public/uploads/${filename}`, uint).catch((e) => {
console.log(e);
return { success: false, message: 'writing file' };
});
const imagePreviewHash = await hashImage(buffer);
const dbCreate = await prisma.post.create({
data: {
caption: zod.data.caption,
tags: zod.data.tags.split(',').map((tag: string) => tag.trim()),
imageUrl: `/uploads/${filename}`,
previewHash: imagePreviewHash,
author: {
connect: {
id: user.id

View File

@@ -16,7 +16,7 @@ export const uploadSchema = z.object({
file: z
.any()
.refine((file: File) => file?.name !== '', 'No file name found.')
.refine((file) => file.size < 5000000, 'Max size is 5MB.'),
.refine((file) => file.size < 20000000, 'Max size is 20MB.'),
caption: z.string().min(1),
tags: z.string().min(1),
});

16
src/lib/hashImage.ts Normal file
View File

@@ -0,0 +1,16 @@
import sharp from 'sharp';
import crypto from 'crypto';
export default async function hashImage(imageBuffer: Buffer) {
try {
const previewBuffer = await sharp(imageBuffer, { limitInputPixels: Number.MAX_SAFE_INTEGER })
.resize(32, 32)
.blur(4)
.toBuffer();
return previewBuffer.toString('base64');
} catch (error) {
throw new Error(`Failed to create image preview: ${error}`);
}
}