mirror of
https://github.com/SrIzan10/nextbooru.git
synced 2026-06-06 00:57:02 +00:00
feat: image previews
This commit is contained in:
25
README.md
25
README.md
@@ -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).
|
||||
@@ -3,7 +3,12 @@ const nextConfig = {
|
||||
webpack: (config) => {
|
||||
config.externals.push("@node-rs/argon2");
|
||||
return config;
|
||||
}
|
||||
},
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: '20mb'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -29,11 +29,12 @@ model Session {
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
imageUrl String
|
||||
caption String
|
||||
createdAt DateTime @default(now())
|
||||
tags String[]
|
||||
authorId String
|
||||
author User @relation(references: [id], fields: [authorId], onDelete: Cascade)
|
||||
id String @id @default(cuid())
|
||||
imageUrl String
|
||||
caption String
|
||||
createdAt DateTime @default(now())
|
||||
tags String[]
|
||||
authorId String
|
||||
author User @relation(references: [id], fields: [authorId], onDelete: Cascade)
|
||||
previewHash String
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
16
src/lib/hashImage.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user