Scaffold webu na Payload CMS 3 + Next.js nad SQLite
CI / test (push) Failing after 1m42s
CI / build-and-push (push) Has been skipped

Kostra prezentace s vlastní administrací pro Martina Reinera.

Obsah:
- kolekce Products (obrázky, volné parametry, cena, dostupnost, skrytí),
  Media se zmenšeninami, Pages, Users bez veřejné registrace
- veřejná část: výpis nabídky, detail položky, statické stránky
- /api/health pro Docker HEALTHCHECK

Provoz:
- Dockerfile, docker-compose.yml (vývoj) a docker-compose.prod.yml
  (nasazení z Gitea registry)
- CI v Gitea Actions: lint -> testy -> build -> push image
- úvodní migrace databáze

Stránky jsou force-dynamic, aby se úprava položky projevila hned;
schéma se za běhu nedomýšlí (push: false), migrace se commitují.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 13:13:23 +02:00
co-authored by Claude Opus 5
parent fedccbee93
commit c8e435139e
49 changed files with 13152 additions and 49 deletions
+104
View File
@@ -0,0 +1,104 @@
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import React from 'react'
import config from '@/payload.config'
import type { Media, Product } from '@/payload-types'
import { formatujCenu, popisStavu } from '@/lib/format'
import '../../styles.css'
export const dynamic = 'force-dynamic'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const polozka = await najdiPolozku((await params).slug)
if (!polozka) return { title: 'Položka nenalezena' }
return {
title: `${polozka.nazev} — Reiner zbraně`,
description: polozka.popis ?? undefined,
}
}
export default async function DetailPolozky({ params }: Props) {
const polozka = await najdiPolozku((await params).slug)
if (!polozka) notFound()
const obrazky = (polozka.obrazky ?? [])
.map((radek) => radek.obrazek)
.filter((o): o is Media => typeof o === 'object' && o !== null)
return (
<article className="stranka detail">
<p className="zpet">
<Link href="/"> Zpět na nabídku</Link>
</p>
<h1>{polozka.nazev}</h1>
<p className="detail__cena">
{formatujCenu(polozka.cena)}
<span className={`znacka-stavu znacka-stavu--${polozka.stav}`}>
{popisStavu(polozka.stav)}
</span>
</p>
{obrazky.length > 0 && (
<div className="galerie">
{obrazky.map((obrazek) => {
const varianta = obrazek.sizes?.detail
const url = varianta?.url ?? obrazek.url
const width = varianta?.width ?? obrazek.width
const height = varianta?.height ?? obrazek.height
if (!url || !width || !height) return null
return (
<Image
key={obrazek.id}
alt={obrazek.alt ?? polozka.nazev}
src={url}
width={width}
height={height}
sizes="(max-width: 900px) 100vw, 800px"
/>
)
})}
</div>
)}
{polozka.parametry && polozka.parametry.length > 0 && (
<table className="parametry">
<tbody>
{polozka.parametry.map((parametr) => (
<tr key={parametr.id}>
<th scope="row">{parametr.nazev}</th>
<td>{parametr.hodnota}</td>
</tr>
))}
</tbody>
</table>
)}
{polozka.popis && <div className="popis">{polozka.popis}</div>}
</article>
)
}
async function najdiPolozku(slug: string): Promise<Product | null> {
const payload = await getPayload({ config: await config })
const { docs } = await payload.find({
collection: 'products',
where: {
slug: { equals: slug },
skryto: { not_equals: true },
},
depth: 1,
limit: 1,
})
return docs[0] ?? null
}