@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env*.local
|
||||
README.md
|
||||
docker-compose*.yml
|
||||
Dockerfile
|
||||
*.md
|
||||
@@ -0,0 +1 @@
|
||||
DATABASE_URL="postgresql://photohost:photohost_secret@localhost:5432/photohost?schema=public"
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals", "next/typescript"]
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# uploads
|
||||
/public/uploads/*
|
||||
!/public/uploads/.gitkeep
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
FROM node:22-alpine AS base
|
||||
RUN apk add --no-cache libc6-compat openssl
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json* ./
|
||||
COPY prisma ./prisma/
|
||||
RUN npm install
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npx prisma generate
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN mkdir -p public/uploads && chown nextjs:nodejs public/uploads
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/next.config.ts ./next.config.ts
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["sh", "-c", "npx prisma db push --skip-generate && npm start"]
|
||||
@@ -0,0 +1,69 @@
|
||||
# PhotoHost — Фотохостинг на Next.js
|
||||
|
||||
Современный фотохостинг с красивым интерфейсом, построенный на Next.js 15, PostgreSQL и Docker Compose.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Drag & drop загрузка изображений
|
||||
- Мгновенное получение прямой ссылки
|
||||
- Галерея последних загрузок
|
||||
- Тёмная тема с современным дизайном
|
||||
- PostgreSQL для хранения метаданных
|
||||
- Docker Compose для простого деплоя
|
||||
|
||||
## Быстрый старт (Docker)
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Приложение будет доступно на [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
## Локальная разработка
|
||||
|
||||
1. Скопируйте `.env.example` в `.env`:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Запустите PostgreSQL:
|
||||
|
||||
```bash
|
||||
docker compose up db -d
|
||||
```
|
||||
|
||||
3. Установите зависимости и запустите:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx prisma db push
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Стек
|
||||
|
||||
- **Frontend:** Next.js 15, React 19, Tailwind CSS 4
|
||||
- **Backend:** Next.js API Routes
|
||||
- **База данных:** PostgreSQL 16 + Prisma ORM
|
||||
- **Инфраструктура:** Docker Compose
|
||||
|
||||
## API
|
||||
|
||||
| Метод | Путь | Описание |
|
||||
|-------|------|----------|
|
||||
| POST | `/api/upload` | Загрузка изображения (multipart/form-data) |
|
||||
| GET | `/api/photos` | Список последних фото |
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
├── docker-compose.yml # PostgreSQL + App
|
||||
├── Dockerfile # Production сборка
|
||||
├── prisma/schema.prisma # Схема БД
|
||||
├── src/
|
||||
│ ├── app/ # Next.js App Router
|
||||
│ ├── components/ # UI компоненты
|
||||
│ └── lib/ # Prisma клиент
|
||||
└── public/uploads/ # Загруженные файлы
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: photohost-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: photohost
|
||||
POSTGRES_PASSWORD: photohost_secret
|
||||
POSTGRES_DB: photohost
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U photohost -d photohost"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: photohost-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://photohost:photohost_secret@db:5432/photohost?schema=public
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- uploads_data:/app/public/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "photohost",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"postinstall": "prisma generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.9.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"next": "^15.3.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.15.29",
|
||||
"@types/react": "^19.1.6",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"prisma": "^6.9.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,21 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Photo {
|
||||
id String @id @default(cuid())
|
||||
filename String
|
||||
originalName String
|
||||
mimeType String
|
||||
size Int
|
||||
url String
|
||||
views Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([createdAt(sort: Desc)])
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="url(#g)"/>
|
||||
<path d="M10 22V14l6-4 6 4v8H10z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
<circle cx="16" cy="15" r="2.5" stroke="white" stroke-width="1.5"/>
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="32" y2="32">
|
||||
<stop stop-color="#6366f1"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
© {new Date().getFullYear()} PhotoHost
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] },
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user