feat: initial upload and image show stuff

This commit is contained in:
2025-01-05 13:07:48 +01:00
parent d735460520
commit 6dd9cefac4
17 changed files with 435 additions and 130 deletions

4
.gitignore vendored
View File

@@ -35,3 +35,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
public/uploads
dev/
!dev/docker-compose.yml

View File

@@ -1,5 +1,6 @@
services:
psql:
user: 1000:1000
image: postgres
environment:
POSTGRES_USER: postgres

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev",
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev --turbo",
"setup": "docker compose --file dev/docker-compose.yml up -d && prisma migrate deploy",
"build": "prisma generate && next build",
"start": "next start",
@@ -20,6 +20,7 @@
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.0",

View File

@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "Post" (
"id" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"caption" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"tags" TEXT[],
"authorId" TEXT NOT NULL,
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -18,6 +18,7 @@ model User {
username String @unique
hashed_password String
sessions Session[]
posts Post[]
}
model Session {
@@ -26,3 +27,13 @@ model Session {
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}
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)
}

View File

@@ -0,0 +1,46 @@
'use client';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import UploadFile from '@/components/app/UploadFile/UploadFile';
import { create } from '@/lib/form/actions';
import { useRouter } from 'next/navigation';
export default function Page() {
const router = useRouter();
return (
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<div className="max-w-md w-full p-4">
<UniversalForm
fields={[
{
name: 'asdf',
label: 'asdf',
type: 'hidden',
placeholder: 'asdf',
customComponent: <UploadFile id="file" />,
},
{
name: 'caption',
label: 'Caption',
type: 'text',
placeholder: 'Caption',
textArea: true,
},
{
name: 'tags',
label: 'Tags',
type: 'text',
placeholder: 'Tags',
description: 'Separate tags with commas',
},
]}
schemaName="upload"
action={create}
submitText="Upload"
submitClassname="w-full"
onActionComplete={(res) => res.success && router.push(`/post/${res.id}`)}
/>
</div>
</div>
);
}

View File

