feat(#3): frontend scaffold (Next.js 14 App Router, TS, Tailwind, галерея)
Deploy / deploy (push) Failing after 5s
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:
co-authored by
Claude Opus 4.8
parent
be29d02cc0
commit
ad35d4e992
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
npm-debug.log
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.git
|
||||
.env*
|
||||
@@ -0,0 +1,9 @@
|
||||
# Next.js / Node
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
npm-debug.log*
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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} мин`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user