Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87c8f27e73 | ||
|
|
20beeeb420 | ||
|
|
f64ada5671 | ||
|
|
3f967a2325 | ||
|
|
57f367c219 | ||
|
|
1c51034031 | ||
|
|
631d2ac2e3 | ||
|
|
c8e435139e |
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.vscode
|
||||||
|
.cache
|
||||||
|
.local
|
||||||
|
data
|
||||||
|
media
|
||||||
|
coverage
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
.env
|
||||||
|
.env*.local
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": false
|
||||||
|
}
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
||||||
|
}
|
||||||
Vendored
+24
@@ -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}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+41
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -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
|
```bash
|
||||||
|
cp .env.example .env # vyplň PAYLOAD_SECRET
|
||||||
```
|
pnpm install
|
||||||
Dokumentace/
|
pnpm payload migrate # založí databázi v data/
|
||||||
├── Claude/ # AI assistant configuration
|
pnpm dev # http://localhost:3000
|
||||||
│ ├── 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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
|
## Struktura
|
||||||
- **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)
|
|
||||||
|
|
||||||
## 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 |
|
## Příkazy
|
||||||
|----------|-----------|
|
|
||||||
| 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` |
|
|
||||||
|
|
||||||
## 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
|
## Změna schématu obsahu
|
||||||
- **No print() for debugging** — loguru everywhere
|
|
||||||
- **Type hints required** on all functions
|
Schéma se nedomýšlí za běhu (`push: false`) — po každé úpravě kolekce:
|
||||||
- **Tests:** pytest only, no unittest, no mocks of the database
|
|
||||||
- **Versioning:** semantic, always ask before bumping
|
```bash
|
||||||
- **Secrets:** `.env` + `python-dotenv`, never committed
|
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).
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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\`;`)
|
||||||
|
}
|
||||||
@@ -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\`;`)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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 })
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+8851
File diff suppressed because it is too large
Load Diff
@@ -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 |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
č. 14/2021 Sb.
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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, ' ')} Kč`
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
@@ -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, '')
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Any setup scripts you might need go here
|
||||||
|
|
||||||
|
// Load .env files
|
||||||
|
import 'dotenv/config'
|
||||||
Reference in New Issue
Block a user