@@ -1,104 +1,23 @@
// https://v0.dev/r/DxCSk58T8pM
import { Button } from "@/components/ui/button";
import Link from "next/link";
import prisma from '@/lib/db';
import Image from 'next/image';
import Link from 'next/link';
export default function Home() {
export default async function Home() {
const posts = await prisma.post.findMany({ take: 30 });
return (
<>
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
The modern tech stack for your next(.js) project
</h1>
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
stack is a comprehensive tech stack that includes everything you need to build and deploy your next
web application.
</p>
</div>
<div className="space-x-4">
<Link href="https://github.com/SrIzan10/stack">
<Button>Start right NOW!</Button>
<div className='text-center pt-2'>
<h1>This is nextbooru</h1>
<p>The simplest and most modern booru software.</p>
<p className='text-sm text-muted-foreground italic'>(very unstable and not feature complete!)</p>
<div className="flex gap-4 p-4">
{posts.map((post) => (
<div key={post.id}>
<Link href={`/post/${post.id}`}>
<Image width={176} height={176} src={post.imageUrl} alt={''} className="aspect-square object-contain border-2 rounded-md border-dashed" />
</Link>
</div>
))}
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 bg-mantle" id="features">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800">
Key Features
</div>
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
Everything you need to build and deploy
</h2>
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
Stack is a comprehensive tech stack that includes everything you need to build and deploy your next
web application.
</p>
</div>
</div>
<div className="mx-auto max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
<div className="flex flex-col justify-center items-center text-center space-y-4">
<ul className="grid gap-6">
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Next.js</h3>
<p className="text-gray-500 dark:text-gray-400">
Build server-rendered React applications with Next.js.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Prisma</h3>
<p className="text-gray-500 dark:text-gray-400">
Access your database with Prisma, the best way to work with databases in Ethan&apos;s opinion
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Tailwind CSS</h3>
<p className="text-gray-500 dark:text-gray-400">
Style your application with Tailwind CSS, the utility-first CSS framework.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">shadcn/ui</h3>
<p className="text-gray-500 dark:text-gray-400">
The customizability of the components makes it one of the best UI libraries out there.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Vercel</h3>
<p className="text-gray-500 dark:text-gray-400">
Deploy your application to the cloud with Vercel, the serverless platform.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Lucia</h3>
<p className="text-gray-500 dark:text-gray-400">
Manage authentication with Lucia auth, the best selfhosted authentication library.
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
</main>
</>
);
}

View File

@@ -0,0 +1,80 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import prisma from '@/lib/db';
import { Heart, Download, Flag, MessageSquare, Link, User, Calendar } from 'lucide-react';
import Image from 'next/image';
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 (
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2">
<Card className="mb-8">
<CardContent className="p-0">
<div className="relative aspect-square w-full">
<Image
src={post.imageUrl}
alt="Post image"
layout="fill"
objectFit="contain"
className="rounded-t-lg"
/>
</div>
<div className="p-4 flex justify-between items-center">
<div className="flex space-x-2">
<Button variant="outline" size="icon">
<Heart className="h-4 w-4" />
</Button>
<a href={post.imageUrl} download>
<Button variant="outline" size="icon">
<Download className="h-4 w-4" />
</Button>
</a>
<Button variant="outline" size="icon">
<Flag className="h-4 w-4" />
</Button>
</div>
<Button>
<MessageSquare className="h-4 w-4 mr-2" />
Comments
</Button>
</div>
</CardContent>
</Card>
<p>comments here</p>
</div>
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold">Image information</h3>
<div className="flex gap-4">
<User className="h-6 w-6" />
<span>Uploaded by {post.author.username}</span>
</div>
<div className="flex gap-4">
<Calendar className="h-6 w-6" />
<span>Uploaded on: {post.createdAt.toLocaleString()}</span>
</div>
<Separator className='my-6' />
<div className="grid gap-4">
<h3 className="text-xl font-bold">Tags</h3>
<div className="flex gap-2">
{post.tags.map((tag) => (
<Badge key={tag} variant={'secondary'} className="select-none cursor-pointer">
{tag}
</Badge>
))}
</div>
</div>
{/* related images missing */}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Path, PathValue, useForm } from 'react-hook-form';
import { Path, useForm } from 'react-hook-form';
import {
Form,
FormControl,
@@ -19,11 +19,12 @@ import React from 'react';
import { toast } from 'sonner';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { accountSchema } from '@/lib/form/zod';
import { accountSchema, uploadSchema } from '@/lib/form/zod';
export const schemaDb = [
{ name: 'login', zod: accountSchema },
{ name: 'register', zod: accountSchema },
{ name: 'upload', zod: uploadSchema },
] as const;
export function UniversalForm<T extends z.ZodType>({
@@ -78,8 +79,11 @@ export function UniversalForm<T extends z.ZodType>({
name={field.name as Path<z.infer<T>>}
render={({ field: formField }) => (
<FormItem>
{field.type !== 'hidden' && <FormLabel>{field.label}</FormLabel>}
{field.type !== 'hidden' && !field.hiddenShowLabel && (
<FormLabel>{field.label}</FormLabel>
)}
<FormControl>
<div>
{field.textArea ? (
<Textarea
placeholder={field.placeholder}
@@ -95,6 +99,8 @@ export function UniversalForm<T extends z.ZodType>({
value={formField.value ?? ''}
/>
)}
{field.customComponent && field.customComponent}
</div>
</FormControl>
{field.description && <FormDescription>{field.description}</FormDescription>}
<FormMessage />
@@ -102,7 +108,7 @@ export function UniversalForm<T extends z.ZodType>({
)}
/>
))}
<div className={cn("flex gap-2 py-2", submitButtonDivClassname)}>
<div className={cn('flex gap-2 py-2', submitButtonDivClassname)}>
{otherSubmitButton}
<SubmitButton buttonText={submitText} className={submitClassname} />
</div>

View File

@@ -6,6 +6,8 @@ export type FormFieldConfig = {
name: string;
label: string;
type?: HTMLInputTypeAttribute;
customComponent?: React.ReactNode;
hiddenShowLabel?: boolean;
placeholder?: string;
description?: string;
value?: string;
@@ -17,7 +19,7 @@ export type UniversalFormProps<T extends z.ZodType> = {
fields: FormFieldConfig[];
schemaName: (typeof schemaDb)[number]['name'];
action: (prev: any, formData: FormData) => void;
onActionComplete?: (result: unknown) => void;
onActionComplete?: (result: any) => void;
defaultValues?: Partial<z.infer<T>>;
submitText?: string;
submitClassname?: string;

View File

@@ -0,0 +1,11 @@
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export default function UploadFile({ id }: { id: string }) {
return (
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor={id}>Image</Label>
<Input id={id} type="file" name={id} />
</div>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

41
src/lib/form/actions.ts Normal file
View File

@@ -0,0 +1,41 @@
'use server';
import { writeFile } from 'fs/promises';
import zodVerify from '../zodVerify';
import { uploadSchema } from './zod';
import prisma from '../db';
import { validateRequest } from '../auth';
export async function create(prev: any, formData: FormData) {
const { user } = await validateRequest();
if (!user) {
return { success: false, message: 'not logged in' };
}
const zod = await zodVerify(uploadSchema, formData);
if (!zod.success) {
return zod;
}
const file = zod.data.file as File;
const buffer = new Uint8Array(await file.arrayBuffer());
const filename = `${Date.now()}-${file.name.replaceAll(' ', '_')}`;
await writeFile(`./public/uploads/${filename}`, buffer).catch((e) => {
console.log(e);
return { success: false, message: 'writing file' };
});
const dbCreate = await prisma.post.create({
data: {
caption: zod.data.caption,
tags: zod.data.tags.split(',').map((tag: string) => tag.trim()),
imageUrl: `/uploads/${filename}`,
author: {
connect: {
id: user.id
}
}
},
});
return { success: true, id: dbCreate.id };
}

View File

@@ -1,6 +1,22 @@
import { z } from 'zod'
import { z } from 'zod';
export const accountSchema = z.object({
username: z.string().min(3, { message: 'Mininum 3 characters' }).max(31, { message: 'Maximum 31 characters' }).regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' }),
password: z.string().min(6, { message: 'Minimum 6 characters' }).max(255, { message: 'Maximum 255 characters' }),
})
username: z
.string()
.min(3, { message: 'Mininum 3 characters' })
.max(31, { message: 'Maximum 31 characters' })
.regex(/^[a-z0-9_-]+$/, { message: 'Only characters from a-z, 0-9, underscores and dashes' }),
password: z
.string()
.min(6, { message: 'Minimum 6 characters' })
.max(255, { message: 'Maximum 255 characters' }),
});
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.'),
caption: z.string().min(1),
tags: z.string().min(1),
});

View File

@@ -1158,6 +1158,13 @@
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-separator@^1.1.1":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@radix-ui/react-separator/-/react-separator-1.1.1.tgz#dd60621553c858238d876be9b0702287424866d2"
integrity sha512-RRiNRSrD8iUiXriq/Y5n4/3iE8HzqgLHsusUSg5jVpU2+3tqcUFPJXHDymwEypunc2sWxDUS3UC+rkZRlHedsw==
dependencies:
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-slot@1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@radix-ui/react-slot/-/react-slot-1.0.2.tgz#a9ff4423eade67f501ffb32ec22064bc9d3099ab"