Skip to content

Commit

Permalink
feat: add dropzone, multiple upload, redisign
Browse files Browse the repository at this point in the history
  • Loading branch information
akicool committed Jan 7, 2025
1 parent 3e52ead commit f8672eb
Show file tree
Hide file tree
Showing 11 changed files with 3,098 additions and 200 deletions.
2,880 changes: 2,738 additions & 142 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@
"@supabase/supabase-js": "^2.47.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"i": "^0.3.7",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.469.0",
"next": "15.1.3",
"npm": "^11.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.5",
"react-toastify": "^11.0.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
Expand Down
69 changes: 69 additions & 0 deletions src/app/(private)/admin/Form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";
import React, { FormEvent, useState } from "react";

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);

const response = await fetch("/api/admin", {
method: "POST",
body: formData,
});

const data = await response.json();
console.log({ data });

if (response.ok) {
window.location.href = "/admin";
} else {
alert(data.error);
}
};

export const Form = () => {
const [showPassword, setShowPassword] = useState(false);

return (
<form
className="flex flex-col gap-4 w-full max-w-md"
onSubmit={handleSubmit}
>
<label className="flex flex-col" htmlFor="login">
Login
<input
className="border border-gray-500 rounded px-2 py-1"
type="text"
id="login"
name="login"
/>
</label>

<label className="flex flex-col" htmlFor="password">
Password
<div className="relative">
<input
className="border border-gray-500 rounded px-2 py-1 pr-10 w-full"
type={showPassword ? "text" : "password"}
id="password"
name="password"
/>

<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? "🙈" : "👁️"}
</button>
</div>
</label>

<button
className="bg-blue-500 text-white px-4 py-2 rounded"
type="submit"
>
Log in
</button>
</form>
);
};
18 changes: 18 additions & 0 deletions src/app/(private)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Rubik } from "next/font/google";
import clsx from "clsx";
import { Form } from "./Form";

const rubik = Rubik({
variable: "--font-rubik",
subsets: ["latin"],
});

export default async function AdminPage() {
return (
<main className="container mx-auto py-8 px-4 h-dvh flex w-full flex-col items-center text-black">
<h1 className={clsx("text-3xl mb-4", rubik.variable)}>Admin</h1>

<Form />
</main>
);
}
32 changes: 32 additions & 0 deletions src/app/api/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { cookies } from "next/headers";
import { NextRequest, NextResponse } from "next/server";
import jwt from "jsonwebtoken";

export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const data = await request.formData();
console.log(data);
const login = data.get("login") as string;
const password = data.get("password") as string;

if (
login === process.env.NEXT_ADMIN_LOGIN &&
password === process.env.NEXT_ADMIN_PASSWORD
) {
const token = jwt.sign({ role: "admin" }, process.env.NEXT_JWT_SECRET_KEY!);

cookieStore.set("admin", token, {
name: "admin",
maxAge: 60 * 60 * 24 * 1,
sameSite: "strict",
path: "/",
});

return NextResponse.json({ success: true });
}

return NextResponse.json(
{ success: false, error: "wrong login or password" },
{ status: 401 }
);
}
105 changes: 61 additions & 44 deletions src/app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,62 +4,79 @@ import { randomUUID } from "crypto";

