feat(#3): frontend scaffold (Next.js 14 App Router, TS, Tailwind, галерея)
Deploy / deploy (push) Failing after 5s

Каркас витрины дизайнера Ники: главная (hero + сетка галереи),
страница работы (детали + место под аукцион), список аукционов,
подписка-заглушка. Клиент к backend /api с graceful-фолбэком на
демо-работы при недоступном API. Standalone-сборка + Dockerfile.
Сборка npm run build и tsc --noEmit проходят.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
german
2026-08-05 19:42:13 +03:00
co-authored by Claude Opus 4.8
parent be29d02cc0
commit ad35d4e992
24 changed files with 1079 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
npm-debug.log
Dockerfile
.dockerignore
.git
.env*
+9
View File
@@ -0,0 +1,9 @@
# Next.js / Node
node_modules/
.next/
out/
next-env.d.ts
*.tsbuildinfo
.env
.env.local
npm-debug.log*
+30
View File
@@ -0,0 +1,30 @@
# Next.js 14 standalone — многоступенчатая сборка (см. output:'standalone').
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci || npm install
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# API-база вшивается в клиентский бандл на этапе сборки.
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
+104
View File
@@ -0,0 +1,104 @@
import Link from 'next/link';
import Image from 'next/image';
import type { Metadata } from 'next';
import { api, safe } from '@/lib/api';
import type { Auction } from '@/lib/types';
import { formatPrice, timeLeft, placeholderImage } from '@/lib/format';
export const revalidate = 30;
export const metadata: Metadata = {
title: 'Аукционы',
description: 'Открытые аукционы на работы Ники: первичные и вторичные торги.',
};
const statusLabel: Record<string, string> = {
pending: 'Скоро',
active: 'Идёт',
ended: 'Завершён',
sold: 'Продано',
cancelled: 'Отменён',
};
export default async function AuctionsPage() {
const { data: auctions, ok } = await safe(
() => api.listAuctions({ status: 'active' }),
[] as Auction[],
);
return (
<section className="mx-auto max-w-content px-6 py-16 md:px-10 md:py-20">
<header className="mb-12 max-w-2xl">
<p className="text-sm uppercase tracking-[0.25em] text-muted">Торги</p>
<h1 className="mt-4 font-serif text-5xl tracking-tightest md:text-6xl">
Аукционы
</h1>
<p className="mt-6 text-lg leading-relaxed text-muted">
Первичные торги на новые работы и вторичные — на работы из частных
собраний. Ставки в реальном времени.
</p>
</header>
{ok && auctions.length > 0 ? (
<ul className="grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{auctions.map((a) => (
<AuctionCard key={a.id} auction={a} />
))}
</ul>
) : (
<EmptyState />
)}
</section>
);
}
function AuctionCard({ auction }: { auction: Auction }) {
const title = auction.work?.title || 'Работа';
const cover =
auction.work?.cover_url || placeholderImage(auction.work_id, 800, 1000);
return (
<li className="animate-fade-in">
<Link href={`/works/${auction.work?.slug || auction.work_id}`} className="group block">
<div className="relative aspect-[4/5] w-full overflow-hidden bg-line/40 dark:bg-white/5">
<Image
src={cover}
alt={title}
fill
sizes="(max-width: 640px) 100vw, 33vw"
className="object-cover transition-transform duration-700 ease-gallery group-hover:scale-[1.03]"
/>
<span className="absolute left-3 top-3 rounded-full bg-accent/90 px-3 py-1 text-[11px] uppercase tracking-wide text-white">
{statusLabel[auction.status] ?? auction.status}
</span>
</div>
<div className="mt-4 flex items-baseline justify-between gap-4">
<h3 className="truncate font-serif text-xl tracking-tightest">{title}</h3>
<span className="shrink-0 text-sm text-muted">{timeLeft(auction.end_at)}</span>
</div>
<p className="mt-1 text-sm text-accent">
Текущая ставка · {formatPrice(auction.current_price)}
</p>
</Link>
</li>
);
}
function EmptyState() {
return (
<div className="rounded-xl border border-dashed border-line py-24 text-center dark:border-white/15">
<p className="font-serif text-3xl tracking-tightest text-muted">
Сейчас активных аукционов нет
</p>
<p className="mx-auto mt-4 max-w-md text-sm text-muted">
Следите за анонсами новых торгов. Подпишитесь, чтобы узнать первыми, —
приём ставок в реальном времени появится в задаче #5.
</p>
<Link
href="/subscribe"
className="mt-8 inline-block rounded-full border border-ink/25 px-6 py-2.5 text-sm transition-colors hover:bg-ink hover:text-paper dark:border-white/25 dark:hover:bg-paper dark:hover:text-ink"
>
Подписаться на анонсы
</Link>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: #f7f5f1;
--fg: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121110;
--fg: #ece8e1;
}
}
html {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
background: var(--bg);
color: var(--fg);
}
/* Тонкая линия-разделитель в галерейном стиле */
.hairline {
border-color: var(--line, #e4e0d8);
}
/* Плавное появление изображений */
@keyframes fade-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in {
animation: fade-in 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Скелетон-пульсация для загрузки */
@keyframes shimmer {
0% { opacity: 0.55; }
50% { opacity: 1; }
100% { opacity: 0.55; }
}
.animate-shimmer {
animation: shimmer 1.6s ease-in-out infinite;
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from 'next';
import { Cormorant_Garamond, Inter } from 'next/font/google';
import './globals.css';
import Nav from '@/components/Nav';
import Footer from '@/components/Footer';
// Заголовочный серифный шрифт — воздушный, «галерейный».
const display = Cormorant_Garamond({
subsets: ['latin', 'cyrillic'],
weight: ['400', '500', '600'],
variable: '--font-display',
display: 'swap',
});
// Текстовый гротеск — нейтральный, для интерфейса и подписей.
const body = Inter({
subsets: ['latin', 'cyrillic'],
variable: '--font-body',
display: 'swap',
});
export const metadata: Metadata = {
title: {
default: 'Ника — галерея и аукцион',
template: '%s · Ника',
},
description:
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка на новые работы.',
openGraph: {
title: 'Ника — галерея и аукцион',
description:
'Галерея работ дизайнера Ники: живопись и графика, аукцион, подписка.',
type: 'website',
locale: 'ru_RU',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru" className={`${display.variable} ${body.variable}`}>
<body className="font-sans antialiased min-h-screen flex flex-col">
<Nav />
<main className="flex-1">{children}</main>
<Footer />
</body>
</html>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from 'next/link';
export default function NotFound() {
return (
<section className="mx-auto flex max-w-content flex-col items-center px-6 py-32 text-center md:px-10">
<p className="font-serif text-7xl tracking-tightest text-muted">404</p>
<h1 className="mt-4 font-serif text-3xl tracking-tightest">
Работа не найдена
</h1>
<p className="mt-4 max-w-sm text-sm text-muted">
Возможно, она была продана или ещё не опубликована.
</p>
<Link
href="/"
className="mt-8 rounded-full border border-ink/25 px-6 py-2.5 text-sm transition-colors hover:bg-ink hover:text-paper dark:border-white/25 dark:hover:bg-paper dark:hover:text-ink"
>
Вернуться в галерею
</Link>
</section>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { api, safe } from '@/lib/api';
import { demoWorks } from '@/lib/demo';
import GalleryGrid from '@/components/GalleryGrid';
// Витрина обновляется каждые 60 c (ISR) — новые работы появляются без пересборки.
export const revalidate = 60;
export default async function HomePage() {
const { data: fetched, ok } = await safe(
() => api.listWorks({ status: 'published' }),
[],
);
// Пока backend не готов или пуст — показываем демо-работы (плейсхолдеры).
const works = ok && fetched.length > 0 ? fetched : demoWorks;
const isDemo = !(ok && fetched.length > 0);
return (
<>
{/* Hero */}
<section className="mx-auto max-w-content px-6 pb-16 pt-20 md:px-10 md:pb-24 md:pt-28">
<p className="text-sm uppercase tracking-[0.25em] text-muted">
Живопись · Графика · Авторские серии
</p>
<h1 className="mt-6 max-w-4xl font-serif text-5xl font-normal leading-[1.02] tracking-tightest md:text-7xl">
Ника — галерея
<br />и аукцион работ
</h1>
<p className="mt-8 max-w-xl text-lg leading-relaxed text-muted">
Собрание живописи и графики. Каждая работа — в одном экземпляре.
Часть работ уходит с открытого аукциона.
</p>
</section>
{/* Галерея */}
<section className="mx-auto max-w-content px-6 md:px-10">
<div className="mb-10 flex items-end justify-between border-b border-line/70 pb-4 dark:border-white/10">
<h2 className="font-serif text-3xl tracking-tightest">Работы</h2>
<span className="text-sm text-muted">
{works.length} {isDemo && '· превью'}
</span>
</div>
<GalleryGrid works={works} />
{isDemo && (
<p className="mt-12 text-center text-xs text-muted">
Демонстрационная витрина. Реальные работы появятся после подключения
каталога.
</p>
)}
</section>
</>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Подписка',
description: 'Подпишитесь на новые работы и анонсы аукционов Ники.',
};
// Заготовка страницы подписки. Форма и подтверждение e-mail — задача #6.
export default function SubscribePage() {
return (
<section className="mx-auto max-w-xl px-6 py-24 text-center md:px-10">
<p className="text-sm uppercase tracking-[0.25em] text-muted">Рассылка</p>
<h1 className="mt-4 font-serif text-5xl tracking-tightest">Будьте первыми</h1>
<p className="mt-6 leading-relaxed text-muted">
Новые работы и анонсы аукционов — на почту, без спама. Форма подписки с
подтверждением появится в задаче #6.
</p>
<form className="mt-10 flex flex-col gap-3 sm:flex-row">
<input
type="email"
disabled
placeholder="e-mail"
className="flex-1 rounded-full border border-line bg-transparent px-5 py-3 text-sm outline-none placeholder:text-muted focus:border-ink dark:border-white/15"
/>
<button
type="button"
disabled
className="cursor-not-allowed rounded-full bg-ink px-6 py-3 text-sm text-paper opacity-60"
>
Подписаться — скоро
</button>
</form>
</section>
);
}
+173
View File
@@ -0,0 +1,173 @@
import Image from 'next/image';
import Link from 'next/link';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { api, safe } from '@/lib/api';
import { demoWorks } from '@/lib/demo';
import { coverOf, formatPrice } from '@/lib/format';
import type { Work } from '@/lib/types';
export const revalidate = 60;
async function resolveWork(id: string): Promise<{ work: Work | null; demo: boolean }> {
const { data, ok } = await safe(() => api.getWork(id), null as Work | null);
if (ok && data) return { work: data, demo: false };
// Fallback на демо-работу с тем же slug/id, пока backend не готов.
const fallback = demoWorks.find((w) => w.slug === id || w.id === id) || null;
return { work: fallback, demo: true };
}
export async function generateMetadata({
params,
}: {
params: { id: string };
}): Promise<Metadata> {
const { work } = await resolveWork(params.id);
if (!work) return { title: 'Работа не найдена' };
return {
title: work.title,
description:
work.description || `${work.title} — работа Ники (${work.medium ?? ''}).`,
};
}
export default async function WorkPage({ params }: { params: { id: string } }) {
const { work, demo } = await resolveWork(params.id);
if (!work) notFound();
const cover = coverOf(work, 1200, 1500);
const gallery = work.media?.length ? work.media : null;
const onAuction = work.auction_mode !== 'none' && work.status !== 'sold';
return (
<article className="mx-auto max-w-content px-6 py-12 md:px-10 md:py-16">
<Link
href="/"
className="mb-8 inline-block text-sm text-muted transition-colors hover:text-ink dark:hover:text-paper"
>
← Все работы
</Link>
<div className="grid grid-cols-1 gap-12 lg:grid-cols-[1.4fr_1fr]">
{/* Изображение(я) */}
<div className="space-y-6">
<div className="relative aspect-[4/5] w-full overflow-hidden bg-line/40 dark:bg-white/5">
<Image
src={cover}
alt={work.title}
fill
priority
sizes="(max-width: 1024px) 100vw, 60vw"
className="object-cover"
/>
</div>
{gallery && gallery.length > 1 && (
<div className="grid grid-cols-3 gap-4">
{gallery.slice(1, 7).map((m) => (
<div
key={m.id}
className="relative aspect-square overflow-hidden bg-line/40 dark:bg-white/5"
>
<Image
src={m.thumb_url || m.url}
alt={work.title}
fill
sizes="20vw"
className="object-cover"
/>
</div>
))}
</div>
)}
</div>
{/* Детали */}
<aside className="lg:sticky lg:top-24 lg:self-start">
<h1 className="font-serif text-4xl leading-tight tracking-tightest md:text-5xl">
{work.title}
</h1>
<dl className="mt-8 space-y-3 text-sm">
{work.medium && <Row label="Техника" value={work.medium} />}
{work.dimensions && <Row label="Размер" value={work.dimensions} />}
{work.year && <Row label="Год" value={String(work.year)} />}
<Row
label="Статус"
value={
work.status === 'sold'
? 'Продано'
: work.status === 'published'
? 'Доступно'
: 'Черновик'
}
/>
</dl>
{work.description && (
<p className="mt-8 whitespace-pre-line leading-relaxed text-muted">
{work.description}
</p>
)}
{/* Цена / место под аукцион */}
<div className="mt-10 border-t border-line/70 pt-8 dark:border-white/10">
{onAuction ? (
// Место под виджет аукциона (задача #5: ставки + WebSocket).
<div className="rounded-lg border border-accent/40 bg-accent/5 p-6">
<p className="text-xs uppercase tracking-wide text-accent">
{work.auction_mode === 'primary'
? 'Первичный аукцион'
: 'Вторичный аукцион'}
</p>
<p className="mt-3 font-serif text-3xl tracking-tightest">
{formatPrice(work.price)}
</p>
<p className="mt-2 text-sm text-muted">
Приём ставок и таймер появятся здесь (задача #5).
</p>
<button
disabled
className="mt-5 w-full cursor-not-allowed rounded-full bg-ink/30 py-3 text-sm text-paper"
>
Сделать ставку — скоро
</button>
</div>
) : work.status === 'sold' ? (
<p className="font-serif text-2xl tracking-tightest text-muted">
Работа продана
</p>
) : (
<div>
<p className="font-serif text-3xl tracking-tightest text-accent">
{formatPrice(work.price)}
</p>
<button
disabled
className="mt-5 w-full cursor-not-allowed rounded-full bg-ink py-3 text-sm text-paper opacity-60"
>
Запросить покупку — скоро
</button>
</div>
)}
</div>
{demo && (
<p className="mt-6 text-xs text-muted">
Демонстрационные данные — витрина работает автономно, пока не
подключён backend.
</p>
)}
</aside>
</div>
</article>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex justify-between gap-6 border-b border-line/50 pb-3 dark:border-white/5">
<dt className="text-muted">{label}</dt>
<dd className="text-right">{value}</dd>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import Link from 'next/link';
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="mt-24 border-t border-line/70 dark:border-white/10">
<div className="mx-auto flex max-w-content flex-col gap-6 px-6 py-12 md:flex-row md:items-end md:justify-between md:px-10">
<div>
<p className="font-serif text-3xl tracking-tightest">Ника</p>
<p className="mt-2 max-w-sm text-sm text-muted">
Галерея и аукцион работ. Живопись, графика, авторские серии.
</p>
</div>
<div className="flex flex-col gap-2 text-sm text-muted">
<Link href="/" className="transition-colors hover:text-ink dark:hover:text-paper">
Галерея
</Link>
<Link href="/auctions" className="transition-colors hover:text-ink dark:hover:text-paper">
Аукционы
</Link>
<a
href="https://t.me/nika_channel"
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-ink dark:hover:text-paper"
>
Telegram-канал
</a>
</div>
</div>
<div className="mx-auto max-w-content px-6 pb-8 text-xs text-muted md:px-10">
© {year} Ника. Все права на изображения принадлежат автору.
</div>
</footer>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { Work } from '@/lib/types';
import WorkCard from './WorkCard';
export default function GalleryGrid({ works }: { works: Work[] }) {
if (!works.length) return null;
return (
<div className="grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{works.map((w) => (
<WorkCard key={w.id} work={w} />
))}
</div>
);
}
// Скелетон витрины на время загрузки / при недоступном backend.
export function GallerySkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="animate-shimmer">
<div className="aspect-[4/5] w-full bg-line/60 dark:bg-white/5" />
<div className="mt-4 h-5 w-2/3 bg-line/60 dark:bg-white/5" />
<div className="mt-2 h-4 w-1/3 bg-line/50 dark:bg-white/5" />
</div>
))}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Link from 'next/link';
const links = [
{ href: '/', label: 'Галерея' },
{ href: '/auctions', label: 'Аукционы' },
];
export default function Nav() {
return (
<header className="sticky top-0 z-40 border-b border-line/70 bg-paper/80 backdrop-blur-md dark:border-white/10 dark:bg-ink/70">
<nav className="mx-auto flex h-16 max-w-content items-center justify-between px-6 md:px-10">
<Link
href="/"
className="font-serif text-2xl font-medium tracking-tightest text-ink dark:text-paper"
>
Ника
</Link>
<ul className="flex items-center gap-8 text-sm text-muted">
{links.map((l) => (
<li key={l.href}>
<Link
href={l.href}
className="transition-colors hover:text-ink dark:hover:text-paper"
>
{l.label}
</Link>
</li>
))}
<li>
<Link
href="/subscribe"
className="rounded-full border border-ink/20 px-4 py-1.5 text-ink transition-colors hover:border-ink hover:bg-ink hover:text-paper dark:border-white/25 dark:text-paper dark:hover:bg-paper dark:hover:text-ink"
>
Подписаться
</Link>
</li>
</ul>
</nav>
</header>
);
}
+55
View File
@@ -0,0 +1,55 @@
import Image from 'next/image';
import Link from 'next/link';
import type { Work } from '@/lib/types';
import { coverOf, formatPrice } from '@/lib/format';
export default function WorkCard({ work }: { work: Work }) {
const href = `/works/${work.slug || work.id}`;
const cover = coverOf(work, 800, 1000);
const onAuction = work.auction_mode !== 'none';
const sold = work.status === 'sold';
return (
<Link href={href} className="group block animate-fade-in">
<figure className="relative overflow-hidden bg-line/40 dark:bg-white/5">
{/* Пропорция портретного холста */}
<div className="relative aspect-[4/5] w-full">
<Image
src={cover}
alt={work.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 ease-gallery group-hover:scale-[1.03]"
/>
</div>
{(onAuction || sold) && (
<figcaption className="absolute left-3 top-3 flex gap-2">
{sold && (
<span className="rounded-full bg-ink/85 px-3 py-1 text-[11px] uppercase tracking-wide text-paper">
Продано
</span>
)}
{onAuction && !sold && (
<span className="rounded-full bg-accent/90 px-3 py-1 text-[11px] uppercase tracking-wide text-white">
Аукцион
</span>
)}
</figcaption>
)}
</figure>
<div className="mt-4 flex items-baseline justify-between gap-4">
<div className="min-w-0">
<h3 className="truncate font-serif text-xl tracking-tightest">
{work.title}
</h3>
<p className="mt-0.5 text-sm text-muted">
{[work.medium, work.year].filter(Boolean).join(' · ') || 'Работа'}
</p>
</div>
<p className="shrink-0 text-sm text-accent">{formatPrice(work.price)}</p>
</div>
</Link>
);
}
+103
View File
@@ -0,0 +1,103 @@
// Клиент к backend-API (Express, порт 4000, публикуется nginx под /api).
// База берётся из NEXT_PUBLIC_API_URL (например https://nika-gallery.ru/api).
// В dev по умолчанию — http://localhost:4000/api.
import type { Auction, Bid, Paginated, Work } from './types';
export const API_BASE = (
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api'
).replace(/\/$/, '');
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
type FetchOpts = RequestInit & { revalidate?: number };
async function request<T>(path: string, opts: FetchOpts = {}): Promise<T> {
const { revalidate, ...init } = opts;
const url = `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`;
const res = await fetch(url, {
...init,
headers: { Accept: 'application/json', ...(init.headers || {}) },
// ISR: обновляем витрину периодически; переопределяемо на месте вызова
next: revalidate !== undefined ? { revalidate } : { revalidate: 60 },
});
if (!res.ok) {
let msg = `${res.status} ${res.statusText}`;
try {
const body = await res.json();
if (body?.error || body?.message) msg = body.error || body.message;
} catch {
/* тело не JSON — оставляем статус */
}
throw new ApiError(msg, res.status);
}
return (await res.json()) as T;
}
// Нормализуем список: бэкенд может вернуть массив или {items,total}.
function toArray<T>(data: T[] | Paginated<T>): T[] {
return Array.isArray(data) ? data : data.items ?? [];
}
export const api = {
// ── Работы ─────────────────────────────────────────────
async listWorks(params?: {
status?: string;
category?: string;
limit?: number;
}): Promise<Work[]> {
const q = new URLSearchParams();
if (params?.status) q.set('status', params.status);
if (params?.category) q.set('category', params.category);
if (params?.limit) q.set('limit', String(params.limit));
const qs = q.toString() ? `?${q}` : '';
return toArray(await request<Work[] | Paginated<Work>>(`/works${qs}`));
},
async getWork(idOrSlug: string): Promise<Work> {
return request<Work>(`/works/${encodeURIComponent(idOrSlug)}`);
},
// ── Аукционы ───────────────────────────────────────────
async listAuctions(params?: { status?: string }): Promise<Auction[]> {
const q = new URLSearchParams();
if (params?.status) q.set('status', params.status);
const qs = q.toString() ? `?${q}` : '';
return toArray(
await request<Auction[] | Paginated<Auction>>(`/auctions${qs}`),
);
},
async getAuction(id: string): Promise<Auction> {
return request<Auction>(`/auctions/${encodeURIComponent(id)}`);
},
async getAuctionBids(id: string): Promise<Bid[]> {
return toArray(
await request<Bid[] | Paginated<Bid>>(
`/auctions/${encodeURIComponent(id)}/bids`,
),
);
},
};
// Безопасная обёртка для серверных компонентов: при недоступном backend
// возвращаем fallback вместо падения страницы (graceful degradation).
export async function safe<T>(
fn: () => Promise<T>,
fallback: T,
): Promise<{ data: T; ok: boolean }> {
try {
return { data: await fn(), ok: true };
} catch {
return { data: fallback, ok: false };
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { Work } from './types';
// Демо-работы: показываются, когда backend ещё не готов (graceful degradation).
// Изображения — плейсхолдеры picsum; реальные придут через задачу #4.
export const demoWorks: Work[] = [
{
id: 'demo-1', title: 'Тишина утра', slug: 'demo-1',
description: null, medium: 'Масло, холст', dimensions: '60×80 см',
year: 2025, price: 85000, status: 'published', auction_mode: 'none',
created_at: '', updated_at: '', cover_url: 'https://picsum.photos/seed/nika-1/800/1000',
},
{
id: 'demo-2', title: 'Пыльца', slug: 'demo-2',
description: null, medium: 'Акварель', dimensions: '40×50 см',
year: 2025, price: 42000, status: 'published', auction_mode: 'primary',
created_at: '', updated_at: '', cover_url: 'https://picsum.photos/seed/nika-2/800/1000',
},
{
id: 'demo-3', title: 'Город спит', slug: 'demo-3',
description: null, medium: 'Акрил', dimensions: '70×70 см',
year: 2024, price: 120000, status: 'sold', auction_mode: 'none',
created_at: '', updated_at: '', cover_url: 'https://picsum.photos/seed/nika-3/800/1000',
},
{
id: 'demo-4', title: 'Серия «Море», I', slug: 'demo-4',
description: null, medium: 'Смешанная техника', dimensions: '50×50 см',
year: 2025, price: 64000, status: 'published', auction_mode: 'none',
created_at: '', updated_at: '', cover_url: 'https://picsum.photos/seed/nika-4/800/1000',
},
{
id: 'demo-5', title: 'Осколок света', slug: 'demo-5',
description: null, medium: 'Пастель', dimensions: '30×40 см',
year: 2024, price: 38000, status: 'published', auction_mode: 'secondary',
created_at: '', updated_at: '', cover_url: 'https://picsum.photos/seed/nika-5/800/1000',
},
{
id: 'demo-6', title: 'Портрет незнакомки', slug: 'demo-6',
description: null, medium: 'Масло, холст', dimensions: '55×75 см',
year: 2025, price: 96000, status: 'published', auction_mode: 'none',
created_at: '', updated_at: '', cover_url: 'https://picsum.photos/seed/nika-6/800/1000',
},
];
+43
View File
@@ -0,0 +1,43 @@
import type { Work } from './types';
// Форматирование цены в рублях. NUMERIC из pg приходит строкой.
export function formatPrice(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') return 'Цена по запросу';
const n = typeof value === 'string' ? Number(value) : value;
if (Number.isNaN(n)) return 'Цена по запросу';
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(n);
}
// Детерминированный плейсхолдер из picsum по seed (id/slug работы),
// пока не пришли реальные изображения. Стабилен между сборками.
export function placeholderImage(seed: string, w = 800, h = 1000): string {
const s = encodeURIComponent(seed || 'nika');
return `https://picsum.photos/seed/${s}/${w}/${h}`;
}
// Выбираем обложку работы: cover_url из view → первое media → плейсхолдер.
export function coverOf(work: Work, w?: number, h?: number): string {
return (
work.cover_url ||
work.cover_thumb ||
work.media?.[0]?.url ||
placeholderImage(work.slug || work.id, w, h)
);
}
// Оставшееся время аукциона в человекочитаемом виде.
export function timeLeft(endAt: string): string {
const ms = new Date(endAt).getTime() - Date.now();
if (Number.isNaN(ms)) return '';
if (ms <= 0) return 'Завершён';
const d = Math.floor(ms / 86_400_000);
const h = Math.floor((ms % 86_400_000) / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
if (d > 0) return `${d} д ${h} ч`;
if (h > 0) return `${h} ч ${m} мин`;
return `${m} мин`;
}
+79
View File
@@ -0,0 +1,79 @@
// Типы данных — соответствуют db/schema.sql (view works_extended, таблицы auctions/bids).
export type WorkStatus = 'draft' | 'published' | 'sold';
export type AuctionMode = 'none' | 'primary' | 'secondary';
export type AuctionType = 'primary' | 'secondary';
export type AuctionStatus =
| 'pending'
| 'active'
| 'ended'
| 'sold'
| 'cancelled';
export interface Media {
id: string;
work_id: string;
url: string;
thumb_url: string | null;
type: 'image' | 'video';
sort_order: number;
}
// Соответствует VIEW works_extended (+ вложенное media при детальном запросе).
export interface Work {
id: string;
title: string;
slug: string;
description: string | null;
medium: string | null;
dimensions: string | null;
year: number | null;
price: string | number | null; // NUMERIC приходит строкой из pg
status: WorkStatus;
auction_mode: AuctionMode;
created_at: string;
updated_at: string;
// агрегаты из works_extended
likes_count?: number;
comments_count?: number;
avg_rating?: number | null;
reviews_count?: number;
cover_url?: string | null;
cover_thumb?: string | null;
// вложенное (детальная выдача)
media?: Media[];
}
export interface Auction {
id: string;
work_id: string;
type: AuctionType;
start_price: string | number;
current_price: string | number;
reserve_price: string | number | null;
min_step: string | number;
start_at: string;
end_at: string;
status: AuctionStatus;
winner_id: string | null;
created_at: string;
// денормализация с бэкенда (join с works) — опционально
work?: Work;
bids_count?: number;
}
export interface Bid {
id: string;
auction_id: string;
user_id: string;
amount: string | number;
created_at: string;
}
// Форма пагинированного ответа списка (гибко: массив или {items,total}).
export interface Paginated<T> {
items: T[];
total: number;
page?: number;
pageSize?: number;
}
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// standalone-сборка для лёгкого Docker-образа (см. Dockerfile)
output: 'standalone',
reactStrictMode: true,
images: {
// плейсхолдеры picsum + собственные /uploads (отдаёт nginx)
remotePatterns: [
{ protocol: 'https', hostname: 'picsum.photos' },
{ protocol: 'https', hostname: 'fastly.picsum.photos' },
{ protocol: 'https', hostname: 'nika-gallery.ru' },
],
},
};
export default nextConfig;
+27
View File
@@ -0,0 +1,27 @@
{
"name": "nika-gallery-frontend",
"version": "0.1.0",
"private": true,
"description": "Ника — Галерея & Аукцион: Next.js 14 App Router frontend",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "14.2.35",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4"
}
}
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
View File
+39
View File
@@ -0,0 +1,39 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./lib/**/*.{ts,tsx}',
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
// подключается через next/font в layout.tsx как CSS-переменные
serif: ['var(--font-display)', 'Georgia', 'serif'],
sans: ['var(--font-body)', 'system-ui', 'sans-serif'],
},
colors: {
// приглушённая галерейная палитра — тёплый бумажный фон, графитовый текст
paper: '#f7f5f1',
ink: '#1a1a1a',
muted: '#6b6b6b',
line: '#e4e0d8',
accent: '#8a6f52', // тёплая бронза для акцентов/цен
},
letterSpacing: {
tightest: '-0.03em',
},
maxWidth: {
content: '1280px',
},
transitionTimingFunction: {
gallery: 'cubic-bezier(0.22, 1, 0.36, 1)',
},
},
},
plugins: [],
};
export default config;
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2021",
"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": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}