diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..2529f18 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,7 @@ +node_modules +.next +npm-debug.log +Dockerfile +.dockerignore +.git +.env* diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..eb8da1c --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,9 @@ +# Next.js / Node +node_modules/ +.next/ +out/ +next-env.d.ts +*.tsbuildinfo +.env +.env.local +npm-debug.log* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e6a1477 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/app/auctions/page.tsx b/frontend/app/auctions/page.tsx new file mode 100644 index 0000000..84c24e6 --- /dev/null +++ b/frontend/app/auctions/page.tsx @@ -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 = { + pending: 'Скоро', + active: 'Идёт', + ended: 'Завершён', + sold: 'Продано', + cancelled: 'Отменён', +}; + +export default async function AuctionsPage() { + const { data: auctions, ok } = await safe( + () => api.listAuctions({ status: 'active' }), + [] as Auction[], + ); + + return ( +
+
+

Торги

+

+ Аукционы +

+

+ Первичные торги на новые работы и вторичные — на работы из частных + собраний. Ставки в реальном времени. +

+
+ + {ok && auctions.length > 0 ? ( + + ) : ( + + )} +
+ ); +} + +function AuctionCard({ auction }: { auction: Auction }) { + const title = auction.work?.title || 'Работа'; + const cover = + auction.work?.cover_url || placeholderImage(auction.work_id, 800, 1000); + return ( +
  • + +
    + {title} + + {statusLabel[auction.status] ?? auction.status} + +
    +
    +

    {title}

    + {timeLeft(auction.end_at)} +
    +

    + Текущая ставка · {formatPrice(auction.current_price)} +

    + +
  • + ); +} + +function EmptyState() { + return ( +
    +

    + Сейчас активных аукционов нет +

    +

    + Следите за анонсами новых торгов. Подпишитесь, чтобы узнать первыми, — + приём ставок в реальном времени появится в задаче #5. +

    + + Подписаться на анонсы + +
    + ); +} diff --git a/frontend/app/globals.css b/frontend/app/globals.css new file mode 100644 index 0000000..b1b717f --- /dev/null +++ b/frontend/app/globals.css @@ -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; +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx new file mode 100644 index 0000000..d5a095f --- /dev/null +++ b/frontend/app/layout.tsx @@ -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 ( + + +