first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
orohi
2026-06-06 14:59:02 +03:00
commit 8bb436ea4f
26 changed files with 1075 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
try {
const photos = await prisma.photo.findMany({
orderBy: { createdAt: "desc" },
take: 50,
});
return NextResponse.json(photos);
} catch (error) {
console.error("Photos fetch error:", error);
return NextResponse.json([], { status: 200 });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, mkdir } from "fs/promises";
import path from "path";
import { prisma } from "@/lib/prisma";
const ALLOWED_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
];
const MAX_SIZE = 10 * 1024 * 1024;
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json(
{ error: "Файл не найден" },
{ status: 400 }
);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: "Недопустимый тип файла" },
{ status: 400 }
);
}
if (file.size > MAX_SIZE) {
return NextResponse.json(
{ error: "Файл слишком большой (макс. 10 МБ)" },
{ status: 400 }
);
}
const ext = file.name.split(".").pop()?.toLowerCase() || "jpg";
const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}.${ext}`;
const uploadDir = path.join(process.cwd(), "public", "uploads");
await mkdir(uploadDir, { recursive: true });
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
await writeFile(path.join(uploadDir, filename), buffer);
const url = `/uploads/${filename}`;
const photo = await prisma.photo.create({
data: {
filename,
originalName: file.name,
mimeType: file.type,
size: file.size,
url,
},
});
return NextResponse.json(photo);
} catch (error) {
console.error("Upload error:", error);
return NextResponse.json(
{ error: "Ошибка сервера при загрузке" },
{ status: 500 }
);
}
}
+64
View File
@@ -0,0 +1,64 @@
@import "tailwindcss";
@theme {
--color-background: #0a0a0f;
--color-surface: #12121a;
--color-surface-hover: #1a1a26;
--color-border: #2a2a3a;
--color-muted: #71717a;
--color-foreground: #fafafa;
--color-accent: #6366f1;
--color-accent-light: #818cf8;
--color-accent-glow: rgba(99, 102, 241, 0.4);
--color-success: #22c55e;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-family: var(--font-sans);
}
::selection {
background: rgba(99, 102, 241, 0.3);
}
}
@layer utilities {
.gradient-text {
@apply bg-gradient-to-r from-indigo-400 via-violet-400 to-purple-400 bg-clip-text text-transparent;
}
.glass {
@apply bg-surface/60 backdrop-blur-xl border border-border/50;
}
.glow {
box-shadow: 0 0 60px -12px var(--color-accent-glow);
}
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-float {
animation: float 6s ease-in-out infinite;
}
.animate-shimmer {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.05), transparent);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({
subsets: ["latin", "cyrillic"],
variable: "--font-inter",
});
export const metadata: Metadata = {
title: "PhotoHost — Быстрый фотохостинг",
description:
"Загружайте, храните и делитесь фотографиями мгновенно. Бесплатный современный фотохостинг.",
icons: { icon: "/icon.svg" },
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ru">
<body className={`${inter.variable} min-h-screen`}>{children}</body>
</html>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { prisma } from "@/lib/prisma";
import { Header } from "@/components/Header";
import { Hero } from "@/components/Hero";
import { UploadZone } from "@/components/UploadZone";
import { Gallery } from "@/components/Gallery";
import { Features } from "@/components/Features";
import { Footer } from "@/components/Footer";
export const dynamic = "force-dynamic";
async function getRecentPhotos() {
try {
return await prisma.photo.findMany({
orderBy: { createdAt: "desc" },
take: 12,
});
} catch {
return [];
}
}
export default async function HomePage() {
const photos = await getRecentPhotos();
return (
<div className="relative min-h-screen overflow-hidden">
<div className="pointer-events-none fixed inset-0 -z-10">
<div className="absolute top-0 left-1/2 -translate-x-1/2 h-[600px] w-[800px] rounded-full bg-indigo-600/10 blur-[120px]" />
<div className="absolute bottom-0 right-0 h-[400px] w-[600px] rounded-full bg-violet-600/8 blur-[100px]" />
<div className="absolute top-1/3 left-0 h-[300px] w-[400px] rounded-full bg-purple-600/6 blur-[80px]" />
</div>
<Header />
<main>
<Hero />
<UploadZone />
<Gallery initialPhotos={photos} />
<Features />
</main>
<Footer />
</div>
);
}
+95
View File
@@ -0,0 +1,95 @@
import {
Zap,
Link2,
Shield,
Globe,
HardDrive,
Sparkles,
} from "lucide-react";
const features = [
{
icon: Zap,
title: "Мгновенная загрузка",
description:
"Drag & drop или выбор файла — прямая ссылка готова за секунду.",
color: "text-amber-400",
bg: "bg-amber-400/10",
},
{
icon: Link2,
title: "Прямые ссылки",
description:
"Получайте прямую ссылку на изображение для форумов, чатов и сайтов.",
color: "text-indigo-400",
bg: "bg-indigo-400/10",
},
{
icon: Shield,
title: "Безопасность",
description:
"Файлы хранятся на защищённом сервере с контролем типов и размера.",
color: "text-emerald-400",
bg: "bg-emerald-400/10",
},
{
icon: Globe,
title: "Доступ отовсюду",
description:
"Загружайте с любого устройства — работает на всех платформах.",
color: "text-sky-400",
bg: "bg-sky-400/10",
},
{
icon: HardDrive,
title: "Надёжное хранение",
description:
"PostgreSQL + Docker — ваши фото в безопасности и всегда доступны.",
color: "text-violet-400",
bg: "bg-violet-400/10",
},
{
icon: Sparkles,
title: "Без регистрации",
description:
"Не нужен аккаунт — просто загрузите и поделитесь ссылкой.",
color: "text-pink-400",
bg: "bg-pink-400/10",
},
];
export function Features() {
return (
<section id="features" className="px-6 py-20">
<div className="mx-auto max-w-7xl">
<div className="mb-14 text-center">
<h2 className="text-3xl font-bold md:text-4xl">
Почему <span className="gradient-text">PhotoHost</span>?
</h2>
<p className="mt-3 text-muted">
Всё, что нужно для быстрого обмена фотографиями
</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{features.map((feature) => (
<div
key={feature.title}
className="glass group rounded-2xl p-6 transition-all duration-300 hover:border-indigo-500/30 hover:bg-surface-hover"
>
<div
className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-xl ${feature.bg} transition-transform group-hover:scale-110`}
>
<feature.icon className={`h-6 w-6 ${feature.color}`} />
</div>
<h3 className="mb-2 text-lg font-semibold">{feature.title}</h3>
<p className="text-sm leading-relaxed text-muted">
{feature.description}
</p>
</div>
))}
</div>
</div>
</section>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Camera, Heart } from "lucide-react";
export function Footer() {
return (
<footer className="border-t border-border/50 px-6 py-10">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 md:flex-row">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600">
<Camera className="h-4 w-4 text-white" />
</div>
<span className="font-semibold">
Photo<span className="gradient-text">Host</span>
</span>
</div>
<p className="flex items-center gap-1 text-sm text-muted">
Сделано с <Heart className="h-3.5 w-3.5 text-red-400" /> на Next.js +
PostgreSQL
</p>
<p className="text-sm text-muted">
&copy; {new Date().getFullYear()} PhotoHost
</p>
</div>
</footer>
);
}
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { useEffect, useState } from "react";
import Image from "next/image";
import { Images, Eye, Calendar } from "lucide-react";
interface Photo {
id: string;
filename: string;
originalName: string;
url: string;
views: number;
createdAt: string;
}
interface GalleryProps {
initialPhotos: Photo[];
}
export function Gallery({ initialPhotos }: GalleryProps) {
const [photos, setPhotos] = useState(initialPhotos);
useEffect(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<Photo>;
setPhotos((prev) => [customEvent.detail, ...prev].slice(0, 12));
};
window.addEventListener("photo-uploaded", handler);
return () => window.removeEventListener("photo-uploaded", handler);
}, []);
return (
<section id="gallery" className="px-6 py-16">
<div className="mx-auto max-w-7xl">
<div className="mb-10 text-center">
<div className="mb-3 inline-flex items-center gap-2 text-indigo-400">
<Images className="h-5 w-5" />
<span className="text-sm font-medium uppercase tracking-wider">
Галерея
</span>
</div>
<h2 className="text-3xl font-bold md:text-4xl">
Недавние загрузки
</h2>
<p className="mt-3 text-muted">
Последние фотографии, загруженные на сервис
</p>
</div>
{photos.length === 0 ? (
<div className="glass mx-auto max-w-md rounded-2xl p-12 text-center">
<Images className="mx-auto h-12 w-12 text-muted/50" />
<p className="mt-4 text-muted">
Пока нет загруженных фото. Будьте первым!
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
{photos.map((photo, index) => (
<a
key={photo.id}
href={photo.url}
target="_blank"
rel="noopener noreferrer"
className="group relative aspect-square overflow-hidden rounded-2xl bg-surface transition-all duration-300 hover:scale-[1.02] hover:shadow-xl hover:shadow-indigo-500/10"
style={{ animationDelay: `${index * 50}ms` }}
>
<Image
src={photo.url}
alt={photo.originalName}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 25vw"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100" />
<div className="absolute right-0 bottom-0 left-0 translate-y-full p-4 transition-transform duration-300 group-hover:translate-y-0">
<p className="truncate text-sm font-medium text-white">
{photo.originalName}
</p>
<div className="mt-1 flex items-center gap-3 text-xs text-white/70">
<span className="flex items-center gap-1">
<Eye className="h-3 w-3" />
{photo.views}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(photo.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
</div>
</a>
))}
</div>
)}
</div>
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { Camera, Upload } from "lucide-react";
import Link from "next/link";
export function Header() {
return (
<header className="sticky top-0 z-50 glass">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link href="/" className="group flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 shadow-lg shadow-indigo-500/25 transition-transform group-hover:scale-105">
<Camera className="h-4.5 w-4.5 text-white" />
</div>
<span className="text-lg font-semibold tracking-tight">
Photo<span className="gradient-text">Host</span>
</span>
</Link>
<nav className="hidden items-center gap-8 md:flex">
<a
href="#upload"
className="text-sm text-muted transition-colors hover:text-foreground"
>
Загрузить
</a>
<a
href="#gallery"
className="text-sm text-muted transition-colors hover:text-foreground"
>
Галерея
</a>
<a
href="#features"
className="text-sm text-muted transition-colors hover:text-foreground"
>
Возможности
</a>
</nav>
<a
href="#upload"
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-600 px-4 py-2 text-sm font-medium text-white shadow-lg shadow-indigo-500/25 transition-all hover:shadow-indigo-500/40 hover:brightness-110"
>
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Загрузить фото</span>
</a>
</div>
</header>
);
}
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { Sparkles, Zap, Shield } from "lucide-react";
export function Hero() {
return (
<section className="relative px-6 pt-20 pb-16 md:pt-28 md:pb-24">
<div className="mx-auto max-w-4xl text-center">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-indigo-500/20 bg-indigo-500/10 px-4 py-1.5 text-sm text-indigo-300">
<Sparkles className="h-3.5 w-3.5" />
Бесплатный фотохостинг нового поколения
</div>
<h1 className="text-4xl font-bold leading-tight tracking-tight md:text-6xl lg:text-7xl">
Загружайте фото{" "}
<span className="gradient-text">мгновенно</span>
</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted md:text-xl">
Перетащите изображение получите прямую ссылку за секунду.
Без регистрации, без ограничений, с красивой галереей.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-6 text-sm text-muted">
<div className="flex items-center gap-2">
<Zap className="h-4 w-4 text-amber-400" />
Мгновенная загрузка
</div>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-emerald-400" />
Безопасное хранение
</div>
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-violet-400" />
Прямые ссылки
</div>
</div>
</div>
<div className="mx-auto mt-16 flex max-w-3xl justify-center gap-4 animate-float">
{[1, 2, 3].map((i) => (
<div
key={i}
className="glass h-32 w-24 rounded-2xl md:h-40 md:w-32"
style={{
animationDelay: `${i * 0.5}s`,
opacity: 1 - i * 0.15,
transform: `rotate(${(i - 2) * 6}deg)`,
}}
>
<div className="flex h-full items-center justify-center">
<div
className="h-16 w-16 rounded-xl bg-gradient-to-br opacity-60 md:h-20 md:w-20"
style={{
backgroundImage: `linear-gradient(${i * 60 + 135}deg, #6366f1, #a855f7, #ec4899)`,
}}
/>
</div>
</div>
))}
</div>
</section>
);
}
+200
View File
@@ -0,0 +1,200 @@
"use client";
import { useCallback, useRef, useState } from "react";
import {
Upload,
ImageIcon,
Link2,
Check,
Loader2,
X,
Copy,
} from "lucide-react";
interface UploadedPhoto {
id: string;
url: string;
originalName: string;
}
export function UploadZone() {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploaded, setUploaded] = useState<UploadedPhoto | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const uploadFile = useCallback(async (file: File) => {
if (!file.type.startsWith("image/")) {
setError("Можно загружать только изображения");
return;
}
if (file.size > 10 * 1024 * 1024) {
setError("Максимальный размер файла — 10 МБ");
return;
}
setError(null);
setIsUploading(true);
setUploaded(null);
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Ошибка загрузки");
}
const photo = await res.json();
setUploaded(photo);
window.dispatchEvent(new CustomEvent("photo-uploaded", { detail: photo }));
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setIsUploading(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const file = e.dataTransfer.files[0];
if (file) uploadFile(file);
},
[uploadFile]
);
const handleCopy = async () => {
if (!uploaded) return;
const fullUrl = `${window.location.origin}${uploaded.url}`;
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const reset = () => {
setUploaded(null);
setError(null);
setCopied(false);
};
return (
<section id="upload" className="px-6 py-16">
<div className="mx-auto max-w-2xl">
<div
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onClick={() => !isUploading && !uploaded && inputRef.current?.click()}
className={`group relative cursor-pointer rounded-3xl border-2 border-dashed p-12 text-center transition-all duration-300 ${
isDragging
? "border-indigo-400 bg-indigo-500/10 glow"
: "border-border hover:border-indigo-500/50 hover:bg-surface/50"
} ${uploaded ? "cursor-default border-emerald-500/30 bg-emerald-500/5" : ""}`}
>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) uploadFile(file);
e.target.value = "";
}}
/>
{isUploading ? (
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-12 w-12 animate-spin text-indigo-400" />
<p className="text-lg font-medium">Загрузка...</p>
</div>
) : uploaded ? (
<div className="flex flex-col items-center gap-5">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/20">
<Check className="h-8 w-8 text-emerald-400" />
</div>
<div>
<p className="text-lg font-medium text-emerald-400">
Фото загружено!
</p>
<p className="mt-1 text-sm text-muted">{uploaded.originalName}</p>
</div>
<div className="flex w-full max-w-md items-center gap-2 rounded-xl bg-surface p-3">
<Link2 className="h-4 w-4 shrink-0 text-muted" />
<input
readOnly
value={`${typeof window !== "undefined" ? window.location.origin : ""}${uploaded.url}`}
className="flex-1 bg-transparent text-sm outline-none"
/>
<button
onClick={(e) => {
e.stopPropagation();
handleCopy();
}}
className="shrink-0 rounded-lg bg-indigo-500/20 p-2 transition-colors hover:bg-indigo-500/30"
>
{copied ? (
<Check className="h-4 w-4 text-emerald-400" />
) : (
<Copy className="h-4 w-4 text-indigo-400" />
)}
</button>
</div>
<button
onClick={(e) => {
e.stopPropagation();
reset();
}}
className="text-sm text-muted transition-colors hover:text-foreground"
>
Загрузить ещё
</button>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-indigo-500/10 transition-transform group-hover:scale-110">
<Upload className="h-8 w-8 text-indigo-400" />
</div>
<div>
<p className="text-lg font-medium">
Перетащите фото сюда
</p>
<p className="mt-1 text-sm text-muted">
или нажмите для выбора файла
</p>
</div>
<div className="mt-2 flex items-center gap-4 text-xs text-muted">
<span className="flex items-center gap-1">
<ImageIcon className="h-3.5 w-3.5" />
JPG, PNG, GIF, WebP
</span>
<span>до 10 МБ</span>
</div>
</div>
)}
</div>
{error && (
<div className="mt-4 flex items-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
<X className="h-4 w-4 shrink-0" />
{error}
</div>
)}
</div>
</section>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}