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
+42
View File
@@ -0,0 +1,42 @@
import { notFound } from 'next/navigation'
import { getPayload } from 'payload'
import React from 'react'
import { RichText } from '@payloadcms/richtext-lexical/react'
import config from '@/payload.config'
import type { Page } from '@/payload-types'
import '../styles.css'
export const dynamic = 'force-dynamic'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const stranka = await najdiStranku((await params).slug)
return { title: stranka ? `${stranka.nazev} — Reiner zbraně` : 'Stránka nenalezena' }
}
/** Statické stránky z kolekce Pages — /kontakt, /o-nas a podobně. */
export default async function StatickaStranka({ params }: Props) {
const stranka = await najdiStranku((await params).slug)
if (!stranka) notFound()
return (
<article className="stranka">
<h1>{stranka.nazev}</h1>
{stranka.obsah && <RichText data={stranka.obsah} />}
</article>
)
}
async function najdiStranku(slug: string): Promise<Page | null> {
const payload = await getPayload({ config: await config })
const { docs } = await payload.find({
collection: 'pages',
where: { slug: { equals: slug } },
limit: 1,
})
return docs[0] ?? null
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Endpoint pro Docker HEALTHCHECK.
*
* Záměrně nesahá do databáze — odpovídá na otázku „stojí server a obsluhuje
* požadavky?“. Výpadek databáze pozná až monitoring obsahu, ne healthcheck;
* kdyby ho shazoval, restartoval by Docker aplikaci kvůli něčemu, co restart
* neopraví.
*/
export const dynamic = 'force-dynamic'
export function GET() {
return Response.json({ status: 'ok' })
}
+38
View File
@@ -0,0 +1,38 @@
import React from 'react'
import Link from 'next/link'
import './styles.css'
export const metadata = {
description: 'Prodej zbraní, střeliva a doplňků — Martin Reiner.',
title: 'Reiner — zbraně a střelivo',
}
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
return (
<html lang="cs">
<body>
<header className="hlavicka">
<Link className="znacka" href="/">
Reiner <span>zbraně a střelivo</span>
</Link>
<nav>
<Link href="/">Nabídka</Link>
<Link href="/kontakt">Kontakt</Link>
</nav>
</header>
<main>{children}</main>
<footer className="paticka">
<p>© {new Date().getFullYear()} Martin Reiner</p>
<p>
Prodej zbraní a střeliva pouze osobám s platným zbrojním průkazem podle zákona
č.&nbsp;14/2021&nbsp;Sb.
</p>
</footer>
</body>
</html>
)
}
+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
}
+90
View File
@@ -0,0 +1,90 @@
import Image from 'next/image'
import Link from 'next/link'
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'
// Obsah se čte z CMS při každém požadavku — jinak by se úprava položky
// projevila až po přestavění webu.
export const dynamic = 'force-dynamic'
/** Výpis nabídky. Skryté položky se nezobrazují, prodané se řadí nakonec. */
export default async function HomePage() {
const payload = await getPayload({ config: await config })
const { docs: polozky } = await payload.find({
collection: 'products',
where: { skryto: { not_equals: true } },
sort: ['stav', '-updatedAt'],
depth: 1,
limit: 100,
})
return (
<div className="stranka">
<h1>Nabídka</h1>
{polozky.length === 0 ? (
<p className="prazdno">
Zatím tu nic není. Položky se přidávají v <Link href="/admin">administraci</Link>.
</p>
) : (
<ul className="mrizka">
{polozky.map((polozka) => (
<KartaPolozky key={polozka.id} polozka={polozka} />
))}
</ul>
)}
</div>
)
}
function KartaPolozky({ polozka }: { polozka: Product }) {
const nahled = prvniObrazek(polozka)
return (
<li className={`karta${polozka.stav === 'prodano' ? ' karta--prodano' : ''}`}>
<Link href={`/nabidka/${polozka.slug}`}>
<div className="karta__obrazek">
{nahled ? (
<Image
alt={nahled.alt}
src={nahled.url}
width={nahled.width}
height={nahled.height}
sizes="(max-width: 700px) 100vw, 320px"
/>
) : (
<div className="karta__bezobrazku">bez fotky</div>
)}
</div>
<h2>{polozka.nazev}</h2>
<p className="karta__cena">{formatujCenu(polozka.cena)}</p>
<p className="karta__stav">{popisStavu(polozka.stav)}</p>
</Link>
</li>
)
}
/**
* Vytáhne z položky první obrázek v podobě použitelné pro `next/image`.
* Vrací `null`, pokud položka fotku nemá nebo se nepodařilo načíst rozměry.
*/
function prvniObrazek(polozka: Product) {
const prvni = polozka.obrazky?.[0]?.obrazek
if (!prvni || typeof prvni === 'number') return null
const media = prvni as Media
const varianta = media.sizes?.nahled
const url = varianta?.url ?? media.url
const width = varianta?.width ?? media.width
const height = varianta?.height ?? media.height
if (!url || !width || !height) return null
return { url, width, height, alt: media.alt ?? polozka.nazev }
}
+263
View File
@@ -0,0 +1,263 @@
/*
* Styly veřejné části. Záměrně jeden soubor bez frameworku — web má tři
* typy stránek a Tailwind by sem přinesl build krok navíc bez užitku.
*/
:root {
--barva-text: #16181c;
--barva-tlumena: #6b7280;
--barva-pozadi: #ffffff;
--barva-plocha: #f5f5f4;
--barva-linka: #e2e0dd;
--barva-akcent: #7a5c3e;
--sirka: 1100px;
--radius: 6px;
}
@media (prefers-color-scheme: dark) {
:root {
--barva-text: #ececec;
--barva-tlumena: #a1a1aa;
--barva-pozadi: #141414;
--barva-plocha: #1e1e1e;
--barva-linka: #2e2e2e;
--barva-akcent: #c9a227;
}
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 17px;
line-height: 1.6;
color: var(--barva-text);
background: var(--barva-pozadi);
-webkit-font-smoothing: antialiased;
}
img {
max-width: 100%;
height: auto;
}
a {
color: inherit;
}
/* ---------- hlavička a patička ---------- */
.hlavicka {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: baseline;
justify-content: space-between;
max-width: var(--sirka);
margin: 0 auto;
padding: 1.5rem 1.25rem;
border-bottom: 1px solid var(--barva-linka);
}
.znacka {
font-size: 1.35rem;
font-weight: 700;
letter-spacing: 0.01em;
text-decoration: none;
}
.znacka span {
display: block;
font-size: 0.8rem;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--barva-tlumena);
}
.hlavicka nav {
display: flex;
gap: 1.5rem;
}
.hlavicka nav a {
text-decoration: none;
color: var(--barva-tlumena);
}
.hlavicka nav a:hover {
color: var(--barva-text);
}
.paticka {
max-width: var(--sirka);
margin: 4rem auto 0;
padding: 1.5rem 1.25rem 3rem;
border-top: 1px solid var(--barva-linka);
font-size: 0.85rem;
color: var(--barva-tlumena);
}
.paticka p {
margin: 0.25rem 0;
}
/* ---------- obecná stránka ---------- */
.stranka {
max-width: var(--sirka);
margin: 0 auto;
padding: 2rem 1.25rem 0;
}
.stranka h1 {
margin: 0 0 1.5rem;
font-size: clamp(1.6rem, 4vw, 2.2rem);
line-height: 1.2;
}
.prazdno {
color: var(--barva-tlumena);
}
/* ---------- výpis nabídky ---------- */
.mrizka {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
gap: 1.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.karta a {
display: block;
text-decoration: none;
color: inherit;
}
.karta__obrazek {
aspect-ratio: 4 / 3;
overflow: hidden;
background: var(--barva-plocha);
border-radius: var(--radius);
}
.karta__obrazek img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.25s ease;
}
.karta a:hover .karta__obrazek img {
transform: scale(1.03);
}
.karta__bezobrazku {
display: grid;
place-items: center;
height: 100%;
font-size: 0.85rem;
color: var(--barva-tlumena);
}
.karta h2 {
margin: 0.75rem 0 0.25rem;
font-size: 1.05rem;
line-height: 1.35;
}
.karta__cena {
margin: 0;
font-weight: 600;
color: var(--barva-akcent);
}
.karta__stav {
margin: 0.1rem 0 0;
font-size: 0.85rem;
color: var(--barva-tlumena);
}
/* Prodané položky zůstávají ve výpisu, ale nekřičí. */
.karta--prodano .karta__obrazek img {
filter: grayscale(1);
opacity: 0.65;
}
/* ---------- detail položky ---------- */
.zpet {
margin: 0 0 1rem;
font-size: 0.9rem;
}
.detail__cena {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
margin: -0.75rem 0 1.75rem;
font-size: 1.4rem;
font-weight: 600;
color: var(--barva-akcent);
}
.znacka-stavu {
padding: 0.15rem 0.6rem;
border: 1px solid var(--barva-linka);
border-radius: 999px;
font-size: 0.8rem;
font-weight: 500;
color: var(--barva-tlumena);
}
.znacka-stavu--prodano {
border-color: transparent;
background: var(--barva-plocha);
}
.galerie {
display: grid;
gap: 1rem;
margin-bottom: 2rem;
}
.galerie img {
width: 100%;
border-radius: var(--radius);
}
.parametry {
width: 100%;
border-collapse: collapse;
margin-bottom: 2rem;
}
.parametry th,
.parametry td {
padding: 0.6rem 0;
text-align: left;
vertical-align: top;
border-bottom: 1px solid var(--barva-linka);
}
.parametry th {
width: 40%;
font-weight: 500;
color: var(--barva-tlumena);
}
.popis {
max-width: 65ch;
white-space: pre-line;
}
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { NotFoundPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const NotFound = ({ params, searchParams }: Args) =>
NotFoundPage({ config, params, searchParams, importMap })
export default NotFound
@@ -0,0 +1,24 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{
segments: string[]
}>
searchParams: Promise<{
[key: string]: string | string[]
}>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page
+52
View File
@@ -0,0 +1,52 @@
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from '@payloadcms/richtext-lexical/rsc'
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
/** @type import('payload').ImportMap */
export const importMap = {
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell": RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField": RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent": LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient": InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UploadFeatureClient": UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient": BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient": RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#LinkFeatureClient": LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient": ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient": OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient": UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#IndentFeatureClient": IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#AlignFeatureClient": AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#HeadingFeatureClient": HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient": ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient": InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient": SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient": SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient": StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient": UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#BoldFeatureClient": BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/richtext-lexical/client#ItalicFeatureClient": ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
}
+19
View File
@@ -0,0 +1,19 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import {
REST_DELETE,
REST_GET,
REST_OPTIONS,
REST_PATCH,
REST_POST,
REST_PUT,
} from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
@@ -0,0 +1,7 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import { GRAPHQL_PLAYGROUND_GET } from '@payloadcms/next/routes'
export const GET = GRAPHQL_PLAYGROUND_GET(config)
+8
View File
@@ -0,0 +1,8 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)
View File
+31
View File
@@ -0,0 +1,31 @@
/* THIS FILE WAS GENERATED AUTOMATICALLY BY PAYLOAD. */
/* DO NOT MODIFY IT BECAUSE IT COULD BE REWRITTEN AT ANY TIME. */
import config from '@payload-config'
import '@payloadcms/next/css'
import type { ServerFunctionClient } from 'payload'
import { handleServerFunctions, RootLayout } from '@payloadcms/next/layouts'
import React from 'react'
import { importMap } from './admin/importMap.js'
import './custom.scss'
type Args = {
children: React.ReactNode
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
+34
View File
@@ -0,0 +1,34 @@
import type { CollectionConfig } from 'payload'
/**
* Nahrané obrázky. Soubory leží v `media/`, což je v produkci Docker volume —
* proto přežijí redeploy (viz NAVRH-PLATFORMY.md, sekce 6).
*/
export const Media: CollectionConfig = {
slug: 'media',
labels: {
singular: 'Obrázek',
plural: 'Média',
},
access: {
read: () => true,
},
upload: {
staticDir: 'media',
mimeTypes: ['image/*'],
// Zmenšeniny se generují při nahrání; výpis nabídky tahá `nahled`,
// ať se na mobilu nestahuje pětimegový originál.
imageSizes: [
{ name: 'nahled', width: 600, height: 450, position: 'centre' },
{ name: 'detail', width: 1400, height: undefined },
],
},
fields: [
{
name: 'alt',
label: 'Popis pro čtečky (alt)',
type: 'text',
required: true,
},
],
}
+42
View File
@@ -0,0 +1,42 @@
import type { CollectionConfig } from 'payload'
/** Statické stránky — o nás, kontakt, otevírací doba. */
export const Pages: CollectionConfig = {
slug: 'pages',
labels: {
singular: 'Stránka',
plural: 'Stránky',
},
admin: {
useAsTitle: 'nazev',
defaultColumns: ['nazev', 'slug', 'updatedAt'],
},
access: {
read: () => true,
},
fields: [
{
name: 'nazev',
label: 'Název',
type: 'text',
required: true,
},
{
name: 'slug',
label: 'URL adresa',
type: 'text',
required: true,
unique: true,
index: true,
admin: {
position: 'sidebar',
description: 'Např. `kontakt` → /kontakt',
},
},
{
name: 'obsah',
label: 'Obsah',
type: 'richText',
},
],
}
+164
View File
@@ -0,0 +1,164 @@
import type { CollectionConfig } from 'payload'
/**
* Položka nabídky — zbraň, střelivo, doplněk.
*
* Záměrně plochá struktura: obrázky, pár parametrů, cena. Parametry jsou
* volný seznam dvojic název/hodnota, protože se u pušky, pistole a střeliva
* liší a pevná pole by znamenala migraci při každém novém sortimentu.
*/
export const Products: CollectionConfig = {
slug: 'products',
labels: {
singular: 'Položka',
plural: 'Nabídka',
},
admin: {
useAsTitle: 'nazev',
defaultColumns: ['nazev', 'kategorie', 'cena', 'stav', 'updatedAt'],
description: 'Zboží zobrazené na webu. Skryté položky se veřejně nezobrazí.',
},
access: {
// Veřejně čitelné, měnit může jen přihlášený uživatel.
read: () => true,
},
defaultSort: '-updatedAt',
fields: [
{
name: 'nazev',
label: 'Název',
type: 'text',
required: true,
},
{
name: 'slug',
label: 'URL adresa',
type: 'text',
unique: true,
index: true,
admin: {
position: 'sidebar',
description: 'Vyplní se samo z názvu, pokud necháte prázdné.',
},
hooks: {
beforeValidate: [
({ value, data }) => value || slugify(data?.nazev ?? ''),
],
},
},
{
name: 'kategorie',
label: 'Kategorie',
type: 'select',
required: true,
defaultValue: 'zbrane',
options: [
{ label: 'Zbraně', value: 'zbrane' },
{ label: 'Střelivo', value: 'strelivo' },
{ label: 'Doplňky', value: 'doplnky' },
],
admin: { position: 'sidebar' },
},
{
name: 'cena',
label: 'Cena (Kč)',
type: 'number',
required: true,
min: 0,
admin: {
position: 'sidebar',
step: 1,
},
},
{
name: 'stav',
label: 'Dostupnost',
type: 'select',
required: true,
defaultValue: 'skladem',
options: [
{ label: 'Skladem', value: 'skladem' },
{ label: 'Na objednávku', value: 'na-objednavku' },
{ label: 'Prodáno', value: 'prodano' },
],
admin: { position: 'sidebar' },
},
{
name: 'skryto',
label: 'Skrýt z webu',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
description: 'Rozpracovaná položka, kterou zatím nikdo nemá vidět.',
},
},
{
name: 'obrazky',
label: 'Obrázky',
type: 'array',
minRows: 1,
labels: { singular: 'Obrázek', plural: 'Obrázky' },
admin: {
description: 'První obrázek se použije jako náhled ve výpisu.',
},
fields: [
{
name: 'obrazek',
label: 'Soubor',
type: 'upload',
relationTo: 'media',
required: true,
},
],
},
{
name: 'parametry',
label: 'Parametry',
type: 'array',
labels: { singular: 'Parametr', plural: 'Parametry' },
admin: {
description: 'Např. Ráže → 9 mm Luger, Délka hlavně → 108 mm.',
},
fields: [
{
type: 'row',
fields: [
{
name: 'nazev',
label: 'Název',
type: 'text',
required: true,
admin: { width: '40%' },
},
{
name: 'hodnota',
label: 'Hodnota',
type: 'text',
required: true,
admin: { width: '60%' },
},
],
},
],
},
{
name: 'popis',
label: 'Popis',
type: 'textarea',
admin: {
description: 'Nepovinný delší text pod parametry.',
},
},
],
}
/** Diakritika pryč, mezery na pomlčky — `CZ 75 SP-01` → `cz-75-sp-01`. */
function slugify(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
+35
View File
@@ -0,0 +1,35 @@
import type { CollectionConfig } from 'payload'
/**
* Účty do administrace. Web nemá zákaznické účty ani registraci —
* existuje jediný účet majitele, založený při prvním spuštění.
*/
export const Users: CollectionConfig = {
slug: 'users',
labels: {
singular: 'Uživatel',
plural: 'Uživatelé',
},
admin: {
useAsTitle: 'email',
},
auth: {
// Ochrana proti hádání hesla — admin je na veřejné doméně.
maxLoginAttempts: 5,
lockTime: 10 * 60 * 1000, // 10 minut
},
access: {
// Nikdo se nesmí zaregistrovat sám; účty zakládá jen přihlášený uživatel.
create: ({ req }) => Boolean(req.user),
read: ({ req }) => Boolean(req.user),
update: ({ req }) => Boolean(req.user),
delete: ({ req }) => Boolean(req.user),
},
fields: [
{
name: 'jmeno',
label: 'Jméno',
type: 'text',
},
],
}
+16
View File
@@ -0,0 +1,16 @@
import type { Product } from '@/payload-types'
/** `28900` → `28 900 Kč` (pevná mezera, ať se cena nezalomí). */
export function formatujCenu(cena: number): string {
return `${cena.toLocaleString('cs-CZ').replace(/\s/g, ' ')} `
}
const STAVY: Record<Product['stav'], string> = {
skladem: 'Skladem',
'na-objednavku': 'Na objednávku',
prodano: 'Prodáno',
}
export function popisStavu(stav: Product['stav']): string {
return STAVY[stav]
}
+509
View File
@@ -0,0 +1,509 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
products: Product;
media: Media;
pages: Page;
users: User;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
products: ProductsSelect<false> | ProductsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
pages: PagesSelect<false> | PagesSelect<true>;
users: UsersSelect<false> | UsersSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* Zboží zobrazené na webu. Skryté položky se veřejně nezobrazí.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products".
*/
export interface Product {
id: number;
nazev: string;
/**
* Vyplní se samo z názvu, pokud necháte prázdné.
*/
slug?: string | null;
kategorie: 'zbrane' | 'strelivo' | 'doplnky';
cena: number;
stav: 'skladem' | 'na-objednavku' | 'prodano';
/**
* Rozpracovaná položka, kterou zatím nikdo nemá vidět.
*/
skryto?: boolean | null;
/**
* První obrázek se použije jako náhled ve výpisu.
*/
obrazky?:
| {
obrazek: number | Media;
id?: string | null;
}[]
| null;
/**
* Např. Ráže → 9 mm Luger, Délka hlavně → 108 mm.
*/
parametry?:
| {
nazev: string;
hodnota: string;
id?: string | null;
}[]
| null;
/**
* Nepovinný delší text pod parametry.
*/
popis?: string | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
nahled?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
detail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages".
*/
export interface Page {
id: number;
nazev: string;
/**
* Např. `kontakt` → /kontakt
*/
slug: string;
obsah?: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
} | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
jmeno?: string | null;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'products';
value: number | Product;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null)
| ({
relationTo: 'pages';
value: number | Page;
} | null)
| ({
relationTo: 'users';
value: number | User;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "products_select".
*/
export interface ProductsSelect<T extends boolean = true> {
nazev?: T;
slug?: T;
kategorie?: T;
cena?: T;
stav?: T;
skryto?: T;
obrazky?:
| T
| {
obrazek?: T;
id?: T;
};
parametry?:
| T
| {
nazev?: T;
hodnota?: T;
id?: T;
};
popis?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
nahled?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
detail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "pages_select".
*/
export interface PagesSelect<T extends boolean = true> {
nazev?: T;
slug?: T;
obsah?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
jmeno?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
+58
View File
@@ -0,0 +1,58 @@
import { sqliteAdapter } from '@payloadcms/db-sqlite'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Media } from './collections/Media'
import { Pages } from './collections/Pages'
import { Products } from './collections/Products'
import { Users } from './collections/Users'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
/**
* Payload odmítá zápisy z jiného původu, než je `serverURL` — jinak by cizí
* stránka mohla zneužít přihlašovací cookie. Adresa ze `serverURL` platí vždy;
* tohle je pro případy, kdy se admin otevírá i odjinud (třeba po IP v LAN).
* Formát: CSRF_ORIGINS=http://10.0.0.110:3000,http://notebook:3000
*/
const dalsiPovoleneOrigins = (process.env.CSRF_ORIGINS ?? '')
.split(',')
.map((o) => o.trim())
.filter(Boolean)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
meta: {
titleSuffix: ' — Reiner zbraně',
},
},
collections: [Products, Media, Pages, Users],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
csrf: dalsiPovoleneOrigins,
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: sqliteAdapter({
client: {
url: process.env.DATABASE_URI || 'file:./data/reiner.db',
},
// Migrace se generují do `migrations/` a commitují — produkce je pak
// jen přehraje, nikdy neodvozuje schéma za běhu.
// Výjimka jsou testy: tam se schéma odvodí z konfigurace, aby každý běh
// startoval nad prázdnou databází bez přehrávání celé historie migrací.
push: process.env.NODE_ENV === 'test',
migrationDir: path.resolve(dirname, '../migrations'),
}),
sharp,
plugins: [],
})