export async function POST(request: NextRequest) {
const data = await request.formData();
const file: File | null = data.get("file") as unknown as File;
const files = Array.from(data.getAll("files")) as File[];
const isPrivate = data.get("isPrivate") === "true";

if (!file) {
return NextResponse.json({ success: false, error: "No file provided" });
console.log({ data, files, isPrivate });

if (!files || !files.length) {
return NextResponse.json({ success: false, error: "Вы не выбрали файл" });
}

const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
if (files.some((file) => file.size > 5 * 1024 * 1024)) {
return NextResponse.json({
success: false,
error: "Размер файла не должен превышать 5MB",
});
}

const validateFilename = file.name
.replace(/ /g, "-")
.replace(/[^a-zA-Z0-9_.-]/g, "");
const promises = files.map(async (file) => {
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);

const filename = Date.now() + validateFilename;
const validateFilename = file.name
.replace(/ /g, "-")
.replace(/[^a-zA-Z0-9_.-]/g, "");

// eslint-disable-next-line
const { data: uploadData, error } = await supabase.storage
.from("images")
.upload(filename, buffer, {
upsert: true,
contentType: file.type,
});
const filename = Date.now() + validateFilename;

if (error) {
console.error("Error uploading file:", error);
return NextResponse.json({ success: false, error: error.message });
}
// eslint-disable-next-line
const { data: uploadData, error } = await supabase.storage
.from("images")
.upload(filename, buffer, {
upsert: true,
contentType: file.type,
});

const { data: publicUrlData } = supabase.storage
.from("images")
.getPublicUrl(filename);

// eslint-disable-next-line
const { data: metaData, error: metaError } = await supabase
.from("image_metadata")
.insert({
filename: filename,
uploaded_at: new Date().toISOString(),
is_private: isPrivate,
unique_id: randomUUID(),
})
.select();

if (metaError) {
console.error("Error saving metadata:", metaError);
return NextResponse.json({ success: false, error: metaError.message });
}
if (error) {
console.error("Ошибка загрузки файла:", error);
return { success: false, error: error.message };
}

const { data: publicUrlData } = supabase.storage
.from("images")
.getPublicUrl(filename);

const { data: metaData, error: metaError } = await supabase
.from("image_metadata")
.insert({
filename: filename,
uploaded_at: new Date().toISOString(),
is_private: isPrivate,
unique_id: randomUUID(),
})
.select();

if (metaError) {
console.error("Ошибка создания метаданных:", metaError);
return { success: false, error: metaError.message };
}

// const viewUrl = `/image/${metaData[0].id}`;
const viewUrl = `/image/${metaData[0].unique_id}`;

return {
success: true,
filename,
publicUrl: publicUrlData.publicUrl,
viewUrl,
};
});

// const viewUrl = `/image/${metaData[0].id}`;
const viewUrl = `/image/${metaData[0].unique_id}`;
const results = await Promise.all(promises);

return NextResponse.json({
success: true,
filename,
publicUrl: publicUrlData.publicUrl,
viewUrl,
results,
});
}
2 changes: 1 addition & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-slate-800`}
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-[#F7F7F7]`}
>
{children}
<Toaster />
Expand Down
18 changes: 14 additions & 4 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import { ImageUploader } from "../components/ImageUploader";
import { ImageGallery } from "../components/ImageGallery";
import { Rubik } from "next/font/google";
import clsx from "clsx";

type Props = {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};

const rubik = Rubik({
variable: "--font-rubik",
subsets: ["latin"],
});

export default async function Home({ searchParams }: Props) {
const { page = "1" } = await searchParams;

return (
<main className="container mx-auto py-8 px-4 h-dvh flex flex-col justify-between">
<h1 className="text-3xl font-bold mb-4">Image Hoster</h1>
<div className="mb-4">
<h2 className="text-2xl font-semibold mb-4">Загрузить изображение</h2>
<main className="container mx-auto py-8 px-4 h-dvh flex flex-col justify-between text-black">
<h1 className={clsx("text-3xl mb-4", rubik.variable)}>Image Hoster</h1>

<div className="mb-4 bg-white p-5 rounded-xl">
<h2 className="text-lg font-semibold mb-4">Загрузить изображение</h2>
<ImageUploader />
</div>

<hr className="my-4 border-black" />

<ImageGallery page={Number(page)} />
</main>
);
Expand Down
23 changes: 23 additions & 0 deletions src/components/ImageGallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import Image from "next/image";
import Link from "next/link";
import { supabase } from "@/lib/supabase";
import { Pagination } from "./Paginaton";
import jwt, { JwtPayload } from "jsonwebtoken";
import { cookies } from "next/headers";
import { ButtonRemove } from "@/shared/ButtonRemove";

const IMAGES_PER_PAGE = 12;

Expand All @@ -23,8 +26,23 @@ async function getImages(page: number) {
return { images: data || [], totalPages };
}

type TypePayload = { role?: string | JwtPayload } | void;

export async function ImageGallery({ page }: { page: number }) {
const { images, totalPages } = await getImages(page);
const cookieStore = await cookies();
const token = cookieStore?.get("admin")?.value;

let payload: TypePayload = {};

if (token?.length) {
payload = jwt.verify(
token,
process.env.NEXT_JWT_SECRET_KEY!
) as TypePayload;
}

console.log("payload", payload);

return (
<>
Expand All @@ -45,12 +63,17 @@ export async function ImageGallery({ page }: { page: number }) {
className="block"
>
<div className="relative aspect-square">
{payload?.role === "admin" && (
<ButtonRemove image={image} />
)}

<Image
src={data.publicUrl}
alt={image.filename}
fill
className="object-cover rounded-lg"
/>

<div className="absolute bottom-0 left-0 right-0 bg-black bg-opacity-50 text-white text-xs p-1">
{new Date(image.uploaded_at).toLocaleDateString()}
</div>
Expand Down
Loading

0 comments on commit f8672eb

Please sign in to comment.