diff --git a/CHANGELOG.md b/CHANGELOG.md index 770c407..d0b0425 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, 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. -- Grafika: silueta lesa přes celou šířku nad patičkou a terč v hlavičce - mezi značkou a navigací. 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ů. +- 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/` zboží v kategorii a detail zůstává + na `/nabidka/`. 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 diff --git a/migrations/20260731_180000_kategorie.ts b/migrations/20260731_180000_kategorie.ts new file mode 100644 index 0000000..27877c4 --- /dev/null +++ b/migrations/20260731_180000_kategorie.ts @@ -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 { + 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 { + 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\`;`) +} diff --git a/migrations/index.ts b/migrations/index.ts index d4cf570..2890ae8 100644 --- a/migrations/index.ts +++ b/migrations/index.ts @@ -1,4 +1,5 @@ import * as migration_20260730_102034_initial from './20260730_102034_initial'; +import * as migration_20260731_180000_kategorie from './20260731_180000_kategorie'; export const migrations = [ { @@ -6,4 +7,9 @@ export const migrations = [ 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' + }, ]; diff --git a/src/app/(frontend)/kategorie/[slug]/page.tsx b/src/app/(frontend)/kategorie/[slug]/page.tsx new file mode 100644 index 0000000..1f20bbe --- /dev/null +++ b/src/app/(frontend)/kategorie/[slug]/page.tsx @@ -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 ( +
+

+ ← Zpět na nabídku +

+ +

{kategorie.nazev}

+ {kategorie.popis &&

{kategorie.popis}

} + + {polozky.length === 0 ? ( +

V této kategorii zatím nic není.

+ ) : ( +
    + {polozky.map((polozka) => ( + + ))} +
+ )} +
+ ) +} + +async function najdiKategorii(slug: string): Promise { + 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 +} diff --git a/src/app/(frontend)/layout.tsx b/src/app/(frontend)/layout.tsx index 7522c4d..55788e5 100644 --- a/src/app/(frontend)/layout.tsx +++ b/src/app/(frontend)/layout.tsx @@ -30,11 +30,14 @@ export default async function RootLayout(props: { children: React.ReactNode }) { return ( + {/* Terč jako nehybný vodoznak — zůstává na místě i při rolování + a je pod obsahem, takže nepřekáží čtení. */} +