8 Commits
Author SHA1 Message Date
Honza 87c8f27e73 Add category level and turn the reticle into a background watermark
CI / test (push) Successful in 1m12s
CI / build-and-push (push) Successful in 2m53s
2026-08-02 19:15:20 +02:00
Honza 20beeeb420 Enlarge reticle and fix cropped tree line silhouette
CI / test (push) Successful in 1m5s
CI / build-and-push (push) Successful in 2m24s
2026-07-31 20:41:52 +02:00
HonzaandClaude Opus 5 f64ada5671 Odstranit pomocné skripty omylem přidané do repa
CI / test (push) Successful in 1m0s
CI / build-and-push (push) Successful in 2m31s
Vznikly jako mount pointy při spouštění nástrojů v kontejneru nad
bind-mountovaným adresářem repa.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 20:06:28 +02:00
HonzaandClaude Opus 5 3f967a2325 Přidat siluetu lesa a terč do vzhledu, normalizovat slug
CI / test (push) Successful in 1m8s
CI / build-and-push (push) Successful in 2m16s
Grafika:
- silueta lesa přes celou šířku nad patičkou
- terč v hlavičce mezi značkou a navigací, s červeným středem

Obojí je vložené jako CSS maska. Zdrojová PNG byla černá kresba na bílém
bez alfa kanálu, takže by z nich koukal bílý obdélník a v tmavém motivu
by to bylo nepoužitelné. Převedená maska nese jen průhlednost a barvu
řídí CSS. Skyline se tím zmenšil z 856 kB na 245 kB.

Assety patří do public/, ne do media/ — media je v produkci Docker volume
mimo image, takže by tam soubory nebyly.

Slug se navíc normalizuje sdíleným helperem: vedoucí lomítko vyrábělo
odkaz //kontakt, což prohlížeč čte jako jméno cizího serveru.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:26:34 +02:00
HonzaandClaude Opus 5 57f367c219 Skládat navigaci z kolekce Pages
CI / test (push) Successful in 1m0s
CI / build-and-push (push) Successful in 2m20s
Menu bylo napsané natvrdo a odkazovalo na /kontakt bez ohledu na to,
jestli stránka s tím slugem existuje — odtud 404. Stránky založené
v administraci se naopak neobjevily nikde.

Hlavička teď vypisuje stránky z kolekce, řazené podle názvu. Layout
kvůli tomu musí být force-dynamic, jinak by se při buildu předgenerovala
stránka 404, která tenhle layout používá taky, a build by sáhl do
databáze.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:00:48 +02:00
HonzaandClaude Opus 5 1c51034031 Opravit nezobrazování obrázků na webu
CI / test (push) Successful in 1m50s
CI / build-and-push (push) Successful in 2m27s
Payload skládá URL uploadů absolutně podle serverURL. next/image to
vyhodnotí jako externí zdroj a bez images.remotePatterns odmítne
s HTTP 400 "url parameter is not allowed" — samotný soubor se přitom
servíruje správně, takže se to projevilo až s nahraným obrázkem.

Do next/image proto posíláme jen cestu (helper cestaKSouboru). Soubor
leží na stejném originu jako web, takže je to korektní, a navíc na tom
nezávisí doména: remotePatterns se zapékají při buildu, ale tentýž image
běží na stagingu i v produkci pod jiným jménem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:44:54 +02:00
HonzaandClaude Opus 5 631d2ac2e3 Opravit pád testů na chybějícím adresáři data/
CI / test (push) Successful in 57s
CI / build-and-push (push) Successful in 22s
SQLite si adresář sám nevytvoří, takže `payload migrate` i integrační
testy padaly na SQLITE_CANTOPEN (kód 14). Lokálně se to neprojevilo,
protože adresář existoval z dřívějška; v CI po čerstvém checkoutu ne.

