diff --git a/.gitignore b/.gitignore
index 00bba9b..3e789e4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+public/uploads
+dev/
+!dev/docker-compose.yml
\ No newline at end of file
diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml
index 910cc0b..e79af75 100644
--- a/dev/docker-compose.yml
+++ b/dev/docker-compose.yml
@@ -1,5 +1,6 @@
services:
psql:
+ user: 1000:1000
image: postgres
environment:
POSTGRES_USER: postgres
diff --git a/package.json b/package.json
index 1462ae6..c21a5bd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/prisma/migrations/20241226183003_add_post/migration.sql b/prisma/migrations/20241226183003_add_post/migration.sql
new file mode 100644
index 0000000..e782902
--- /dev/null
+++ b/prisma/migrations/20241226183003_add_post/migration.sql
@@ -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;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 96bb15c..958608c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -14,10 +14,11 @@ datasource db {
}
model User {
- id String @id @default(cuid())
- username String @unique
+ id String @id @default(cuid())
+ username String @unique
hashed_password String
- sessions Session[]
+ sessions Session[]
+ posts Post[]
}
model Session {
@@ -25,4 +26,14 @@ model Session {
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
-}
\ No newline at end of file
+}
+
+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)
+}
diff --git a/src/app/(protected)/upload/page.tsx b/src/app/(protected)/upload/page.tsx
new file mode 100644
index 0000000..613e708
--- /dev/null
+++ b/src/app/(protected)/upload/page.tsx
@@ -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 (
+
+
+ ,
+ },
+ {
+ 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}`)}
+ />
+
+
+ );
+}
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx
index 8b3d0bc..b73bb7d 100644
--- a/src/app/(public)/page.tsx
+++ b/src/app/(public)/page.tsx
@@ -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 (
- <>
-
-
-
-
-
-
- The modern tech stack for your next(.js) project
-
-
- stack is a comprehensive tech stack that includes everything you need to build and deploy your next
- web application.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Key Features
-
-
- Everything you need to build and deploy
-
-
- Stack is a comprehensive tech stack that includes everything you need to build and deploy your next
- web application.
-
-
-
-
-
-
- -
-
-
Next.js
-
- Build server-rendered React applications with Next.js.
-
-
-
- -
-
-
Prisma
-
- Access your database with Prisma, the best way to work with databases in Ethan's opinion
-
-
-
- -
-
-
Tailwind CSS
-
- Style your application with Tailwind CSS, the utility-first CSS framework.
-
-
-
- -
-
-
shadcn/ui
-
- The customizability of the components makes it one of the best UI libraries out there.
-
-
-
- -
-
-
Vercel
-
- Deploy your application to the cloud with Vercel, the serverless platform.
-
-
-
- -
-
-
Lucia
-
- Manage authentication with Lucia auth, the best selfhosted authentication library.
-
-
-
-
-
-
-
-
-
- >
+
+
This is nextbooru
+
The simplest and most modern booru software.
+
(very unstable and not feature complete!)
+
+ {posts.map((post) => (
+
+
+
+
+
+ ))}
+
+
);
}
diff --git a/src/app/(public)/post/[id]/page.tsx b/src/app/(public)/post/[id]/page.tsx
new file mode 100644
index 0000000..779b9c6
--- /dev/null
+++ b/src/app/(public)/post/[id]/page.tsx
@@ -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 Post not found
;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
comments here
+
+
+
Image information
+
+
+ Uploaded by {post.author.username}
+
+
+
+ Uploaded on: {post.createdAt.toLocaleString()}
+
+
+
+
Tags
+
+ {post.tags.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ {/* related images missing */}
+
+
+
+ );
+}
diff --git a/src/components/app/UniversalForm/UniversalForm.tsx b/src/components/app/UniversalForm/UniversalForm.tsx
index d6dd253..250cf07 100644
--- a/src/components/app/UniversalForm/UniversalForm.tsx
+++ b/src/components/app/UniversalForm/UniversalForm.tsx
@@ -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({
@@ -78,23 +79,28 @@ export function UniversalForm({
name={field.name as Path>}
render={({ field: formField }) => (
- {field.type !== 'hidden' && {field.label}}
+ {field.type !== 'hidden' && !field.hiddenShowLabel && (
+ {field.label}
+ )}
- {field.textArea ? (
-
- ) : (
-
- )}
+
+ {field.textArea ? (
+
+ ) : (
+
+ )}
+ {field.customComponent && field.customComponent}
+
{field.description && {field.description}}
@@ -102,11 +108,11 @@ export function UniversalForm({
)}
/>
))}
-
+
{otherSubmitButton}
);
-}
\ No newline at end of file
+}
diff --git a/src/components/app/UniversalForm/types.ts b/src/components/app/UniversalForm/types.ts
index e559dcb..111670e 100644
--- a/src/components/app/UniversalForm/types.ts
+++ b/src/components/app/UniversalForm/types.ts
@@ -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
= {
fields: FormFieldConfig[];
schemaName: (typeof schemaDb)[number]['name'];
action: (prev: any, formData: FormData) => void;
- onActionComplete?: (result: unknown) => void;
+ onActionComplete?: (result: any) => void;
defaultValues?: Partial>;
submitText?: string;
submitClassname?: string;
diff --git a/src/components/app/UploadFile/UploadFile.tsx b/src/components/app/UploadFile/UploadFile.tsx
new file mode 100644
index 0000000..c83a9d1
--- /dev/null
+++ b/src/components/app/UploadFile/UploadFile.tsx
@@ -0,0 +1,11 @@
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+
+export default function UploadFile({ id }: { id: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
new file mode 100644
index 0000000..f000e3e
--- /dev/null
+++ b/src/components/ui/badge.tsx
@@ -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,
+ VariantProps {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+ return (
+
+ )
+}
+
+export { Badge, badgeVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..f62edea
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,79 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 0000000..12d81c4
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -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,
+ React.ComponentPropsWithoutRef
+>(
+ (
+ { className, orientation = "horizontal", decorative = true, ...props },
+ ref
+ ) => (
+
+ )
+)
+Separator.displayName = SeparatorPrimitive.Root.displayName
+
+export { Separator }
diff --git a/src/lib/form/actions.ts b/src/lib/form/actions.ts
new file mode 100644
index 0000000..e46dbea
--- /dev/null
+++ b/src/lib/form/actions.ts
@@ -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 };
+}
diff --git a/src/lib/form/zod.ts b/src/lib/form/zod.ts
index 63dfe8c..f548710 100644
--- a/src/lib/form/zod.ts
+++ b/src/lib/form/zod.ts
@@ -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' }),
-})
\ No newline at end of file
+ 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),
+});
diff --git a/yarn.lock b/yarn.lock
index f556a36..f340608 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"