Add category level and turn the reticle into a background watermark
This commit is contained in:
+12
-5
@@ -21,11 +21,18 @@ verzování [sémantické](https://semver.org/lang/cs/).
|
|||||||
- Proměnná `CSRF_ORIGINS` pro případy, kdy se admin otevírá i z jiné adresy,
|
- 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
|
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.
|
odmítal veškeré zápisy včetně nahrávání obrázků chybou 403.
|
||||||
- Grafika: silueta lesa přes celou šířku nad patičkou a terč v hlavičce
|
- Kategorie jako vlastní kolekce s názvem, pořadím, úvodním obrázkem
|
||||||
mezi značkou a navigací. Obojí je vloženo jako CSS maska — zdrojové PNG
|
a popisem. Položka na ni odkazuje vazbou místo dřívějšího pevného výběru,
|
||||||
nese jen průhlednost a barvu řídí CSS, takže to funguje ve světlém
|
takže rozšíření sortimentu nevyžaduje zásah do kódu.
|
||||||
i tmavém motivu. Výška siluety se odvozuje z poměru stran obrázku, aby
|
- Tříúrovňová nabídka: úvodní stránka vypisuje kategorie jako dlaždice
|
||||||
se neořezávaly špičky stromů.
|
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
|
### Opraveno
|
||||||
|
|
||||||
|
|||||||
@@ -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\`;`)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as migration_20260730_102034_initial from './20260730_102034_initial';
|
import * as migration_20260730_102034_initial from './20260730_102034_initial';
|
||||||
|
import * as migration_20260731_180000_kategorie from './20260731_180000_kategorie';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
@@ -6,4 +7,9 @@ export const migrations = [
|
|||||||
down: migration_20260730_102034_initial.down,
|
down: migration_20260730_102034_initial.down,
|
||||||
name: '20260730_102034_initial'
|
name: '20260730_102034_initial'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260731_180000_kategorie.up,
|
||||||
|
down: migration_20260731_180000_kategorie.down,
|
||||||
|
name: '20260731_180000_kategorie'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -30,11 +30,14 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<html lang="cs">
|
<html lang="cs">
|
||||||
<body>
|
<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">
|
<header className="hlavicka">
|
||||||
<Link className="znacka" href="/">
|
<Link className="znacka" href="/">
|
||||||
Reiner <span>zbraně a střelivo</span>
|
Reiner <span>zbraně a střelivo</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="terc" aria-hidden="true" />
|
|
||||||
<nav>
|
<nav>
|
||||||
<Link href="/">Nabídka</Link>
|
<Link href="/">Nabídka</Link>
|
||||||
{stranky.map((stranka) => (
|
{stranky.map((stranka) => (
|
||||||
|
|||||||
@@ -34,7 +34,13 @@ export default async function DetailPolozky({ params }: Props) {
|
|||||||
return (
|
return (
|
||||||
<article className="stranka detail">
|
<article className="stranka detail">
|
||||||
<p className="zpet">
|
<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>
|
<Link href="/">← Zpět na nabídku</Link>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1>{polozka.nazev}</h1>
|
<h1>{polozka.nazev}</h1>
|
||||||
|
|||||||
+57
-33
@@ -4,38 +4,59 @@ import { getPayload } from 'payload'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import config from '@/payload.config'
|
import config from '@/payload.config'
|
||||||
import type { Media, Product } from '@/payload-types'
|
import type { Category, Media } from '@/payload-types'
|
||||||
import { cestaKSouboru, formatujCenu, popisStavu } from '@/lib/format'
|
import { cestaKSouboru } from '@/lib/format'
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
// Obsah se čte z CMS při každém požadavku — jinak by se úprava položky
|
// 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.
|
// projevila až po přestavění webu.
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
/** Výpis nabídky. Skryté položky se nezobrazují, prodané se řadí nakonec. */
|
/** Úvodní stránka: rozcestník po kategoriích nabídky. */
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const payload = await getPayload({ config: await config })
|
const payload = await getPayload({ config: await config })
|
||||||
|
|
||||||
const { docs: polozky } = await payload.find({
|
const { docs: kategorie } = await payload.find({
|
||||||
collection: 'products',
|
collection: 'categories',
|
||||||
where: { skryto: { not_equals: true } },
|
sort: ['poradi', 'nazev'],
|
||||||
sort: ['stav', '-updatedAt'],
|
|
||||||
depth: 1,
|
depth: 1,
|
||||||
limit: 100,
|
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 (
|
return (
|
||||||
<div className="stranka">
|
<div className="stranka">
|
||||||
<h1>Nabídka</h1>
|
<h1>Nabídka</h1>
|
||||||
|
|
||||||
{polozky.length === 0 ? (
|
{kategorie.length === 0 ? (
|
||||||
<p className="prazdno">
|
<p className="prazdno">
|
||||||
Zatím tu nic není. Položky se přidávají v <Link href="/admin">administraci</Link>.
|
Zatím tu nic není. Kategorie se zakládají v{' '}
|
||||||
|
<Link href="/admin">administraci</Link>.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mrizka">
|
<ul className="mrizka">
|
||||||
{polozky.map((polozka) => (
|
{kategorie.map((k) => (
|
||||||
<KartaPolozky key={polozka.id} polozka={polozka} />
|
<DlazdiceKategorie
|
||||||
|
key={k.id}
|
||||||
|
kategorie={k}
|
||||||
|
pocet={pocetVKategorii.get(k.id) ?? 0}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
@@ -43,42 +64,39 @@ export default async function HomePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function KartaPolozky({ polozka }: { polozka: Product }) {
|
function DlazdiceKategorie({ kategorie, pocet }: { kategorie: Category; pocet: number }) {
|
||||||
const nahled = prvniObrazek(polozka)
|
const obrazek = nahledKategorie(kategorie)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={`karta${polozka.stav === 'prodano' ? ' karta--prodano' : ''}`}>
|
<li className="karta">
|
||||||
<Link href={`/nabidka/${polozka.slug}`}>
|
<Link href={`/kategorie/${kategorie.slug}`}>
|
||||||
<div className="karta__obrazek">
|
<div className="karta__obrazek">
|
||||||
{nahled ? (
|
{obrazek ? (
|
||||||
<Image
|
<Image
|
||||||
alt={nahled.alt}
|
alt={obrazek.alt}
|
||||||
src={nahled.url}
|
src={obrazek.url}
|
||||||
width={nahled.width}
|
width={obrazek.width}
|
||||||
height={nahled.height}
|
height={obrazek.height}
|
||||||
sizes="(max-width: 700px) 100vw, 320px"
|
sizes="(max-width: 700px) 100vw, 320px"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="karta__bezobrazku">bez fotky</div>
|
<div className="karta__bezobrazku">bez fotky</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h2>{polozka.nazev}</h2>
|
<h2>{kategorie.nazev}</h2>
|
||||||
<p className="karta__cena">{formatujCenu(polozka.cena)}</p>
|
<p className="karta__stav">
|
||||||
<p className="karta__stav">{popisStavu(polozka.stav)}</p>
|
{pocet === 0 ? 'zatím prázdné' : `${pocet} ${sklonujPolozky(pocet)}`}
|
||||||
|
</p>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function nahledKategorie(kategorie: Category) {
|
||||||
* Vytáhne z položky první obrázek v podobě použitelné pro `next/image`.
|
const obrazek = kategorie.obrazek
|
||||||
* Vrací `null`, pokud položka fotku nemá nebo se nepodařilo načíst rozměry.
|
if (!obrazek || typeof obrazek === 'number') return null
|
||||||
*/
|
|
||||||
function prvniObrazek(polozka: Product) {
|
|
||||||
const prvni = polozka.obrazky?.[0]?.obrazek
|
|
||||||
if (!prvni || typeof prvni === 'number') return null
|
|
||||||
|
|
||||||
const media = prvni as Media
|
const media = obrazek as Media
|
||||||
const varianta = media.sizes?.nahled
|
const varianta = media.sizes?.nahled
|
||||||
const url = varianta?.url ?? media.url
|
const url = varianta?.url ?? media.url
|
||||||
const width = varianta?.width ?? media.width
|
const width = varianta?.width ?? media.width
|
||||||
@@ -86,5 +104,11 @@ function prvniObrazek(polozka: Product) {
|
|||||||
|
|
||||||
if (!url || !width || !height) return null
|
if (!url || !width || !height) return null
|
||||||
|
|
||||||
return { url: cestaKSouboru(url), width, height, alt: media.alt ?? polozka.nazev }
|
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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,30 +103,48 @@ a {
|
|||||||
* PNG nesou jen průhlednost, barvu určuje `background-color` — díky tomu
|
* 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.
|
* fungují ve světlém i tmavém motivu a nekouká z nich bílý obdélník.
|
||||||
*/
|
*/
|
||||||
.terc {
|
/*
|
||||||
position: relative;
|
* Terč je vodoznak, ne prvek rozhraní: `fixed` ho drží na místě i při
|
||||||
flex: 0 0 auto;
|
* rolování, nízké krytí ho odsune do pozadí a `pointer-events: none`
|
||||||
width: 63px;
|
* zajistí, že nepřekáží klikání.
|
||||||
height: 63px;
|
*/
|
||||||
|
.terc-pozadi {
|
||||||
|
position: fixed;
|
||||||
|
top: 7rem;
|
||||||
|
left: 65%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 246px;
|
||||||
|
height: 246px;
|
||||||
background-color: var(--barva-tlumena);
|
background-color: var(--barva-tlumena);
|
||||||
|
opacity: 0.2;
|
||||||
-webkit-mask: url('/terc.png') center / contain no-repeat;
|
-webkit-mask: url('/terc.png') center / contain no-repeat;
|
||||||
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 je jediný barevný akcent v hlavičce — maska ho neumí
|
/* Červený střed maska nepřenese, tak ho doplníme. */
|
||||||
přenést, tak ho doplníme. */
|
.terc-pozadi::after {
|
||||||
.terc::after {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
width: 7px;
|
width: 21px;
|
||||||
height: 7px;
|
height: 21px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #c0392b;
|
background: #c0392b;
|
||||||
transform: translate(-50%, -50%);
|
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
|
* 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
|
* byla pevná výška s `cover`, což ořezávalo špičky stromů. `100% 100%` při
|
||||||
@@ -142,8 +160,10 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.terc {
|
.terc-pozadi {
|
||||||
display: none;
|
top: 5rem;
|
||||||
|
width: 195px;
|
||||||
|
height: 195px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,6 +197,12 @@ a {
|
|||||||
color: var(--barva-tlumena);
|
color: var(--barva-tlumena);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.uvodni-text {
|
||||||
|
max-width: 65ch;
|
||||||
|
margin: -0.75rem 0 2rem;
|
||||||
|
color: var(--barva-tlumena);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- výpis nabídky ---------- */
|
/* ---------- výpis nabídky ---------- */
|
||||||
|
|
||||||
.mrizka {
|
.mrizka {
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -49,14 +49,10 @@ export const Products: CollectionConfig = {
|
|||||||
{
|
{
|
||||||
name: 'kategorie',
|
name: 'kategorie',
|
||||||
label: 'Kategorie',
|
label: 'Kategorie',
|
||||||
type: 'select',
|
type: 'relationship',
|
||||||
|
relationTo: 'categories',
|
||||||
required: true,
|
required: true,
|
||||||
defaultValue: 'zbrane',
|
index: true,
|
||||||
options: [
|
|
||||||
{ label: 'Zbraně', value: 'zbrane' },
|
|
||||||
{ label: 'Střelivo', value: 'strelivo' },
|
|
||||||
{ label: 'Doplňky', value: 'doplnky' },
|
|
||||||
],
|
|
||||||
admin: { position: 'sidebar' },
|
admin: { position: 'sidebar' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
+48
-1
@@ -68,6 +68,7 @@ export interface Config {
|
|||||||
blocks: {};
|
blocks: {};
|
||||||
collections: {
|
collections: {
|
||||||
products: Product;
|
products: Product;
|
||||||
|
categories: Category;
|
||||||
media: Media;
|
media: Media;
|
||||||
pages: Page;
|
pages: Page;
|
||||||
users: User;
|
users: User;
|
||||||
@@ -79,6 +80,7 @@ export interface Config {
|
|||||||
collectionsJoins: {};
|
collectionsJoins: {};
|
||||||
collectionsSelect: {
|
collectionsSelect: {
|
||||||
products: ProductsSelect<false> | ProductsSelect<true>;
|
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||||
|
categories: CategoriesSelect<false> | CategoriesSelect<true>;
|
||||||
media: MediaSelect<false> | MediaSelect<true>;
|
media: MediaSelect<false> | MediaSelect<true>;
|
||||||
pages: PagesSelect<false> | PagesSelect<true>;
|
pages: PagesSelect<false> | PagesSelect<true>;
|
||||||
users: UsersSelect<false> | UsersSelect<true>;
|
users: UsersSelect<false> | UsersSelect<true>;
|
||||||
@@ -134,7 +136,7 @@ export interface Product {
|
|||||||
* Vyplní se samo z názvu, pokud necháte prázdné.
|
* Vyplní se samo z názvu, pokud necháte prázdné.
|
||||||
*/
|
*/
|
||||||
slug?: string | null;
|
slug?: string | null;
|
||||||
kategorie: 'zbrane' | 'strelivo' | 'doplnky';
|
kategorie: number | Category;
|
||||||
cena: number;
|
cena: number;
|
||||||
stav: 'skladem' | 'na-objednavku' | 'prodano';
|
stav: 'skladem' | 'na-objednavku' | 'prodano';
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +169,34 @@ export interface Product {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "media".
|
* via the `definition` "media".
|
||||||
@@ -287,6 +317,10 @@ export interface PayloadLockedDocument {
|
|||||||
relationTo: 'products';
|
relationTo: 'products';
|
||||||
value: number | Product;
|
value: number | Product;
|
||||||
} | null)
|
} | null)
|
||||||
|
| ({
|
||||||
|
relationTo: 'categories';
|
||||||
|
value: number | Category;
|
||||||
|
} | null)
|
||||||
| ({
|
| ({
|
||||||
relationTo: 'media';
|
relationTo: 'media';
|
||||||
value: number | Media;
|
value: number | Media;
|
||||||
@@ -369,6 +403,19 @@ export interface ProductsSelect<T extends boolean = true> {
|
|||||||
updatedAt?: T;
|
updatedAt?: T;
|
||||||
createdAt?: 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
|
* This interface was referenced by `Config`'s JSON-Schema
|
||||||
* via the `definition` "media_select".
|
* via the `definition` "media_select".
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { buildConfig } from 'payload'
|
|||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import sharp from 'sharp'
|
import sharp from 'sharp'
|
||||||
|
|
||||||
|
import { Categories } from './collections/Categories'
|
||||||
import { Media } from './collections/Media'
|
import { Media } from './collections/Media'
|
||||||
import { Pages } from './collections/Pages'
|
import { Pages } from './collections/Pages'
|
||||||
import { Products } from './collections/Products'
|
import { Products } from './collections/Products'
|
||||||
@@ -34,7 +35,7 @@ export default buildConfig({
|
|||||||
titleSuffix: ' — Reiner zbraně',
|
titleSuffix: ' — Reiner zbraně',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
collections: [Products, Media, Pages, Users],
|
collections: [Products, Categories, Media, Pages, Users],
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor(),
|
||||||
secret: process.env.PAYLOAD_SECRET || '',
|
secret: process.env.PAYLOAD_SECRET || '',
|
||||||
serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
|
serverURL: process.env.NEXT_PUBLIC_SERVER_URL,
|
||||||
|
|||||||
@@ -13,15 +13,23 @@ describe('Payload API', () => {
|
|||||||
|
|
||||||
it('má nadefinované všechny kolekce', () => {
|
it('má nadefinované všechny kolekce', () => {
|
||||||
const slugy = Object.keys(payload.collections)
|
const slugy = Object.keys(payload.collections)
|
||||||
expect(slugy).toEqual(expect.arrayContaining(['products', 'media', 'pages', 'users']))
|
expect(slugy).toEqual(
|
||||||
|
expect.arrayContaining(['products', 'categories', 'media', 'pages', 'users']),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('umí založit a najít položku nabídky', async () => {
|
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({
|
const vytvorena = await payload.create({
|
||||||
collection: 'products',
|
collection: 'products',
|
||||||
data: {
|
data: {
|
||||||
nazev: 'CZ 75 SP-01 Shadow',
|
nazev: 'CZ 75 SP-01 Shadow',
|
||||||
kategorie: 'zbrane',
|
kategorie: kategorie.id,
|
||||||
cena: 28900,
|
cena: 28900,
|
||||||
stav: 'skladem',
|
stav: 'skladem',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user