Adresáře data/ a media/ proto drží v repu .gitkeep, samotný obsah
zůstává ignorovaný. Řeší to i onboarding — po `git clone` teď projde
postup z README bez ručního mkdir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 13:45:17 +02:00
HonzaandClaude Opus 5 c8e435139e 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>
2026-07-30 13:13:23 +02:00
58 changed files with 13686 additions and 49 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
.git
.gitea
.vscode
.cache
.local
data
media
coverage
*.md
!README.md
.env
.env*.local
+25
View File
@@ -0,0 +1,25 @@
# Vzor konfigurace. Zkopíruj do `.env` a vyplň. `.env` NIKDY necommituj.
# Jméno Compose projektu odlišuje produkci od stagingu (vlastní volumes).
PROJECT=reiner-zbrane
APP_PORT=3000
# Která verze image má běžet. Rollback = přepsat na starší SHA a `pull` + `up -d`.
IMAGE_TAG=latest
# SQLite soubor. V kontejneru leží ve volume /app/data, takže přežije redeploy.
DATABASE_URI=file:./data/reiner.db
# Klíč pro podpis session cookie v produkci náhodný řetězec min. 32 znaků!
# Vygeneruj: openssl rand -base64 32
PAYLOAD_SECRET=zmen-me-na-nahodny-retezec
# Veřejná adresa webu Payload z ní skládá odkazy na obrázky a admin.
# Zápisy z jiné adresy Payload odmítá (CSRF), takže tohle musí sedět s tím,
# co máš v prohlížeči v adresním řádku.
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Další adresy, ze kterých smí admin zapisovat oddělené čárkou.
# Nech prázdné, pokud web otevíráš jen přes adresu výše.
# Např. při přístupu z jiného stroje v LAN: CSRF_ORIGINS=http://10.0.0.110:3000
CSRF_ORIGINS=
+73
View File
@@ -0,0 +1,73 @@
name: CI
# Spustí se při pushi do devel/release a jde i ručně spustit z GUI.
on:
push:
branches: [devel, release]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Zapnutí pnpm
run: corepack enable
- name: Instalace závislostí
run: pnpm install --frozen-lockfile
- name: ESLint
run: pnpm lint
- name: Testy
run: pnpm test
env:
PAYLOAD_SECRET: ci-placeholder-min-32-znaku-dlouhy-retezec
DATABASE_URI: file:./data/ci.db
# `next build` uvnitř sebe spouští i kontrolu typů.
- name: Build
run: pnpm build
env:
PAYLOAD_SECRET: ci-placeholder-min-32-znaku-dlouhy-retezec
DATABASE_URI: file:./data/ci.db
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Docker jméno musí být malými písmeny (Honza/Zbrane-Reiner-Web -> honza/zbrane-reiner-web)
- name: Název image
run: |
echo "IMAGE=gitea.doubynet.eu/$(echo '${{ github.repository }}' \
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
# release -> latest (produkce), devel -> devel (staging)
- name: Značka podle větve
run: |
if [ "${{ github.ref_name }}" = "release" ]; then
echo "MOVING_TAG=latest" >> "$GITHUB_ENV"
else
echo "MOVING_TAG=devel" >> "$GITHUB_ENV"
fi
- name: Přihlášení do Gitea registry
run: |
echo '${{ secrets.REGISTRY_TOKEN }}' \
| docker login gitea.doubynet.eu -u '${{ github.actor }}' --password-stdin
- name: Build image
run: docker build -t "$IMAGE:$MOVING_TAG" -t "$IMAGE:${{ github.sha }}" .
- name: Push image
run: |
docker push "$IMAGE:$MOVING_TAG"
docker push "$IMAGE:${{ github.sha }}"
+43
View File
@@ -0,0 +1,43 @@
# závislosti
/node_modules
node_modules/
# Next.js
/.next/
/out/
/build
# testy
/coverage
# konfigurace s tajemstvími
.env
.env*.local
# Data aplikace — obsah, ne kód. Samotné adresáře ale v repu být musí:
# SQLite si adresář nevytvoří a bez něj spadne `payload migrate` i testy
# hláškou SQLITE_CANTOPEN (kód 14).
/data/*
!/data/.gitkeep
/media/*
!/media/.gitkeep
# editory a OS
/.idea/*
!/.idea/runConfigurations
.DS_Store
*.pem
# logy
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# TypeScript
*.tsbuildinfo
next-env.d.ts
# zbytky po spouštění nástrojů v kontejneru (HOME=/app)
/.cache
/.local
/.config
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+6
View File
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"semi": false
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
}
+24
View File
@@ -0,0 +1,24 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Next.js: debug full stack",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/node_modules/next/dist/bin/next",
"runtimeArgs": ["--inspect"],
"skipFiles": ["<node_internals>/**"],
"serverReadyAction": {
"action": "debugWithChrome",
"killOnServerStop": true,
"pattern": "- Local:.+(https?://.+)",
"uriFormat": "%s",
"webRoot": "${workspaceFolder}"
},
"cwd": "${workspaceFolder}"
}
]
}
+41
View File
@@ -0,0 +1,41 @@
{
"npm.packageManager": "pnpm",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"[jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
},
"editor.formatOnSaveMode": "file",
"js/ts.tsdk.path": "node_modules/typescript/lib",
"js/ts.tsdk.promptToUseWorkspaceVersion": true,
"[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
}
}
}
+52
View File
@@ -0,0 +1,52 @@
# Changelog
Formát podle [Keep a Changelog](https://keepachangelog.com/cs/1.1.0/),
verzování [sémantické](https://semver.org/lang/cs/).
## [Nevydáno]
### Přidáno
- Kostra projektu na Payload CMS 3 + Next.js 16 nad SQLite.
- Kolekce `Products` (obrázky, volné parametry, cena, dostupnost, skrytí),
`Media` (zmenšeniny `nahled` a `detail`), `Pages`, `Users`.
- Veřejná část: výpis nabídky, detail položky, statické stránky.
- Administrace na `/admin` s vypnutou veřejnou registrací a zamykáním účtu
po pěti neúspěšných pokusech.
- Endpoint `/api/health` pro Docker HEALTHCHECK.
- Produkční `Dockerfile`, `docker-compose.yml` pro vývoj a
`docker-compose.prod.yml` pro nasazení z Gitea registry.
- CI v Gitea Actions: lint → testy → build → push image.
- Úvodní migrace databáze.
- Proměnná `CSRF_ORIGINS` pro případy, kdy se admin otevírá i z jiné adresy,
než je `NEXT_PUBLIC_SERVER_URL` (typicky po IP v LAN). Bez ní Payload
odmítal veškeré zápisy včetně nahrávání obrázků chybou 403.
- Kategorie jako vlastní kolekce s názvem, pořadím, úvodním obrázkem
a popisem. Položka na ni odkazuje vazbou místo dřívějšího pevného výběru,
takže rozšíření sortimentu nevyžaduje zásah do kódu.
- Tříúrovňová nabídka: úvodní stránka vypisuje kategorie jako dlaždice
s počtem položek, `/kategorie/<slug>` zboží v kategorii a detail zůstává
na `/nabidka/<slug>`. Plochá adresa detailu je záměr — přeřazení položky
do jiné kategorie tak nerozbije odkazy.
- Grafika: silueta lesa přes celou šířku nad patičkou a terč jako velký
nehybný vodoznak v pozadí stránky. Obojí je vloženo jako CSS maska —
zdrojové PNG nese jen průhlednost a barvu řídí CSS, takže to funguje
ve světlém i tmavém motivu. Výška siluety se odvozuje z poměru stran
obrázku, aby se neořezávaly špičky stromů.
### Opraveno
- Adresáře `data/` a `media/` jsou v repu přes `.gitkeep`. SQLite si adresář
nevytvoří, takže po čerstvém `git clone` padaly testy i `payload migrate`
na `SQLITE_CANTOPEN` (kód 14) — projevilo se to až v CI.
- Obrázky se na webu nezobrazovaly. Payload vrací absolutní URL podle
`serverURL`, což `next/image` bere jako cizí zdroj a bez `remotePatterns`
odmítne s HTTP 400 „url parameter is not allowed". Do `next/image` se nově
posílá jen cesta — soubor leží na stejném originu a stejný image tak funguje
na stagingu i v produkci, kde se domény liší.
- Stránky založené v administraci se nikde neobjevily. Navigace byla napsaná
natvrdo a odkazovala na `/kontakt`, i když stránka s tím slugem nemusela
existovat (odtud 404). Menu se teď skládá z kolekce Pages.
- Slug se u stránek i položek normalizuje — vedoucí lomítko (`/kontakt`)
vyrábělo odkaz `href="//kontakt"`, který prohlížeč čte jako jméno cizího
serveru.
+38
View File
@@ -0,0 +1,38 @@
# Produkční image. Záměrně BEZ Next `standalone` outputu: image je o pár set
# MB větší, ale Payload CLI (migrace) v něm funguje bez ladění file-tracingu.
# Na tento objem provozu je to správný kompromis.
FROM node:22-alpine AS base
# `corepack enable` jen vytvoří shim; bez `prepare` by si kontejner stahoval
# pnpm z npmjs.com až při startu — a bez internetu by vůbec nenaběhl.
RUN corepack enable && corepack prepare pnpm@11.18.0 --activate
WORKDIR /app
# --- závislosti (cache se zahodí jen při změně manifestů) ---
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# --- build ---
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Build jen sestavuje, do databáze nesahá — placeholder stačí a zadává se
# inline, aby nezůstal v ENV vrstvě image.
RUN PAYLOAD_SECRET=build-time-placeholder-nahrazen-za-behu pnpm build
# --- runtime ---
FROM base AS runtime
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1
# sqlite jen kvůli zálohám (`.dump`), ~1,5 MB
RUN apk add --no-cache sqlite
COPY --from=build /app ./
RUN mkdir -p /app/data /app/media
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
# Migrace proběhnou při startu, teprve pak naskočí server.
CMD ["sh", "-c", "pnpm payload migrate && pnpm start"]
+523
View File
@@ -0,0 +1,523 @@
# Návrh webové platformy — Martin Reiner, zbraně a střelivo
> Pracovní návrh vývojové a produkční platformy. Stav: před scaffoldem.
> Datum: 2026-07-30
> Revize: databáze změněna z PostgreSQL na SQLite po upřesnění rozsahu (viz [2. Volba stacku](#2-volba-stacku)).
> Revize: deploy přepsán na Gitea Actions + Gitea registry a sjednocen se zavedeným postupem z [Dashboardu](../Dashboard/) — pojmenování repa, rozdělení compose souborů i podoba CI (viz [4.](#4-pojmenování-repozitáře) a [7.](#7-deploy)).
> **Stav: naskafoldováno.** Projekt existuje, lint/testy/build i produkční image ověřeny. Produkční větev je `release` (výchozí větev repa na Giteji), ne `main`.
## Obsah
- [1. Zadání](#1-zadání)
- [2. Volba stacku](#2-volba-stacku)
- [3. Topologie](#3-topologie)
- [4. Pojmenování repozitáře](#4-pojmenování-repozitáře)
- [5. Struktura repa](#5-struktura-repa)
- [6. Konfigurační soubory](#6-konfigurační-soubory)
- [7. Deploy](#7-deploy)
- [8. Zálohy](#8-zálohy)
- [9. Cesta dál](#9-cesta-dál)
- [10. Otevřené otázky](#10-otevřené-otázky)
---
## 1. Zadání
| Parametr | Hodnota |
|---|---|
| Typ webu | Prezentace + jednoduchá administrace obsahu (CMS) |
| Obsah | Položky zboží: obrázek/obrázky, pár parametrů, cena. Nic víc. |
| Administrace | Přihlášení heslem přímo na webu, přidat / upravit / odebrat položku. Žádné zákaznické účty, žádný košík. |
| Produkce | Stávající server s Dockerem v domácí síti (tentýž, kde běží Dashboard) |
| HTTPS | Stávající domácí Caddy, veřejná IP, Let's Encrypt |
| Jazyk | TypeScript / JavaScript |
| K dispozici | Gitea (vlastní), Gitea Actions, server s Dockerem, Caddy |
| Požadavek | Oddělené dev a prod prostředí, vývoj na notebooku, nasazení na jiném stroji |
---
## 2. Volba stacku
| Volba | Důvod |
|---|---|
| **Payload CMS 3** | Admin rozhraní i veřejný web jsou **jedna Next.js aplikace**, ne dvě služby. Schéma obsahu se píše v TypeScriptu → typy pro frontend zdarma. Odpadá oddělený headless CMS. |
| **Next.js** | Součást Payloadu 3, není to volba navíc. |
| **SQLite** (`@payloadcms/db-sqlite`) | Payload bez databáze neběží, ale na tenhle rozsah stačí soubor. Jeden editor, žádné souběžné zápisy, desítky až stovky položek — Postgres by tu neřešil nic, jen přidal službu, heslo, healthcheck a druhý bod selhání. |
| **Docker Compose** | `docker-compose.yml` pro vývoj, `docker-compose.prod.yml` pro nasazení — stejné rozdělení jako u Dashboardu. Prostředí (prod / staging) se liší jen `.env`. |
| **Gitea Actions** | CI už je k dispozici, takže image staví runner, ne produkční server. Server pak jen `docker compose pull` — deploy trvá sekundy místo minut a produkce nikdy nekompiluje. |
| **Gitea container registry** | Vestavěná součást Gitey, netřeba Harbor ani GHCR. Běží za stejným Caddy, takže má platný certifikát a odpadá otravování s `insecure-registries`. |
Zvažované a zamítnuté varianty:
- **PostgreSQL** — v první verzi návrhu. Zamítnut po upřesnění rozsahu: pro obrázek, pár parametrů a cenu je to služba navíc bez přínosu. Payload adaptér se mění za jeden řádek, takže případný pozdější přechod je export/import pár set řádků, ne přestavba.
- **Astro + Decap CMS** — jednodušší provoz (obsah v gitu, žádná DB), ale slabší admin UX, nutný GitHub OAuth a rebuild po každé editaci. Horší cesta k budoucímu e-shopu.
- **Astro + soubory v gitu** — nejjednodušší provoz, ale položky by musel přidávat vývojář, ne majitel. Neplní zadání.
- **Astro + Directus** — dvě samostatné služby místo jedné, víc pohyblivých dílů bez odpovídajícího přínosu.
---
## 3. Topologie
```
┌─ notebook (vývoj) ─────┐
│ pnpm dev → :3000 │
│ DB = soubor v ./data │
└───────────┬────────────┘
│ git push
┌─ gitea.doubynet.eu ────────────────────────┐
│ repo release → produkce, devel → staging│
│ Actions: lint → test → build image │
│ registry: honza/zbrane-reiner-web:<sha> │
└───────────┬────────────────────────────────┘
│ docker compose pull (server si sáhne pro image)
┌─ server s Dockerem ────────┐
│ prod: app :3000 │
│ staging: app :3001 │
│ data + media volumes │
└───────────▲────────────────┘
│ reverse_proxy
┌───────────┴─────────────────┐
│ Caddy │
│ reiner-zbrane.cz → 3000│
│ dev.reiner-zbrane.cz → 3001│
│ gitea.doubynet.eu → Gitea│
└─────────────────────────────┘
```
Prod i staging běží na **jednom serveru** jako dva izolované Compose projekty — jiný `name:`, jiné porty, jiná volumes, jiný databázový soubor. Stačí stroj, který už máte, a na něm dva kontejnery navíc.
---
## 4. Pojmenování repozitáře
### Rozhodnutí: zůstat u `Zbrane-Reiner-Web`
První verze tohoto dokumentu navrhovala `web-reiner-zbrane` (malá písmena, prefix podle typu). **Zamítnuto** — v `~/Dokumenty/Repo/` už je zavedená jiná konvence a ta má přednost před teorií:
```
Curator Dashboard Dokumentace GOGUpdater
PlanetaryTime RWCalc SQLmem Trainyard Zbrane-Reiner-Web
```
TitleCase, žádný prefix podle typu, název = co to je. Zavést pro jeden projekt jiné pravidlo je horší než jakákoli nedokonalost toho stávajícího — konvence má cenu jen když platí všude.
**Velká písmena Dockeru nevadí**, protože je CI srovná — přesně jak to dělá Dashboard:
```yaml
echo "IMAGE=gitea.doubynet.eu/$(echo '${{ github.repository }}' \
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
```
`Honza/Zbrane-Reiner-Web``honza/zbrane-reiner-web`. Bez ručního zásahu.
### Propis do konfigurace
```
Gitea repo gitea.doubynet.eu/Honza/Zbrane-Reiner-Web (private)
adresář ~/Dokumenty/Repo/Zbrane-Reiner-Web
větve release (produkce), devel (staging)
doména reiner-zbrane.cz → Caddy → server:3000
staging dev.reiner-zbrane.cz → Caddy → server:3001
na serveru /srv/reiner-zbrane (release)
/srv/reiner-zbrane-staging (devel)
compose name reiner-zbrane / reiner-zbrane-staging
volumes reiner-zbrane_data, reiner-zbrane_media
databáze /app/data/reiner.db (uvnitř volume data)
image gitea.doubynet.eu/honza/zbrane-reiner-web:<tag>
```
Jméno repa (`Zbrane-Reiner-Web`) a jméno nasazení (`reiner-zbrane`) se **záměrně liší**. Repo je katalogová položka, nasazení je runtime — sufix `-Web` v cestách na serveru a ve jménech volumes nenese žádnou informaci, tam je webem všechno.
### Doplňky k pojmenování
- **Repo držet private** — skončí v něm struktura `.env`, migrace a časem integrace na dodavatele.
- **Přejmenovat před nasazením, ne po něm.** Název se propisuje do jmen Docker volumes; změna po nasazení znamená ruční migraci dat.
- **Přibude-li druhý projekt téhož klienta**, vznikne `Zbrane-Reiner-Strelnice` — stejný vzor, řadí se vedle sebe.
- **Monorepo** — pokud by víc webů sdílelo komponenty nebo design, ne N repozitářů, ale jedno repo s `apps/*` a `packages/ui`. Tento návrh do monorepa přejde bez přestavby.
---
## 5. Struktura repa
```
Zbrane-Reiner-Web/
├─ src/
│ ├─ collections/ # Products.ts, Media.ts, Pages.ts, Users.ts ← schéma obsahu
│ ├─ app/(frontend)/ # veřejný web
│ │ ├─ page.tsx # výpis nabídky
│ │ ├─ nabidka/[slug]/ # detail položky
│ │ ├─ [slug]/ # statické stránky z kolekce Pages
│ │ └─ api/health/ # endpoint pro Docker HEALTHCHECK
│ ├─ app/(payload)/ # admin, generuje Payload
│ ├─ lib/format.ts # formátování ceny a stavu
│ └─ payload.config.ts
├─ migrations/ # verzované DB migrace, commitují se
├─ .gitea/workflows/ci.yml # lint → test → build → push do registry
├─ Dockerfile
├─ .dockerignore
├─ docker-compose.yml # lokální vývoj (build z Dockerfile)
├─ docker-compose.prod.yml # produkce (image z registry)
├─ data/ # databázový soubor, NIKDY v gitu
├─ .env.example # v gitu
├─ .env # NIKDY v gitu
├─ PROJECT.md # cíle a fáze projektu
├─ CHANGELOG.md
└─ README.md
```
Rozdělení na `docker-compose.yml` a `docker-compose.prod.yml` (místo jednoho souboru s overridy) je převzaté z [Dashboardu](../Dashboard/) — prod soubor je samostatný, čitelný a na serveru je jediné, co tam z repa musí být.
### Obsahový model a přístup
| Kolekce | Obsah |
|---|---|
| `Products` | název, obrázky (vazba na Media), parametry (ráže, délka hlavně, stav…), cena, popis, příznak „prodáno" |
| `Media` | nahrané obrázky, Payload k nim sám generuje zmenšeniny |
| `Pages` | statické stránky — o nás, kontakt, otevírací doba |
| `Users` | **jeden účet pro majitele.** Registrace vypnutá, účet se založí jednou při prvním spuštění. |
Administrace je na `/admin` — přihlášení e-mailem a heslem, za ním rovnou seznam položek s tlačítky přidat / upravit / smazat. Žádná další role, žádné zákaznické účty. Veřejná část položky jen vypisuje.
> **Přihlašování ven z internetu.** Admin je dostupný na veřejné doméně, takže: silné heslo, zapnout Payloadu `maxLoginAttempts` + `lockTime` a zvážit two-factor. Alternativa, pokud stačí správa z domácí sítě — pustit `/admin` v Caddy jen z LAN.
---
## 6. Konfigurační soubory
### `Dockerfile`
Záměrně **bez** Next `standalone` outputu: image je o pár set MB větší, ale Payload CLI (migrace) v něm funguje bez ladění file-tracingu. Na tento objem provozu je to správný kompromis.
Skutečná podoba je v [Dockerfile](Dockerfile); podstatné jsou tři věci, které nejsou zřejmé:
```dockerfile
# `corepack enable` jen vytvoří shim; bez `prepare` by si kontejner stahoval
# pnpm z npmjs.com až při startu — a bez internetu by vůbec nenaběhl.
RUN corepack enable && corepack prepare pnpm@11.18.0 --activate
# Placeholder inline, ne přes ENV — jinak zůstane secret ve vrstvě image
# (a `docker build` na to právem nadává).
RUN PAYLOAD_SECRET=build-time-placeholder-nahrazen-za-behu pnpm build
# sqlite jen kvůli zálohám (`.dump`), ~1,5 MB
RUN apk add --no-cache sqlite
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
# Migrace proběhnou při startu, teprve pak naskočí server.
CMD ["sh", "-c", "pnpm payload migrate && pnpm start"]
```
`HEALTHCHECK` je převzatý ze vzoru [Dashboardu](../Dashboard/Dockerfile) — `docker ps` pak rovnou ukazuje, jestli aplikace opravdu odpovídá, ne jen jestli proces běží. Míří na [`/api/health`](src/app/(frontend)/api/health/route.ts), který **záměrně nesahá do databáze**: kdyby ji kontroloval, výpadek databáze by Docker „léčil“ restartem aplikace, což nepomůže.
Ověřeno: image naběhne i s `--network none`, tedy bez internetu.
### `docker-compose.yml` (lokální vývoj)
```yaml
# Lokální vývoj: build z Dockerfile. Databáze je soubor v ./data/.
name: reiner-zbrane-dev
services:
app:
build: .
ports:
- "3000:3000"
env_file: .env
volumes:
- ./src:/app/src # změny bez rebuildu
- ./data:/app/data
- ./media:/app/media
command: pnpm dev
restart: unless-stopped
```
Na notebooku se ale běžně **ani tohle nepoužije** — Payload se pouští nativně přes `pnpm dev` kvůli rychlému hot-reloadu. Tenhle soubor slouží k ověření, že se obraz vůbec postaví a rozjede.
### `docker-compose.prod.yml` (produkce i staging)
```yaml
# Produkční nasazení: image se STAHUJE z Gitea registry (nestaví se lokálně).
# Na serveru (jednorázově): docker login gitea.doubynet.eu
# Nasazení / aktualizace:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
name: ${PROJECT:-reiner-zbrane}
services:
app:
image: gitea.doubynet.eu/honza/zbrane-reiner-web:${IMAGE_TAG:-latest}
ports:
- "${APP_PORT:-3000}:3000" # viz poznámka o Caddy níže
env_file: .env
volumes:
- data:/app/data # databázový soubor
- media:/app/media # nahrané obrázky přežijí redeploy
restart: unless-stopped
volumes:
data:
media:
```
Jedna služba, dva volumes, žádné heslo k databázi. Celý stav webu = tyhle dva volumes.
### `.env.example`
```dotenv
# Vzor konfigurace. Zkopíruj do `.env` a vyplň. `.env` NIKDY necommituj.
PROJECT=reiner-zbrane
APP_PORT=3000
IMAGE_TAG=latest
DATABASE_URI=file:./data/reiner.db
# Klíč pro podpis session cookie v produkci náhodný řetězec min. 32 znaků!
PAYLOAD_SECRET=zmen-me-na-nahodny-retezec
NEXT_PUBLIC_SERVER_URL=https://reiner-zbrane.cz
```
Staging má `PROJECT=reiner-zbrane-staging`, `APP_PORT=3001`, `IMAGE_TAG=devel`, vlastní `PAYLOAD_SECRET` a `NEXT_PUBLIC_SERVER_URL=https://dev.reiner-zbrane.cz`. `DATABASE_URI` zůstává stejné — soubor je v jiném volume, takže se nemají jak potkat.
`IMAGE_TAG` je zároveň páka na rollback: přepsat na předchozí SHA, `pull`, `up -d`.
### Caddyfile (na stávajícím domácím Caddy)
```caddyfile
reiner-zbrane.cz, www.reiner-zbrane.cz {
reverse_proxy 192.168.1.50:3000
}
dev.reiner-zbrane.cz {
basic_auth { honza <bcrypt-hash> } # staging schovaný před světem i roboty
reverse_proxy 192.168.1.50:3001
}
```
Hash hesla: `caddy hash-password`.
Caddy už tímhle způsobem obsluhuje Gitea i Dashboard, takže přibývají jen dva bloky.
> **Síťová poznámka.** Pokud Caddy běží na **jiném stroji** než Docker, musí porty poslouchat na LAN, ne na `127.0.0.1`, a přístup se omezí firewallem:
>
> ```bash
> ufw allow from <IP-Caddy> to any port 3000,3001 proto tcp
> ufw default deny incoming
> ```
>
> Pokud Caddy běží na **tomtéž stroji** (jak to vypadá u Dashboardu), je to jednodušší a bezpečnější — v `docker-compose.prod.yml` stačí publikovat port jen na loopback: `"127.0.0.1:${APP_PORT}:3000"`. Ven se pak nedostane nic mimo Caddy a žádná `ufw` pravidla netřeba.
---
## 7. Deploy
Postup je záměrně **stejný jako u [Dashboardu](../Dashboard/)** — CI postaví a nahraje image, server si ho stáhne. Žádný nový vzor k naučení.
### Pipeline
| Krok | Kde běží |
|---|---|
| 1. `git push` do `devel` / `release` | notebook |
| 2. lint + typy + testy | Gitea Actions runner |
| 3. build image, tag `<sha>` + `devel`/`latest`, push do registry | Gitea Actions runner |
| 4. `docker compose pull && up -d`, migrace v entrypointu | server (ručně) |
| 5. Caddy posílá provoz beze změny | Caddy |
### `.gitea/workflows/ci.yml`
```yaml
name: CI
# Spustí se při pushi do devel/release a jde i ručně spustit z GUI.
on:
push:
branches: [devel, release]
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- name: Instalace pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Instalace závislostí
run: pnpm install --frozen-lockfile
- name: ESLint
run: pnpm lint
- name: Typová kontrola
run: pnpm exec tsc --noEmit
- name: Build (ověření, že projde)
run: pnpm build
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Docker jméno musí být malými písmeny (Honza/Zbrane-Reiner-Web -> honza/zbrane-reiner-web)
- name: Název image
run: |
echo "IMAGE=gitea.doubynet.eu/$(echo '${{ github.repository }}' \
| tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Značka podle větve
run: |
if [ "${{ github.ref_name }}" = "release" ]; then
echo "MOVING_TAG=latest" >> "$GITHUB_ENV"
else
echo "MOVING_TAG=devel" >> "$GITHUB_ENV"
fi
- name: Přihlášení do Gitea registry
run: |
echo '${{ secrets.REGISTRY_TOKEN }}' \
| docker login gitea.doubynet.eu -u '${{ github.actor }}' --password-stdin
- name: Build image
run: docker build -t "$IMAGE:$MOVING_TAG" -t "$IMAGE:${{ github.sha }}" .
- name: Push image
run: |
docker push "$IMAGE:$MOVING_TAG"
docker push "$IMAGE:${{ github.sha }}"
```
Oproti Dashboardu přibyl jen **plovoucí tag podle větve**`release``latest`, `devel``devel`. Bez toho by staging a produkce ukazovaly na tentýž `latest` a nešly by oddělit.
Záměrně **bez** `docker/build-push-action` — Gitea si actions tahá z github.com, takže každá závislost navíc je další věc, která se může rozbít. Holé `docker` funguje vždycky. Stejný důvod, proč to tak má Dashboard.
### Nasazení na serveru
```bash
cd /srv/reiner-zbrane-staging # nebo /srv/reiner-zbrane
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
Migrace se pustí samy v entrypointu kontejneru. Server **nic nekompiluje** a nemá zdrojové kódy — jen `docker-compose.prod.yml`, `.env` a dva volumes.
### Co je potřeba nastavit jednou
**Na Giteji** — repo → Settings → Secrets: `REGISTRY_TOKEN` (osobní token s oprávněním `write:package`). Stejný secret, jaký už používá Dashboard.
**Runner**`act_runner` už běží kvůli Dashboardu, další projekt nepotřebuje nic navíc.
**Na serveru** — jednorázově:
```bash
mkdir -p /srv/reiner-zbrane /srv/reiner-zbrane-staging
# do každého: docker-compose.prod.yml + .env
docker login gitea.doubynet.eu # aby šlo image stáhnout
```
### Dva adresáře na serveru
| Cesta | Větev | Tag | Port | Doména |
|---|---|---|---|---|
| `/srv/reiner-zbrane` | `release` | `latest` | 3000 | `reiner-zbrane.cz` |
| `/srv/reiner-zbrane-staging` | `devel` | `devel` | 3001 | `dev.reiner-zbrane.cz` |
Každý má vlastní `.env`. Liší se `PROJECT`, `APP_PORT`, `IMAGE_TAG` a `PAYLOAD_SECRET`.
### Denní workflow
```bash
# vývoj
git switch -c feature/kontaktni-formular
pnpm dev # localhost:3000
# změna schématu obsahu → migrace do gitu
pnpm payload migrate:create pridani-formulare
git add . && git commit && git push
# staging
git switch devel && git merge feature/kontaktni-formular && git push
ssh server 'cd /srv/reiner-zbrane-staging && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d'
# produkce, až to na dev.reiner-zbrane.cz sedí
git switch release && git merge devel && git push
ssh server 'cd /srv/reiner-zbrane && docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d'
```
### Rollback
```bash
sed -i 's/^IMAGE_TAG=.*/IMAGE_TAG=<predchozi-sha>/' .env
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
Funguje, protože každý build je otagovaný svým SHA a v registry zůstává. Migrace jsou ale jednosměrné — pokud verze mezitím změnila schéma databáze, patří k rollbacku i obnova zálohy.
---
## 8. Zálohy
S CMS je databáze **jediná kopie obsahu** — na rozdíl od statického webu ji nemáte v gitu. Nepodceňovat.
Cron na serveru, denně:
```bash
cd /srv/reiner-zbrane
docker compose -f docker-compose.prod.yml exec -T app \
sqlite3 /app/data/reiner.db .dump | gzip > /backup/db-$(date +%F).sql.gz
docker run --rm -v reiner-zbrane_media:/m -v /backup:/b alpine \
tar czf /b/media-$(date +%F).tgz -C /m .
find /backup -mtime +14 -delete
```
`.dump` běží v transakci, takže je konzistentní i za provozu — soubor **nekopírovat** obyčejným `cp`, ten za běhu s WAL zachytí rozpracovaný zápis. Obnova: `zcat db-2026-07-30.sql.gz | sqlite3 reiner.db`.
Zálohy odvézt i mimo server (rsync/restic na NAS nebo do cloudu) — záloha na stejném stroji neřeší selhání disku.
**Obnovu jednou za čas opravdu vyzkoušet na stagingu.** Záloha, kterou jste nikdy nenačetli, není záloha.
---
## 9. Cesta dál
Nic z toho neřešit teď — ruční `pull && up -d` vydrží hodně dlouho.
1. **Automatický deploy stagingu** — buď [Watchtower](https://containrrr.dev/watchtower/) sledující tag `devel`, nebo krok navíc v CI, který se přes SSH přihlásí na server a spustí `pull && up -d`. Ušetří jeden příkaz po každém pushi do `devel`; u produkce je ruční krok spíš výhoda.
2. **Buildx cache v CI** — první build instaluje závislosti od nuly. S `--cache-from` proti registry se sestavení zkrátí na desítky sekund.
3. **E-shop** — Payload má oficiální plugin pro platby; přechod nevyžaduje změnu infrastruktury.
4. **Postgres**, ale až kdyby přišly souběžné zápisy (objednávky, víc editorů současně). Mění se jeden adaptér v `payload.config.ts` a přidává jedna služba v `docker-compose.prod.yml`; data se převedou exportem a importem. Do té doby by to byla údržba navíc bez užitku.
---
## 10. Otevřené otázky
- [ ] **Příjmení: „Reirenr" vs. „Reiner"** — vypadá to jako překlep. Celý dokument počítá s `reiner`. Potvrdit před scaffoldem, protože se to propíše do jmen Docker volumes a přejmenování po nasazení znamená ruční migraci dat.
- [ ] **Skutečná doména** — do `NEXT_PUBLIC_SERVER_URL` a Caddyfile. Název repa už na ní nezávisí, ten je daný konvencí.
- [ ] **Běží Caddy na tomtéž stroji jako Docker?** — pokud ano, publikovat porty jen na `127.0.0.1` a vynechat `ufw` pravidla. Pokud ne, doplnit IP serveru (v návrhu placeholder `192.168.1.50`).
- [ ] **Porty 3000/3001 volné?** — na serveru už běží Dashboard na `:8000`, kolize nehrozí, ale ověřit.
- [ ] **Smazat prázdný adresář `Web-ZaS`.**
---
## Další krok
Po potvrzení otevřených otázek:
1. `git init` v tomto repu, remote na `gitea.doubynet.eu/Honza/Zbrane-Reiner-Web`, větve `release` + `devel`
2. vygenerovat Payload 3 + Next.js projekt se SQLite adaptérem
3. napsat `Dockerfile`, `.dockerignore`, `docker-compose.yml`, `docker-compose.prod.yml`, `.env.example`, `.gitea/workflows/ci.yml`, `README.md`, `PROJECT.md`, `CHANGELOG.md`
4. přidat kolekce `Products`, `Media`, `Pages`, `Users`, vypnout veřejnou registraci a založit jediný účet pro majitele
5. postavit veřejný výpis položek a detail položky
+67
View File
@@ -0,0 +1,67 @@
# Zbrane-Reiner-Web — cíle a stav projektu
> Prezentační web s vlastní administrací pro Martina Reinera (prodej zbraní,
> střeliva a doplňků). Majitel si sám spravuje nabídku přes přihlášení na webu.
>
> Návrh platformy a zdůvodnění rozhodnutí: [NAVRH-PLATFORMY.md](NAVRH-PLATFORMY.md).
## 1. Cíl a rozsah
**MVP obsah:**
- **Nabídka** — položky s obrázky, volnými parametry (ráže, délka hlavně…),
cenou a dostupností.
- **Detail položky** — galerie, tabulka parametrů, popis.
- **Statické stránky** — kontakt, otevírací doba, o nás.
**Administrace:**
- Přihlášení e-mailem a heslem na `/admin`.
- Přidat / upravit / odebrat položku, nahrát obrázky.
- Jediný účet majitele, žádná veřejná registrace, žádné role.
**Mimo rozsah (zatím):** košík a platby, zákaznické účty, sklad, rezervace,
vyhledávání a filtrování, vícejazyčnost.
## 2. Technologický stack
| Vrstva | Volba |
|---|---|
| CMS + web | Payload CMS 3 (jedna Next.js aplikace) |
| Framework | Next.js 16, React 19 |
| Databáze | SQLite (`@payloadcms/db-sqlite`) |
| Obrázky | sharp, zmenšeniny `nahled` a `detail` |
| Styly | jeden CSS soubor, bez frameworku |
| Testy | vitest (integrační, proti reálné databázi) |
| Lint | ESLint 9 flat config (`eslint-config-next`) |
| Kontejnerizace | Docker + docker compose |
| Reverzní proxy | Caddy (HTTPS přes Let's Encrypt) |
| Registry | Gitea Container Registry |
| CI | Gitea Actions |
## 3. Fáze vývoje
| Fáze | Obsah | Stav |
|---|---|---|
| 0 | Návrh platformy, volba stacku | ✅ hotovo |
| 1 | Scaffold, kolekce, veřejný web, CI, Docker | ✅ hotovo |
| 2 | Nasazení na server, Caddy, doména, zálohy | ⬜ čeká na doménu a IP |
| 3 | Obsah — skutečné položky, texty, fotky | ⬜ |
| 4 | Vzhled podle představy klienta | ⬜ |
| 5 | Filtrování podle kategorie, vyhledávání | ⬜ nice-to-have |
## 4. Otevřené otázky
Vedené v [NAVRH-PLATFORMY.md, sekce 10](NAVRH-PLATFORMY.md#10-otevřené-otázky):
skutečná doména, umístění Caddy vůči Dockeru, IP serveru.
## 5. Rozhodnutí, která stojí za připomenutí
- **SQLite, ne Postgres** — na obrázek, pár parametrů a cenu s jedním editorem
by Postgres přidal službu, heslo a druhý bod selhání bez užitku.
- **Parametry jako volný seznam dvojic**, ne pevná pole — u pušky, pistole
a střeliva se liší a pevná pole by znamenala migraci při každém sortimentu.
- **Stránky jsou `force-dynamic`** — statický prerender by znamenal, že se
úprava položky projeví až po přestavění webu.
- **Schéma se nedomýšlí za běhu** (`push: false`) — migrace se commitují
a v produkci se přehrají při startu kontejneru. Výjimkou jsou testy.
+75 -49
View File
@@ -1,61 +1,87 @@
# Dokumentace
# Zbrane-Reiner-Web
Personal development documentation — guidelines, conventions, and templates for my projects, structured for use with AI coding assistants.
Web Martina Reinera — prodej zbraní, střeliva a doplňků. Prezentace s vlastní
administrací obsahu postavená na [Payload CMS 3](https://payloadcms.com/)
(Next.js) nad SQLite.
## Purpose
Návrh platformy a zdůvodnění všech rozhodnutí: [NAVRH-PLATFORMY.md](NAVRH-PLATFORMY.md).
This repository is the single source of truth for how I develop software. AI assistants (Claude Code, Cursor, Copilot) are given these documents at the start of each session to understand my conventions before touching any code.
## Rychlý start
## Structure
```
Dokumentace/
├── Claude/ # AI assistant configuration
│ ├── CLAUDE.md # Claude Code session instructions
│ ├── AGENTS.md # Rules for all AI assistants
│ └── skills/ # Custom Claude Code skills
├── Python/ # Python development guidelines
│ ├── DESIGN_DOCUMENT.md # Guidelines for Python applications
│ ├── DESIGN_DOCUMENT_MODULE.md # Guidelines for Python libraries
│ ├── TEMPLATE.md # New project template specification
│ └── prebuild.py # Pre-build script (PyInstaller)
├── Rust/ # Rust development guidelines
│ ├── DESIGN_DOCUMENT.md # Guidelines for Rust applications
│ └── DESIGN_DOCUMENT_LIB.md # Guidelines for Rust libraries
├── Project template/ # Reusable files for new projects
│ ├── CHANGELOG.md # Changelog template
│ └── PROJECT.md # Project documentation template
└── Zscaler/ # Corporate network setup
├── NODE_EXTRA_CA_CERTS.md # Instructions for Zscaler cert
└── ZscalerRootCertificate-2048-SHA256.crt
```bash
cp .env.example .env # vyplň PAYLOAD_SECRET
pnpm install
pnpm payload migrate # založí databázi v data/
pnpm dev # http://localhost:3000
```
## AI Coding Workflow
První účet se založí sám při prvním otevření `/admin`.
Each project references these documents so AI assistants operate within consistent rules:
> Node se v repu nepředpokládá nainstalovaný globálně — pokud ho nemáš, jde
> všechno pustit v kontejneru:
> `docker run --rm -it -v "$PWD:/app" -w /app -p 3000:3000 node:22-alpine sh`
- **AGENTS.md** — language-agnostic rules: dependency management, testing, logging, git, versioning
- **DESIGN_DOCUMENT.md** — Python-specific: code style, tooling (Ruff, mypy, pytest, Poetry), project structure, logging with loguru, distribution via PyInstaller
- **DESIGN_DOCUMENT_MODULE.md** — same as above, adapted for Python libraries (no sinks, no `.env`, PyPI distribution)
## Struktura
## Languages Covered
```
src/
├─ collections/ # schéma obsahu — Products, Media, Pages, Users
├─ app/(frontend)/ # veřejný web
│ ├─ page.tsx # výpis nabídky
│ ├─ nabidka/[slug]/ # detail položky
│ ├─ [slug]/ # statické stránky z kolekce Pages
│ └─ api/health/ # endpoint pro Docker HEALTHCHECK
├─ app/(payload)/ # administrace, generuje Payload
├─ lib/format.ts # formátování ceny a stavu
└─ payload.config.ts
migrations/ # verzované DB migrace, commitují se
```
| Language | Guidelines |
|----------|-----------|
| Python (application) | `Python/DESIGN_DOCUMENT.md` |
| Python (library) | `Python/DESIGN_DOCUMENT_MODULE.md` |
| Rust (application) | `Rust/DESIGN_DOCUMENT.md` |
| Rust (library) | `Rust/DESIGN_DOCUMENT_LIB.md` |
## Příkazy
## Key Conventions
| Příkaz | Co dělá |
|---|---|
| `pnpm dev` | vývojový server s hot-reloadem |
| `pnpm lint` | ESLint nad `src` a `tests` |
| `pnpm test` | integrační testy (vitest) |
| `pnpm build` | produkční build, uvnitř i kontrola typů |
| `pnpm generate:types` | přegeneruje `src/payload-types.ts` ze schématu |
| `pnpm payload migrate:create <nazev>` | vytvoří migraci po změně schématu |
| `pnpm payload migrate` | přehraje migrace |
- **Python tooling:** Poetry · Ruff · mypy · pytest · loguru
- **No print() for debugging** — loguru everywhere
- **Type hints required** on all functions
- **Tests:** pytest only, no unittest, no mocks of the database
- **Versioning:** semantic, always ask before bumping
- **Secrets:** `.env` + `python-dotenv`, never committed
## Změna schématu obsahu
Schéma se nedomýšlí za běhu (`push: false`) — po každé úpravě kolekce:
```bash
pnpm generate:types
pnpm payload migrate:create nazev-zmeny
pnpm payload migrate
```
Migraci commitni. V produkci se přehraje sama při startu kontejneru.
Výjimkou jsou testy, kde se schéma odvodí z konfigurace, takže každý běh
startuje nad prázdnou databází.
## Nasazení
CI (Gitea Actions) postaví image a nahraje ho do Gitea registry; server si ho
jen stáhne. Celý postup včetně větví, tagů a rollbacku je v
[NAVRH-PLATFORMY.md, sekce 7](NAVRH-PLATFORMY.md#7-deploy).
```bash
# na serveru
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
```
| Větev | Prostředí | Tag image | Port |
|---|---|---|---|
| `release` | produkce | `latest` | 3000 |
| `devel` | staging | `devel` | 3001 |
## Data a zálohy
Veškerý stav jsou dva adresáře — `data/` (SQLite) a `media/` (nahrané
obrázky), v produkci Docker volumes. Databáze je **jediná kopie obsahu**,
v gitu není. Zálohování viz [NAVRH-PLATFORMY.md, sekce 8](NAVRH-PLATFORMY.md#8-zálohy).
View File
+24
View File
@@ -0,0 +1,24 @@
# Produkční nasazení: image se STAHUJE z Gitea registry (nestaví se lokálně).
# Server nepotřebuje zdrojové kódy stačí tento soubor a .env.
#
# Na serveru (jednorázově): docker login gitea.doubynet.eu
# Nasazení / aktualizace:
# docker compose -f docker-compose.prod.yml pull
# docker compose -f docker-compose.prod.yml up -d
name: ${PROJECT:-reiner-zbrane}
services:
app:
image: gitea.doubynet.eu/honza/zbrane-reiner-web:${IMAGE_TAG:-latest}
ports:
# Pokud Caddy běží na tomtéž stroji, předsaď 127.0.0.1: a nic není venku.
- "${APP_PORT:-3000}:3000"
env_file: .env
volumes:
- data:/app/data # databázový soubor
- media:/app/media # nahrané obrázky přežijí redeploy
restart: unless-stopped
volumes:
data:
media:
+14
View File
@@ -0,0 +1,14 @@
# Lokální ověření, že se image postaví a rozjede.
# Pro běžný vývoj se používá `pnpm dev` nativně — je to rychlejší (HMR).
name: reiner-zbrane-dev
services:
app:
build: .
ports:
- "3000:3000"
env_file: .env
volumes:
- ./data:/app/data
- ./media:/app/media
restart: unless-stopped
+42
View File
@@ -0,0 +1,42 @@
import nextCoreWebVitals from 'eslint-config-next/core-web-vitals'
import nextTypescript from 'eslint-config-next/typescript'
// eslint-config-next 16 nabízí flat config přímo, takže tu není FlatCompat
// ani @eslint/eslintrc — ten se s touhle kombinací verzí zacyklí.
const eslintConfig = [
...nextCoreWebVitals,
...nextTypescript,
{
rules: {
'@typescript-eslint/ban-ts-comment': 'warn',
'@typescript-eslint/no-empty-object-type': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': [
'warn',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: false,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
destructuredArrayIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^(_|ignore)',
},
],
},
},
{
ignores: [
'.next/',
'.cache/',
'.local/',
'data/',
'media/',
'migrations/',
'src/payload-types.ts',
'src/app/(payload)/admin/importMap.js',
],
},
]
export default eslintConfig
View File
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-sqlite'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.run(sql`CREATE TABLE \`products_obrazky\` (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` text PRIMARY KEY NOT NULL,
\`obrazek_id\` integer NOT NULL,
FOREIGN KEY (\`obrazek_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null,
FOREIGN KEY (\`_parent_id\`) REFERENCES \`products\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`products_obrazky_order_idx\` ON \`products_obrazky\` (\`_order\`);`)
await db.run(sql`CREATE INDEX \`products_obrazky_parent_id_idx\` ON \`products_obrazky\` (\`_parent_id\`);`)
await db.run(sql`CREATE INDEX \`products_obrazky_obrazek_idx\` ON \`products_obrazky\` (\`obrazek_id\`);`)
await db.run(sql`CREATE TABLE \`products_parametry\` (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` text PRIMARY KEY NOT NULL,
\`nazev\` text NOT NULL,
\`hodnota\` text NOT NULL,
FOREIGN KEY (\`_parent_id\`) REFERENCES \`products\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`products_parametry_order_idx\` ON \`products_parametry\` (\`_order\`);`)
await db.run(sql`CREATE INDEX \`products_parametry_parent_id_idx\` ON \`products_parametry\` (\`_parent_id\`);`)
await db.run(sql`CREATE TABLE \`products\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`nazev\` text NOT NULL,
\`slug\` text,
\`kategorie\` text DEFAULT 'zbrane' NOT NULL,
\`cena\` numeric NOT NULL,
\`stav\` text DEFAULT 'skladem' NOT NULL,
\`skryto\` integer DEFAULT false,
\`popis\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(sql`CREATE UNIQUE INDEX \`products_slug_idx\` ON \`products\` (\`slug\`);`)
await db.run(sql`CREATE INDEX \`products_updated_at_idx\` ON \`products\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`products_created_at_idx\` ON \`products\` (\`created_at\`);`)
await db.run(sql`CREATE TABLE \`media\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`alt\` text NOT NULL,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`url\` text,
\`thumbnail_u_r_l\` text,
\`filename\` text,
\`mime_type\` text,
\`filesize\` numeric,
\`width\` numeric,
\`height\` numeric,
\`focal_x\` numeric,
\`focal_y\` numeric,
\`sizes_nahled_url\` text,
\`sizes_nahled_width\` numeric,
\`sizes_nahled_height\` numeric,
\`sizes_nahled_mime_type\` text,
\`sizes_nahled_filesize\` numeric,
\`sizes_nahled_filename\` text,
\`sizes_detail_url\` text,
\`sizes_detail_width\` numeric,
\`sizes_detail_height\` numeric,
\`sizes_detail_mime_type\` text,
\`sizes_detail_filesize\` numeric,
\`sizes_detail_filename\` text
);
`)
await db.run(sql`CREATE INDEX \`media_updated_at_idx\` ON \`media\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`media_created_at_idx\` ON \`media\` (\`created_at\`);`)
await db.run(sql`CREATE UNIQUE INDEX \`media_filename_idx\` ON \`media\` (\`filename\`);`)
await db.run(sql`CREATE INDEX \`media_sizes_nahled_sizes_nahled_filename_idx\` ON \`media\` (\`sizes_nahled_filename\`);`)
await db.run(sql`CREATE INDEX \`media_sizes_detail_sizes_detail_filename_idx\` ON \`media\` (\`sizes_detail_filename\`);`)
await db.run(sql`CREATE TABLE \`pages\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`nazev\` text NOT NULL,
\`slug\` text NOT NULL,
\`obsah\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(sql`CREATE UNIQUE INDEX \`pages_slug_idx\` ON \`pages\` (\`slug\`);`)
await db.run(sql`CREATE INDEX \`pages_updated_at_idx\` ON \`pages\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`pages_created_at_idx\` ON \`pages\` (\`created_at\`);`)
await db.run(sql`CREATE TABLE \`users_sessions\` (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` text PRIMARY KEY NOT NULL,
\`created_at\` text,
\`expires_at\` text NOT NULL,
FOREIGN KEY (\`_parent_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`users_sessions_order_idx\` ON \`users_sessions\` (\`_order\`);`)
await db.run(sql`CREATE INDEX \`users_sessions_parent_id_idx\` ON \`users_sessions\` (\`_parent_id\`);`)
await db.run(sql`CREATE TABLE \`users\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`jmeno\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`email\` text NOT NULL,
\`reset_password_token\` text,
\`reset_password_expiration\` text,
\`salt\` text,
\`hash\` text,
\`login_attempts\` numeric DEFAULT 0,
\`lock_until\` text
);
`)
await db.run(sql`CREATE INDEX \`users_updated_at_idx\` ON \`users\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`users_created_at_idx\` ON \`users\` (\`created_at\`);`)
await db.run(sql`CREATE UNIQUE INDEX \`users_email_idx\` ON \`users\` (\`email\`);`)
await db.run(sql`CREATE TABLE \`payload_kv\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`key\` text NOT NULL,
\`data\` text NOT NULL
);
`)
await db.run(sql`CREATE UNIQUE INDEX \`payload_kv_key_idx\` ON \`payload_kv\` (\`key\`);`)
await db.run(sql`CREATE TABLE \`payload_locked_documents\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`global_slug\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_global_slug_idx\` ON \`payload_locked_documents\` (\`global_slug\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_updated_at_idx\` ON \`payload_locked_documents\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_created_at_idx\` ON \`payload_locked_documents\` (\`created_at\`);`)
await db.run(sql`CREATE TABLE \`payload_locked_documents_rels\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`order\` integer,
\`parent_id\` integer NOT NULL,
\`path\` text NOT NULL,
\`products_id\` integer,
\`media_id\` integer,
\`pages_id\` integer,
\`users_id\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_locked_documents\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`products_id\`) REFERENCES \`products\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`media_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`pages_id\`) REFERENCES \`pages\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_order_idx\` ON \`payload_locked_documents_rels\` (\`order\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_parent_idx\` ON \`payload_locked_documents_rels\` (\`parent_id\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_path_idx\` ON \`payload_locked_documents_rels\` (\`path\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_products_id_idx\` ON \`payload_locked_documents_rels\` (\`products_id\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_media_id_idx\` ON \`payload_locked_documents_rels\` (\`media_id\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_pages_id_idx\` ON \`payload_locked_documents_rels\` (\`pages_id\`);`)
await db.run(sql`CREATE INDEX \`payload_locked_documents_rels_users_id_idx\` ON \`payload_locked_documents_rels\` (\`users_id\`);`)
await db.run(sql`CREATE TABLE \`payload_preferences\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`key\` text,
\`value\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(sql`CREATE INDEX \`payload_preferences_key_idx\` ON \`payload_preferences\` (\`key\`);`)
await db.run(sql`CREATE INDEX \`payload_preferences_updated_at_idx\` ON \`payload_preferences\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`payload_preferences_created_at_idx\` ON \`payload_preferences\` (\`created_at\`);`)
await db.run(sql`CREATE TABLE \`payload_preferences_rels\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`order\` integer,
\`parent_id\` integer NOT NULL,
\`path\` text NOT NULL,
\`users_id\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_preferences\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`)
await db.run(sql`CREATE INDEX \`payload_preferences_rels_order_idx\` ON \`payload_preferences_rels\` (\`order\`);`)
await db.run(sql`CREATE INDEX \`payload_preferences_rels_parent_idx\` ON \`payload_preferences_rels\` (\`parent_id\`);`)
await db.run(sql`CREATE INDEX \`payload_preferences_rels_path_idx\` ON \`payload_preferences_rels\` (\`path\`);`)
await db.run(sql`CREATE INDEX \`payload_preferences_rels_users_id_idx\` ON \`payload_preferences_rels\` (\`users_id\`);`)
await db.run(sql`CREATE TABLE \`payload_migrations\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`name\` text,
\`batch\` numeric,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`)
await db.run(sql`CREATE INDEX \`payload_migrations_updated_at_idx\` ON \`payload_migrations\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`payload_migrations_created_at_idx\` ON \`payload_migrations\` (\`created_at\`);`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.run(sql`DROP TABLE \`products_obrazky\`;`)
await db.run(sql`DROP TABLE \`products_parametry\`;`)
await db.run(sql`DROP TABLE \`products\`;`)
await db.run(sql`DROP TABLE \`media\`;`)
await db.run(sql`DROP TABLE \`pages\`;`)
await db.run(sql`DROP TABLE \`users_sessions\`;`)
await db.run(sql`DROP TABLE \`users\`;`)
await db.run(sql`DROP TABLE \`payload_kv\`;`)
await db.run(sql`DROP TABLE \`payload_locked_documents\`;`)
await db.run(sql`DROP TABLE \`payload_locked_documents_rels\`;`)
await db.run(sql`DROP TABLE \`payload_preferences\`;`)
await db.run(sql`DROP TABLE \`payload_preferences_rels\`;`)
await db.run(sql`DROP TABLE \`payload_migrations\`;`)
}
+73
View File
@@ -0,0 +1,73 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-sqlite'
/**
* Kategorie se z pevného výběru stávají vlastní kolekcí.
*
* Psáno ručně, protože generátor se u přechodu `kategorie` (text) →
* `kategorie_id` (vazba) interaktivně ptá, jestli jde o přejmenování sloupce,
* nebo o nový. Jde o nový — a starý se převede na vazbu, ať položky nepřijdou
* o zařazení.
*/
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.run(sql`CREATE TABLE \`categories\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`nazev\` text NOT NULL,
\`slug\` text,
\`poradi\` numeric DEFAULT 0,
\`obrazek_id\` integer,
\`popis\` text,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
FOREIGN KEY (\`obrazek_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE set null
);
`)
await db.run(sql`CREATE UNIQUE INDEX \`categories_slug_idx\` ON \`categories\` (\`slug\`);`)
await db.run(sql`CREATE INDEX \`categories_obrazek_idx\` ON \`categories\` (\`obrazek_id\`);`)
await db.run(sql`CREATE INDEX \`categories_updated_at_idx\` ON \`categories\` (\`updated_at\`);`)
await db.run(sql`CREATE INDEX \`categories_created_at_idx\` ON \`categories\` (\`created_at\`);`)
// Tři kategorie, které dosud byly natvrdo v kódu. Na prázdné databázi
// slouží jako výchozí obsah, na existující jako cíl převodu.
await db.run(sql`INSERT INTO \`categories\` (\`nazev\`, \`slug\`, \`poradi\`) VALUES
('Zbraně', 'zbrane', 1),
('Střelivo', 'strelivo', 2),
('Doplňky', 'doplnky', 3);`)
await db.run(sql`ALTER TABLE \`products\` ADD \`kategorie_id\` integer REFERENCES categories(id);`)
await db.run(sql`CREATE INDEX \`products_kategorie_idx\` ON \`products\` (\`kategorie_id\`);`)
// Převod původní textové hodnoty na vazbu.
await db.run(sql`UPDATE \`products\` SET \`kategorie_id\` = (
SELECT \`id\` FROM \`categories\` WHERE \`categories\`.\`slug\` = \`products\`.\`kategorie\`
);`)
await db.run(sql`ALTER TABLE \`products\` DROP COLUMN \`kategorie\`;`)
// Payload si sem ukládá zámky rozepsaných dokumentů; bez sloupce by
// administrace u kategorií hlásila chybu.
await db.run(
sql`ALTER TABLE \`payload_locked_documents_rels\` ADD \`categories_id\` integer REFERENCES categories(id);`,
)
await db.run(
sql`CREATE INDEX \`payload_locked_documents_rels_categories_id_idx\` ON \`payload_locked_documents_rels\` (\`categories_id\`);`,
)
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.run(
sql`ALTER TABLE \`products\` ADD \`kategorie\` text DEFAULT 'zbrane' NOT NULL;`,
)
await db.run(sql`UPDATE \`products\` SET \`kategorie\` = COALESCE((
SELECT \`slug\` FROM \`categories\` WHERE \`categories\`.\`id\` = \`products\`.\`kategorie_id\`
), 'zbrane');`)
await db.run(sql`DROP INDEX IF EXISTS \`products_kategorie_idx\`;`)
await db.run(sql`ALTER TABLE \`products\` DROP COLUMN \`kategorie_id\`;`)
await db.run(sql`DROP INDEX IF EXISTS \`payload_locked_documents_rels_categories_id_idx\`;`)
await db.run(
sql`ALTER TABLE \`payload_locked_documents_rels\` DROP COLUMN \`categories_id\`;`,
)
await db.run(sql`DROP TABLE \`categories\`;`)
}
+15
View File
@@ -0,0 +1,15 @@
import * as migration_20260730_102034_initial from './20260730_102034_initial';
import * as migration_20260731_180000_kategorie from './20260731_180000_kategorie';
export const migrations = [
{
up: migration_20260730_102034_initial.up,
down: migration_20260730_102034_initial.down,
name: '20260730_102034_initial'
},
{
up: migration_20260731_180000_kategorie.up,
down: migration_20260731_180000_kategorie.down,
name: '20260731_180000_kategorie'
},
];
+31
View File
@@ -0,0 +1,31 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(__filename)
const nextConfig: NextConfig = {
images: {
localPatterns: [
{
pathname: '/api/media/file/**',
},
],
},
webpack: (webpackConfig) => {
webpackConfig.resolve.extensionAlias = {
'.cjs': ['.cts', '.cjs'],
'.js': ['.ts', '.tsx', '.js', '.jsx'],
'.mjs': ['.mts', '.mjs'],
}
return webpackConfig
},
turbopack: {
root: path.resolve(dirname),
},
}
export default withPayload(nextConfig, { devBundleServerPackages: false })
+55
View File
@@ -0,0 +1,55 @@
{
"name": "zbrane-reiner-web",
"version": "0.1.0",
"description": "Web Martina Reinera — zbraně a střelivo",
"license": "UNLICENSED",
"private": true,
"type": "module",
"scripts": {
"build": "cross-env NODE_OPTIONS=\"--no-deprecation --max-old-space-size=8000\" next build",
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev",
"devsafe": "rm -rf .next && cross-env NODE_OPTIONS=--no-deprecation next dev",
"generate:importmap": "cross-env NODE_OPTIONS=--no-deprecation payload generate:importmap",
"generate:types": "cross-env NODE_OPTIONS=--no-deprecation payload generate:types",
"lint": "cross-env NODE_OPTIONS=--no-deprecation eslint src tests",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start",
"typecheck": "tsc --noEmit",
"test": "pnpm run test:int",
"test:int": "cross-env NODE_OPTIONS=--no-deprecation vitest run --config ./vitest.config.mts"
},
"dependencies": {
"@payloadcms/db-sqlite": "3.86.0",
"@payloadcms/next": "3.86.0",
"@payloadcms/richtext-lexical": "3.86.0",
"@payloadcms/ui": "3.86.0",
"cross-env": "^7.0.3",
"dotenv": "16.4.7",
"graphql": "^16.8.1",
"next": "16.2.6",
"payload": "3.86.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"sharp": "0.34.2"
},
"devDependencies": {
"@testing-library/react": "16.3.0",
"@types/node": "22.19.9",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "4.5.2",
"eslint": "^9.16.0",
"eslint-config-next": "16.2.6",
"jsdom": "28.0.0",
"prettier": "^3.4.2",
"tsx": "4.22.4",
"typescript": "5.7.3",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18"
},
"packageManager": "pnpm@11.18.0",
"engines": {
"node": ">=22.12.0",
"pnpm": "^11"
}
}
+8851
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
# pnpm 11 už nečte nastavení z package.json — patří sem.
# Tyto balíčky smějí spustit build skript, protože kompilují nativní binárky.
allowBuilds:
esbuild: true
sharp: true
unrs-resolver: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+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' })
}
@@ -0,0 +1,75 @@
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 { Category } from '@/payload-types'
import { KartaPolozky } from '@/components/KartaPolozky'
import '../../styles.css'
export const dynamic = 'force-dynamic'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props) {
const kategorie = await najdiKategorii((await params).slug)
if (!kategorie) return { title: 'Kategorie nenalezena' }
return {
title: `${kategorie.nazev} — Reiner zbraně`,
description: kategorie.popis ?? undefined,
}
}
/** Výpis zboží v jedné kategorii. */
export default async function StrankaKategorie({ params }: Props) {
const kategorie = await najdiKategorii((await params).slug)
if (!kategorie) notFound()
const payload = await getPayload({ config: await config })
const { docs: polozky } = await payload.find({
collection: 'products',
where: {
kategorie: { equals: kategorie.id },
skryto: { not_equals: true },
},
sort: ['stav', '-updatedAt'],
depth: 1,
limit: 100,
})
return (
<div className="stranka">
<p className="zpet">
<Link href="/"> Zpět na nabídku</Link>
</p>
<h1>{kategorie.nazev}</h1>
{kategorie.popis && <p className="uvodni-text">{kategorie.popis}</p>}
{polozky.length === 0 ? (
<p className="prazdno">V této kategorii zatím nic není.</p>
) : (
<ul className="mrizka">
{polozky.map((polozka) => (
<KartaPolozky key={polozka.id} polozka={polozka} />
))}
</ul>
)}
</div>
)
}
async function najdiKategorii(slug: string): Promise<Category | null> {
const payload = await getPayload({ config: await config })
const { docs } = await payload.find({
collection: 'categories',
where: { slug: { equals: slug } },
depth: 0,
limit: 1,
})
return docs[0] ?? null
}
+66
View File
@@ -0,0 +1,66 @@
import React from 'react'
import Link from 'next/link'
import { getPayload } from 'payload'
import config from '@/payload.config'
import './styles.css'
export const metadata = {
description: 'Prodej zbraní, střeliva a doplňků — Martin Reiner.',
title: 'Reiner — zbraně a střelivo',
}
// Menu se skládá z obsahu, takže se nesmí předgenerovat při buildu.
// Platí i pro stránku 404, která tenhle layout používá také.
export const dynamic = 'force-dynamic'
export default async function RootLayout(props: { children: React.ReactNode }) {
const { children } = props
const payload = await getPayload({ config: await config })
// Do menu se propíše každá stránka založená v administraci — natvrdo psaný
// odkaz by mířil na něco, co majitel ještě nevytvořil, nebo pojmenoval jinak.
const { docs: stranky } = await payload.find({
collection: 'pages',
sort: 'nazev',
depth: 0,
limit: 20,
})
return (
<html lang="cs">
<body>
{/* Terč jako nehybný vodoznak — zůstává na místě i při rolování
a je pod obsahem, takže nepřekáží čtení. */}
<div className="terc-pozadi" aria-hidden="true" />
<header className="hlavicka">
<Link className="znacka" href="/">
Reiner <span>zbraně a střelivo</span>
</Link>
<nav>
<Link href="/">Nabídka</Link>
{stranky.map((stranka) => (
<Link key={stranka.id} href={`/${stranka.slug}`}>
{stranka.nazev}
</Link>
))}
</nav>
</header>
<main>{children}</main>
{/* Silueta lesa uzavírá stránku — přes celou šířku, nad patičkou. */}
<div className="skyline" aria-hidden="true" />
<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>
)
}
+110
View File
@@ -0,0 +1,110 @@
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 { cestaKSouboru, 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">
{typeof polozka.kategorie === 'object' && polozka.kategorie !== null ? (
<Link href={`/kategorie/${polozka.kategorie.slug}`}>
Zpět na {polozka.kategorie.nazev}
</Link>
) : (
<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={cestaKSouboru(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
}
+114
View File
@@ -0,0 +1,114 @@
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 { Category, Media } from '@/payload-types'
import { cestaKSouboru } 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'
/** Úvodní stránka: rozcestník po kategoriích nabídky. */
export default async function HomePage() {
const payload = await getPayload({ config: await config })
const { docs: kategorie } = await payload.find({
collection: 'categories',
sort: ['poradi', 'nazev'],
depth: 1,
limit: 50,
})
// Kolik položek je v které kategorii — ať dlaždice neslibuje prázdný výpis.
const pocty = await Promise.all(
kategorie.map(async (k) => {
const { totalDocs } = await payload.find({
collection: 'products',
where: {
kategorie: { equals: k.id },
skryto: { not_equals: true },
},
limit: 0,
depth: 0,
})
return [k.id, totalDocs] as const
}),
)
const pocetVKategorii = new Map(pocty)
return (
<div className="stranka">
<h1>Nabídka</h1>
{kategorie.length === 0 ? (
<p className="prazdno">
Zatím tu nic není. Kategorie se zakládají v{' '}
<Link href="/admin">administraci</Link>.
</p>
) : (
<ul className="mrizka">
{kategorie.map((k) => (
<DlazdiceKategorie
key={k.id}
kategorie={k}
pocet={pocetVKategorii.get(k.id) ?? 0}
/>
))}
</ul>
)}
</div>
)
}
function DlazdiceKategorie({ kategorie, pocet }: { kategorie: Category; pocet: number }) {
const obrazek = nahledKategorie(kategorie)
return (
<li className="karta">
<Link href={`/kategorie/${kategorie.slug}`}>
<div className="karta__obrazek">
{obrazek ? (
<Image
alt={obrazek.alt}
src={obrazek.url}
width={obrazek.width}
height={obrazek.height}
sizes="(max-width: 700px) 100vw, 320px"
/>
) : (
<div className="karta__bezobrazku">bez fotky</div>
)}
</div>
<h2>{kategorie.nazev}</h2>
<p className="karta__stav">
{pocet === 0 ? 'zatím prázdné' : `${pocet} ${sklonujPolozky(pocet)}`}
</p>
</Link>
</li>
)
}
function nahledKategorie(kategorie: Category) {
const obrazek = kategorie.obrazek
if (!obrazek || typeof obrazek === 'number') return null
const media = obrazek 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: cestaKSouboru(url), width, height, alt: media.alt ?? kategorie.nazev }
}
function sklonujPolozky(pocet: number): string {
if (pocet === 1) return 'položka'
if (pocet < 5) return 'položky'
return 'položek'
}
+339
View File
@@ -0,0 +1,339 @@
/*
* 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;
--barva-silueta: #a49b8e;
--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;
--barva-silueta: #3a3a3a;
}
}
* {
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);
}
/*
* Terč a silueta lesa jsou vloženy jako CSS maska, ne jako <img>. Zdrojové
* PNG nesou jen průhlednost, barvu určuje `background-color` — díky tomu
* fungují ve světlém i tmavém motivu a nekouká z nich bílý obdélník.
*/
/*
* Terč je vodoznak, ne prvek rozhraní: `fixed` ho drží na místě i při
* rolování, nízké krytí ho odsune do pozadí a `pointer-events: none`
* zajistí, že nepřekáží klikání.
*/
.terc-pozadi {
position: fixed;
top: 7rem;
left: 65%;
transform: translateX(-50%);
width: 246px;
height: 246px;
background-color: var(--barva-tlumena);
opacity: 0.2;
-webkit-mask: url('/terc.png') center / contain no-repeat;
mask: url('/terc.png') center / contain no-repeat;
pointer-events: none;
z-index: 0;
}
/* Červený střed maska nepřenese, tak ho doplníme. */
.terc-pozadi::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 21px;
height: 21px;
border-radius: 50%;
background: #c0392b;
transform: translate(-50%, -50%);
}
/* Obsah patří nad vodoznak. */
.hlavicka,
main,
.skyline,
.paticka {
position: relative;
z-index: 1;
}
/*
* Výška se odvozuje z poměru stran obrázku, ne z pevné hodnoty. Předtím tu
* byla pevná výška s `cover`, což ořezávalo špičky stromů. `100% 100%` při
* shodném poměru nic nedeformuje.
*/
.skyline {
width: 100%;
aspect-ratio: 1920 / 312;
margin-top: 4rem;
background-color: var(--barva-silueta);
-webkit-mask: url('/skyline.png') bottom center / 100% 100% no-repeat;
mask: url('/skyline.png') bottom center / 100% 100% no-repeat;
}
@media (max-width: 620px) {
.terc-pozadi {
top: 5rem;
width: 195px;
height: 195px;
}
}
.paticka {
max-width: var(--sirka);
margin: 0 auto;
padding: 1.5rem 1.25rem 3rem;
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);
}
.uvodni-text {
max-width: 65ch;
margin: -0.75rem 0 2rem;
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
+76
View File
@@ -0,0 +1,76 @@
import type { CollectionConfig } from 'payload'
import { vytvorSlug } from '../lib/slug'
/**
* Kategorie nabídky — zbraně, střelivo, doplňky a co časem přibude.
*
* Záměrně vlastní kolekce, ne výběr z pevného seznamu: sortiment se bude
* rozšiřovat (optika, trezory) a přidání kategorie tak nevyžaduje zásah
* do kódu ani nasazení.
*/
export const Categories: CollectionConfig = {
slug: 'categories',
labels: {
singular: 'Kategorie',
plural: 'Kategorie',
},
admin: {
useAsTitle: 'nazev',
defaultColumns: ['nazev', 'poradi', 'updatedAt'],
description: 'Rozdělení nabídky. Pořadí určuje, jak se zobrazí na úvodní stránce.',
},
access: {
read: () => true,
},
defaultSort: 'poradi',
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 }) => vytvorSlug(value || data?.nazev || '')],
},
},
{
name: 'poradi',
label: 'Pořadí',
type: 'number',
defaultValue: 0,
admin: {
position: 'sidebar',
description: 'Menší číslo = dřív. Při shodě se řadí podle názvu.',
},
},
{
name: 'obrazek',
label: 'Úvodní obrázek',
type: 'upload',
relationTo: 'media',
admin: {
description: 'Zobrazí se na dlaždici na úvodní stránce.',
},
},
{
name: 'popis',
label: 'Popis',
type: 'textarea',
admin: {
description: 'Krátký text nad výpisem položek v kategorii.',
},
},
],
}
+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,
},
],
}
+50
View File
@@ -0,0 +1,50 @@
import type { CollectionConfig } from 'payload'
import { vytvorSlug } from '../lib/slug'
/** 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',
unique: true,
index: true,
admin: {
position: 'sidebar',
description:
'Vyplní se samo z názvu, pokud necháte prázdné. Bez lomítek a diakritiky — ' +
'z „Otevírací doba" vznikne /oteviraci-doba.',
},
hooks: {
// Normalizuje i to, co uživatel napíše ručně — hlavně vede lomítko,
// které by vyrobilo odkaz na cizí server (`//kontakt`).
beforeValidate: [({ value, data }) => vytvorSlug(value || data?.nazev || '')],
},
},
{
name: 'obsah',
label: 'Obsah',
type: 'richText',
},
],
}
+151
View File
@@ -0,0 +1,151 @@
import type { CollectionConfig } from 'payload'
import { vytvorSlug } from '../lib/slug'
/**
* 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 }) => vytvorSlug(value || data?.nazev || '')],
},
},
{
name: 'kategorie',
label: 'Kategorie',
type: 'relationship',
relationTo: 'categories',
required: true,
index: true,
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.',
},
},
],
}
+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',
},
],
}
+53
View File
@@ -0,0 +1,53 @@
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
import type { Media, Product } from '@/payload-types'
import { cestaKSouboru, formatujCenu, popisStavu } from '@/lib/format'
/** Dlaždice zboží ve výpisu — používá ji úvod kategorie i případné další výpisy. */
export 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: cestaKSouboru(url), width, height, alt: media.alt ?? polozka.nazev }
}
+33
View File
@@ -0,0 +1,33 @@
import type { Product } from '@/payload-types'
/**
* Payload skládá URL obrázků absolutně podle `serverURL`
* (`https://…/api/media/file/x.png`). `next/image` by to bral jako cizí zdroj
* a bez `remotePatterns` ho odmítne s „url parameter is not allowed".
*
* Doménu proto zahazujeme — soubor leží na stejném originu jako web. Navíc
* je tím stejný image použitelný na stagingu i v produkci, kde se domény liší;
* `remotePatterns` se zapékají při buildu, takže by to jinak nešlo.
*/
export function cestaKSouboru(url: string): string {
try {
return new URL(url).pathname
} catch {
return url // už je relativní
}
}
/** `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]
}
+16
View File
@@ -0,0 +1,16 @@
/**
* Převede text na URL adresu: `Otevírací doba` → `oteviraci-doba`.
*
* Snese i to, co člověk do pole běžně napíše — lomítka (`/kontakt`),
* diakritiku, velká písmena i mezery. Právě lomítko je zrádné: uložený
* slug `/kontakt` vyrobí odkaz `href="//kontakt"`, což prohlížeč přečte
* jako jméno cizího serveru, ne jako cestu na tomhle webu.
*/
export function vytvorSlug(text: string): string {
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
+556
View File
@@ -0,0 +1,556 @@
/* 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;
categories: Category;
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>;
categories: CategoriesSelect<false> | CategoriesSelect<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: number | Category;
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;
}
/**
* Rozdělení nabídky. Pořadí určuje, jak se zobrazí na úvodní stránce.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "categories".
*/
export interface Category {
id: number;
nazev: string;
/**
* Vyplní se samo z názvu, pokud necháte prázdné.
*/
slug?: string | null;
/**
* Menší číslo = dřív. Při shodě se řadí podle názvu.
*/
poradi?: number | null;
/**
* Zobrazí se na dlaždici na úvodní stránce.
*/
obrazek?: (number | null) | Media;
/**
* Krátký text nad výpisem položek v kategorii.
*/
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;
/**
* Vyplní se samo z názvu, pokud necháte prázdné. Bez lomítek a diakritiky — z „Otevírací doba" vznikne /oteviraci-doba.
*/
slug?: string | null;
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: 'categories';
value: number | Category;
} | 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` "categories_select".
*/
export interface CategoriesSelect<T extends boolean = true> {
nazev?: T;
slug?: T;
poradi?: T;
obrazek?: 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 {}
}
+59
View File
@@ -0,0 +1,59 @@
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 { Categories } from './collections/Categories'
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, Categories, 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: [],
})
+50
View File
@@ -0,0 +1,50 @@
import { getPayload, Payload } from 'payload'
import config from '@/payload.config'
import { describe, it, beforeAll, expect } from 'vitest'
let payload: Payload
describe('Payload API', () => {
beforeAll(async () => {
// Schéma vznikne z konfigurace (`push` je zapnuté jen v testech).
payload = await getPayload({ config: await config })
})
it('má nadefinované všechny kolekce', () => {
const slugy = Object.keys(payload.collections)
expect(slugy).toEqual(
expect.arrayContaining(['products', 'categories', 'media', 'pages', 'users']),
)
})
it('umí založit a najít položku nabídky', async () => {
const kategorie = await payload.create({
collection: 'categories',
data: { nazev: 'Zbraně' },
})
expect(kategorie.slug).toBe('zbrane')
const vytvorena = await payload.create({
collection: 'products',
data: {
nazev: 'CZ 75 SP-01 Shadow',
kategorie: kategorie.id,
cena: 28900,
stav: 'skladem',
},
})
expect(vytvorena.id).toBeDefined()
// slug se doplňuje hookem z názvu
expect(vytvorena.slug).toBe('cz-75-sp-01-shadow')
const nalezene = await payload.find({
collection: 'products',
where: { slug: { equals: 'cz-75-sp-01-shadow' } },
})
expect(nalezene.docs).toHaveLength(1)
expect(nalezene.docs[0]?.cena).toBe(28900)
})
})
+45
View File
@@ -0,0 +1,45 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
],
"@payload-config": [
"./src/payload.config.ts"
]
},
"target": "ES2022"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: ['tests/int/**/*.int.spec.ts'],
},
})
+4
View File
@@ -0,0 +1,4 @@
// Any setup scripts you might need go here
// Load .env files
import 'dotenv/config'