Architektura
Symfony 7.4 LTS aplikace (composer min PHP 8.2; reálně dev+CI image jede na 8.4, prod 8.3 — viz roadmap.md). Vyvíjí se v Dockeru (Compose); staging na https://beta.slack.cz (Hetzner CX22, native PHP 8.3 + Postgres 16 + Caddy — viz deploy.md), cutover legacy slack.cz ještě neproběhl.
Dev běží na native docker-ce (Linux) — žádný Docker Desktop, žádný linuxkit, žádný FUSE wrapper. Bind mount je raw ext4 přímo z hostu, atomic-rename funguje korektně (důležité, viz historie editací; DD's fakeowner FUSE měl invalidation bug).
Infrastruktura, prod provoz, deploy flow, CI workflow, skripty →
deploy.md(sekce „Infrastruktura na první pohled" je 2-minutový rozcestník). Operační cookbook (maketargety, časté konzole příkazy, smoke testy) →dev.md.
Stack
| Vrstva | Komponenta | Host port |
|---|---|---|
| Web | Apache 2.4 | 8000 (pevný) |
| App | PHP-FPM (image slack-cz-php, Symfony 7.4, PHP 8.4 alpine) |
– (jen interní 9000) |
| DB nová | PostgreSQL 16 (Doctrine EM default) |
dynamický (compose ho přidělí) |
| DB legacy | MySQL (Doctrine EM old, čte slackcz_44953.sql dump) |
– (interní) |
| Mailpit (UI 8025, SMTP 1025) | dynamické pro UI i SMTP | |
| Adminer | DB UI | 8080 (pevný) |
Aktuální dynamické porty: docker compose port database 5432, docker compose port mailer 8025 atp.
Struktura kódu
src/
Controller/ # PagesController, LineController, LineCrudController, LinePhotoController,
# CrossingController, LonglineCrossingController, UserController, MarkdownSectionController, Registration/Reset/Security
Entity/ # NOVÉ entity (Postgres, EM default): User, Line, LineCrossing, LonglineCrossing,
# LineEdit, LinePhoto, LinePhotoComment, LinePhotoLike, ResetPasswordRequest
Enum/ # LineType, CrossingStyle, LineEditStatus
Service/ # PhotoNormalizer (magick + exiftool: datum/GPS/rozměry → DB, soubor → WebP master), NormalizedImage
Feed/ # plochý homepage feed + cache + dispatcher (mock fallback)
Tv/ # sekcový /tv feed: FeedGroup, TvFeedInterface, YoutubeClient, paginace + cache
Form/ # Symfony forms (LineForm, LineCrossingForm, LonglineCrossingForm, RegistrationForm, ...)
Legacy/ # UserMergeMap (singleton — duplicit-email merge mapa; plní app:import:users, čte :line-crossings + :longline-crossings)
Markdown/Section/ # MD subsystém pro /docs + /wiki, čte z lokálního checkoutu (detail v § Markdown sections níž)
Repository/ # Doctrine repos (LineCrossingRepository::RECENT_LIMIT je single source of truth)
Security/ # EmailVerifier
Command/ # Console commands: app:import:*, app:admin:grant, app:edit:sync-from-history,
# app:user:list, app:user:reset-password, app:photo:backfill-dimensions
Pojmenování: entita Line, disciplíny "highline" a "longline" a další
- Entita pro lajny se jmenuje
Line LineType::Highline(enum case — highline vs midline vs longline vs waterline),- legacy import command
app:import:line-crossings(zapisuje doLineCrossing, třída jeImportLineCrossingsCommand, - dál existuje
app:import:longline-crossings- to je speciální případ a v podstatě nesystémové řešení pro zachování původního longline deníku uživatelů (nesystémové protože entita Line umožňuje zadávat i typLineType::Longline) - UI labely a
#highline/#longlinedeep-link hash na/denik/{id}, tohle je taky nesystémové, protože#highlineukazuje na všechny deník všech typů zLineType - legacy data (MySQL tabulka
highline, souborový stromline/high/<id>/, prod URL…/highlines/detail/{id}).
Pravidlo: Line = entita v novém schématu; „highline" = disciplína / legacy.
Důležité — legacy data se čtou přes DBAL, ne přes ORM
Legacy MySQL se nemapuje na ORM entity. Import commandy (app:import:*) čtou z legacy přes DBAL connection old raw SQL (autowire doctrine.dbal.old_connection + fetchAllAssociative) a zapisují nové App\Entity\* přes default (Postgres) EM. Díky tomu default EM o legacy tabulkách vůbec neví a make:migration je nikdy nezahrne do Postgres schématu.
# config/packages/doctrine.yaml — jen DBAL connection, žádný ORM entity_manager nad legacy
dbal:
connections:
old:
url: '%env(resolve:OLD_DATABASE_URL)%'
Pravidlo: legacy tabulky čti raw SQL přes doctrine.dbal.old_connection, nemapuj je na ORM entity.
Frontend
- Asset Mapper + Stimulus + Turbo Drive (Symfony stack; jediný „build step" je Sass —
symfonycasts/sass-bundle,make dcSassBuild/make dcSassWatch) - Importmap (
importmap.php) drží: stimulus, turbo, leaflet, bootstrap (JS + Popper) abootstrap/dist/css/bootstrap.min.css - Turbo je aktivně zapnuté — link kliky a form submity jsou frame swapy, ne full reloady. Předpokládá to, že stránky extendují
base.html.twiga vrací 30x redirecty po POST. - Stimulus controllery v
assets/controllers/:hello_controller.js— placeholder z generátorucsrf_protection_controller.js— vendored z Symfony Flex recepty (přišel s 7.4): stateless CSRF, dopočítáváframework.csrf_protection.check_headertoken na form submitech. Neměň ručně.map_controller.js— hlavní Leaflet mapa: highline markery vestaticLayer=L.markerClusterGroup(clustering v hustých oblastech, viz § Mapa — Leaflet), emoji markery posledních přechodů (usersLayer, přepínatelné okem z tabu Přechody; default schované), time-travel režim (timelineLayer, neclusterovaný). Broadcastujeslack:viewport-linesnamoveend/zoomend+ přes ResizeObserver drží velikost plátna v syncu s výškou.map-panel. Leaflet zoom je přesunutý nabottomright, aby se nemlátil s panelem vlevo nahoře.hp_map_controller.js(identifierhp-map) — homepage mapa v panelu Mapa: ukazuje jen aktivní přechod, vykreslí reálnou linku + emoji walker animaci po lajně a auto-showcase cyklus přes posledních N přechodů. Emoji uživatele dostává ze serverových dat; kanonická paleta jeApp\UserEmoji. Detailní chování v § Hotové features → Index page.line_detail_map_controller.js(identifierline-detail-map) — slim mini-mapa pro detail lajny: jeden pin nebo polyline mezipoint1apoint2GPS (pokud má lajna oba body)line_form_map_controller.js(identifierline-form-map) — 2-endpoint GPS picker pro line form. Alternující klik 1→2→1, oba markery draggable, polyline + live haversine length overlay. Sync se 4 input poli (point1Lat/Lng + point2Lat/Lng) oboustranně.user_diary_map_controller.js— mini-mapa na/denik/{id}se všemi unikátními highlines, co user prošelmap_panel_controller.js— tabový panel.map-panelna/mapa(Lajny/Přechody, desktop i mobil stejně): přepínání tabů a sbalování — taby jsou Bootstrap buttony (btn-outline-primary btn-sm),.active(filled) = vybraný tab při rozbaleném panelu; sbalený = jen outline button Lajny + šipka (tab Přechody schová--crossingsmodifikátor; výjimka: při zobrazených markerech ho--pinnedodcrossing-feeddrží v liště), pevná user-driven výška tažením úchytu na spodní hraně (clamp: spodní hrana ≤ ½ mapové plochy; výška NIKDY z obsahu — smyčka výška→resize mapy→jiný výřez→jiný obsah) a gradient „dole je toho víc". Obsah panes renderujíline-feed/crossing-feedcontrollery na témže elementu; s panelem mluví Stimulus dispatchi (*:rendered) přesdata-actionna rootu.crossing_feed_controller.js— tab Přechody v.map-panel: kompaktní list posledních N přechodů (dvouřádkové hairline řádky à laline-feed; komentář jen v popupu markeru), filtr (posledních N / rozsah dat) a oko (= visibility emoji markerů na mapě, default schované), reaguje na time-travel režim (změní se v okno -7 dní zpět od virtuálního času). Klik na řádek →slack:crossing-focus(skryté markery se předtím zviditelní vč. přepnutí oka; panel zůstává rozbalený). Collapse vlastní panel, ne tenhle controller.search_controller.js— globální vyhledávání lajn v hlavičceintro_controller.js— slackvibes 📻 audio player; persistent přes Turbo přesdata-turbo-permanent. Detaily vdocs/audio-player.md.live_slug_controller.js— live URL preview pro unverified line edit form: slugify-uje typed name a updatuje<code>preview. Server na save přegeneruje slug přesmakeUniqueSlug.photo_like_controller.js— like button na/lajna/{slug}/fotky/{id}; intercept-uje form submit, POST přes fetch sAccept: application/json, endpoint vrátí{liked, count}JSON, controller updatuje classy +aria-pressed+ icon + count. Při fetch failu padá zpět na nativní form submit (graceful degradation).tv_controller.js— click-to-play facade na/tv: náhled karty nahradíyoutube-nocookieembedem až po kliknutí (neload 24 iframů + privacy). Viz § Feed (slackTV).tabs_controller.js— obecná tab komponenta: přepínáaria-selected+hiddenpanel podle indexu (+ Bootstrap.activena tabu), volitelný deep-link přes URL hash (data-tabs-hash-param). Vzhled dodává Bootstrap (nav-underline/nav-pillss brand theme v_tabs.scss). Použito na/tv(hashtag taby) a/denik/{id}(Highline/Longline).tv_more_controller.js— AJAX „načíst další" stránkování sliderů na/tv(/tv/more,pageToken).data_table_controller.js— generická filtrovatelná + řaditelná tabulka (fulltext filtr, klik nath[data-sort-type]řadí,aria-sort, count badge). Použito na/denicky.line_feed_controller.js— tab Lajny v.map-panel(seznam lajn ve výřezu): čistý view, posloucháslack:viewport-linesbroadcast zmap_controller(viz event bus níž), klik na řádek →slack:line-focus(panel zůstává rozbalený).gallery_nav_controller.js— lišta roků na/galerie: scrollspy (rAF-throttled scroll listener zvýrazňuje rok právě pod hlavičkou + doscrolluje aktivní chip v liště) a klik na rok (jump). Gotcha: klik na in-page kotvu nesmí propadnout Turbu — to ho bere jako novou visit (refetch celé stránky + vlastní scroll, který přestřeluje), protopreventDefault+scrollIntoView+history.replaceState.
Cross-controller event bus
Mapový panel (.map-panel) a mapa potřebují komunikovat napříč nezávislými Stimulus controllery. Používáme document-level CustomEvents:
| Event | Producer | Consumer | Payload |
|---|---|---|---|
slack:map-mode |
map |
crossing-feed |
{ mode: 'recent' | 'time-travel', date?, days? } — feed podle toho přefetchuje data (sloty se debouncují 200 ms + AbortController) |
slack:users-visibility |
crossing-feed (eye button) |
map |
{ visible: bool } — mapa přidá/odebere usersLayer |
slack:viewport-lines |
map (na moveend/zoomend) |
line-feed |
{ lines: [...] } — lajny v aktuálním výřezu (tab Lajny) |
slack:lines-request |
line-feed (na connect) |
map |
replay posledního viewport-lines — jistí boot race při pořadí connectů |
slack:line-focus |
line-feed (klik na řádek) |
map |
{ id } — setView na lajnu + zoomToShowLayer (marker může být v clusteru) + popup |
slack:crossing-focus |
crossing-feed (klik na řádek) |
map |
{ id } — setView na emoji marker přechodu (userIndex, klíč = id přechodu) + popup; skryté markery feed před dispatchem zviditelní (toggleUsers) |
Vedle document-eventů běží element-level Stimulus dispatche mezi controllery sdílejícími root .map-panel (line-feed:rendered, crossing-feed:rendered → map-panel), zapojené přes data-action na rootu v map.html.twig.
Stav přežívá Turbo navigaci přes sessionStorage (slack.cz:map:panel-collapsed, :panel-height, :panel-tab, :users-hidden, :view). Map controller čte users-hidden přímo na bootu (ne přes event), aby nedocházelo k race condition s pořadím Stimulus connectů; default = markery přechodů schované ('0' = user je explicitně zapnul).
- CSS = Bootstrap 5 + jedna vlastní Sass vrstva (mobile-first pivot 2026-07-01, custom CSS monolit zahozen). Kaskáda ve
app.js:bootstrap.min.css(importmap) →assets/styles/app.scss— jediný vlastní stylesheet.app.scssje jen@usemanifest (žádné selektory!): shell (tokeny + base) → porty legacy stránek → Bootstrap theme vrstvy. Konvence:- jedna komponenta = jeden flat
_*.scssvassets/styles/+ řádek v manifestu (pořadí = kaskáda) - breakpointy VŽDY přes mixiny
media-up($bp)/media-down($bp)z_breakpoints.scss(Bootstrap škála sm 576 / md 768 / lg 992 / xl 1200 / xxl 1400) — žádné hardcoded px v@media; JS dvojče jeassets/breakpoints.js(isMobile(), 767.98px = přesněmedia-down(md)) — žádné hardcoded px ani vmatchMedia - žádný surový Bootstrap vzhled — default je polotovar; komponenty dostávají brand theme (paleta v
_variables.scss:$brand#e1005b, theming přes Bootstrap CSS proměnné v_shell.scss, ne přes Sass$primary) _shell.scssdrží--header-height(72 px fixní hlavička) + legacy--color-*tokeny, které ještě čtou portnuté partialy- build:
make dcSassBuild(jednorázově) /make dcSassWatch(dev); v dev pozor napublic/assets/— zkompilované assety zasset-map:compilestínují živé soubory, po změně assetů je přegenerovat nebo smazat
- jedna komponenta = jeden flat
- Obrázky v
assets/images/(logo, leaflet ikony, archivní artefakty) - Audio v
public/audio/(12 stop, 128 kbps stereo). Originály vvar/audio-original/(gitignored). - Google Fonts: Space Grotesk (400, 500, 600, 700) jako globální font na
body— geometrický sans s charakterem, sedí k bold display logu. Linkovaný přes<link>tag vbase.html.twig.
Mapa — Leaflet
- Lokace lajny = jen 2 kotvící body (
point1Latitude/Longitude+point2Latitude/Longitude, + volitelně parking). Žádný uloženýlatitude/longitude— to byl dřív denormalizovaný „střed", co u většiny legacy lajn dávno nesedělo (drift až 166 km, protože se přepočítával jen při re-save formuláře), zahozený v migraciVersion20260609120000. Kde je potřeba jeden reprezentativní bod (tečka v deníku/feedu, odkaz na mapy.cz, úvodní střed mini-mapy), odvozuje se zpoint1:Line::getLatitude()/getLongitude()jsou ponechané jako alias na point1 (kvůli Twig šablonám), read queries aliasujíh.point1Latitude AS latitude.deriveGeometry()po submitu počítá už jenlength(haversine z obou bodů). - Podklady: OpenStreetMap (default) + Esri World Imagery ortofoto — přepínač pilulkou „Ortofoto"/„Mapa" na všech mapách. Sdílené v
assets/basemap.js(addBasemapToggle) jako jediný zdroj URL/atribucí; homepage mapa startuje rovnou na ortofotu ({ ortho: true }). - Fullscreen na všech mapách — ikonka v rohu (
assets/map_fullscreen.js,addFullscreenToggle, sebe-úklid přesmap.on('unload')); na/mapajde do fullscreenu celý wrapper (zůstane feed + time-travel ovládání), jinde plátno mapy. - Linka mezi body na
/mapa: vlastní layer, viditelná až od zoom ≥ 14 (LINE_MIN_ZOOM) — z pohledu na celou ČR by byla sub-pixelová; marker sedí napoint1. Detail lajny ukazuje oba body (řádky „GPS bod 1 / bod 2" s odkazy na mapy.cz) + polyline na mini-mapě. - Markery: defaultní Leaflet ikony — kvůli AssetMapperu jsme musely PNG (
marker-icon,marker-icon-2x,marker-shadow) stáhnout doassets/images/leaflet/a předat URLs z Twigu jakodata-*(klasický bundling problém) - Clustering markerů lajn (
leaflet.markerclusterpřes importmap, JS + base CSS s animacemi):staticLayerna/mapajeL.markerClusterGroup— radius 50 px,disableClusteringAtZoom=LINE_MIN_ZOOM(14), tzn. clustery končí přesně tam, kde se začínají kreslit linky.spiderfyOnMaxZoom: false— spiderfy vějíří markery na fake pozice, pro reálné kotvy nesmysl; klik na cluster vždy jen zoomuje (zoomToBounds). Ikona clusteru = vlastní BEM blok.map-cluster(akcent + bílý ring jako.line-marker); gotcha:leaflet.css(.leaflet-marker-icon { display: block }) se linkuje až za naším CSS, takže flex na ikoně prohrává — počet se centruje absolutně (.map-cluster__count,inset: 0). - „Moje poloha" (
assets/map_locate.js,addLocateControl): toggle pilulka — geolocation watch, modrá tečka + accuracy kruh, centrování na první fix. Chybové hlášky per platforma (iOS Safari = dvojitá past Polohových služeb, viz commit1c96775); nikdytimeout: Infinity(Safari přeteče na 0). - Zoom controly jsou na
bottomright(defaulttopleftkoliduje s panelem na/mapa); v time-travel módu se zoom navíc CSSkem zvedne nad time-travel panel. Basemap/fullscreen/locate/legenda pilulky jsou vtopright(offset pod „Přehrát historii" je dočasně zakomentovaný spolu s toggle — TODO(mapa-mobile)). - Layout
/mapa: bez patičky (prázdný{% block footer %}), wrappercalc(100dvh - hlavička)+overflow: clip→ stránka nikdy svisle nescrolluje..map-sidemáz-index: 900(nad leafletí panes), aby mapa nepřekreslovala úchyt panelu přesahující přes hranu. - Použití na 4 místech: full mapa
/mapa(s tabovým.map-panel+ time-travel), mini mapa v panelu na indexu (sdílímap_controller), mini-mapa detailu lajny (line_detail_map_controller), mini-mapa deníku (user_diary_map_controller)
Recent crossings — single source of truth
Konstanta App\Repository\LineCrossingRepository::RECENT_LIMIT (default 10) určuje, kolik nejnovějších přechodů se zobrazí v UI. Sjednocuje tři místa, která jindy „žila vlastním limitem":
| Místo | Metoda repa | Tvar |
|---|---|---|
| Index page — sidebar přechodů v panelu Mapa | findRecent() |
entity (server-render <li> s data-* geometrií pro hp-map) |
/mapa emoji markery |
findRecentForJson() |
array (lat/lng + popup data) |
/mapa tab Přechody |
findRecentForJson() |
array (sdílený endpoint /mapa/feed) |
Bez dedup by user — homepage list, emoji markery i tab Přechody zobrazují stejné přechody. Když má jeden user 3 ze 10 nejnovějších, mapa ukáže 3× jeho emoji na 3 různých lajnách (fan-offset stackuje pouze identické GPS).
Emoji paleta účtů má jediný zdroj v App\UserEmoji::VALUES. Používá ji
registrační i profilový formulář; nové registraci se předvybere náhodná hodnota
a zvolená emoji se ukládá na User. Mapové endpointy ji posílají spolu s
přechodem, takže hlavní i homepage mapa zobrazují stejnou uloženou hodnotu.
emoji_picker_controller.js řeší pouze živý náhled ve formuláři.
Pro time-travel režim je separátní findForFeedInRange(from, to) (tab Přechody volá /mapa/feed?date=YYYY-MM-DD&days=7).
Vložené mapy — scroll-zoom citlivost (assets/map_scroll_zoom.js)
enableCtrlScrollZoom() (na 4 vložených mapách — homepage, deník, detail lajny, form lajny; ne na /mapa, ta má nativní plain-wheel zoom) drží Ctrl/⌘+wheel gate, ale performZoom() není 1:1 kopie Leafletí interní pixel-akumulace/sigmoid dampening matiky (wheelPxPerZoomLevel, log/exp) — ta byla naladěná na plynulé trackpad pinch gesto a s klasickým kolečkem myši působila výrazně necitlivěji než tlačítka zoomu. Každé (debounced) gesto kolečkem teď udělá přesně jeden krok o map.options.zoomDelta (stejný krok jako tlačítka +/-), ukotvený pod kurzorem (setZoomAround). (Session 2026-07-09.)
Line form — GPS picker (line_form_map_controller.js)
- Iniciální zoom/fit: dřív natvrdo
zoom=15, jakmile existoval aspoň jeden bod — u delší lajny to ořízlo druhý bod (nebo parkování) mimo viditelnou oblast.fitToKnownPoints()teď sestaví bounds ze všech už umístěných bodů (bod 1, bod 2 i parkování) a použijefitBounds()(padding 40px, maxZoom 17); jen jeden bod →setViewna něj, žádný bod → fallback nainitLat/initLng. Volá se přiconnect()i znovu při opuštění fullscreenu (fullscreen měl jinou aspect ratio, takže starý fit už nesedí). - Live redraw při tažení bodu: marker měl jen
dragendlistener (čára/délka se překreslily až po puštění). Přidándraglistener —refreshLine()(polyline + oba délkové displeje) se teď volá při každém pohybu myši; zápis do skutečných form inputů (writeInputs, spouštíchange) zůstal jen nadragend.refreshLine()zároveň přešlo z remove+recreate polyline nasetLatLngs()(běží teď při vysoké frekvenci). - Read-only délka mimo mapu:
<output id="line-length-output">v sekci Poloha (mimo.hl-form-map, tedy mimo Stimulus controller element) — wiring stejným „by-ID" trikem jako lat/lng inputy (data-line-form-map-length-output-value), ne přes Stimulus target. Server-render zline.length, JS ho pak drží live v sync s mapovým badge (ten je od teď vidět jen ve fullscreenu —.hl-form-distance { display: none }, override přes.hl-form-map:fullscreen .hl-form-distance, mimo fullscreen stačí to<output>pod mapou). - Helper text pod mapou: „Délka se počítá automaticky ze zadaných bodů."
Foto galerie — upload pipeline
Fotky lajn (LinePhoto, Vich storage public/uploads/line/<id>/, gitignored). Uploady jsou z 99 % z mobilu včetně iPhonů (HEIC). Každý upload — user i legacy import — projde App\Service\PhotoNormalizer (volá magick + exiftool, viz docker/php/Dockerfile):
- Vytáhne metadata přes exiftool:
DateTimeOriginal→LinePhoto.createdAt, GPS →gpsLat/gpsLng. Nic víc (ISO/model foťáku = balast). - Normalizuje soubor: HEIC/JPG/PNG → WebP master, auto-orientace, zmenšení na ≤ 2560 px delší hrana, q85, strip všech metadat.
- Uloží pixel rozměry masteru →
LinePhoto.width/height(zgetimagesizenad výstupem, tj. po resize + rotaci). Potřebuje je justified grid na/galerie— poměr stran musí být známý před načtením obrázku (layout bez CLS). Řádky z doby před sloupci dorovnáváapp:photo:backfill-dimensions(čte mastery přes Vich storage; jednorázově po importu / na novém prostředí).
Důvod modelu „metadata do DB sloupců + čistý soubor": embedovaná metadata v souborech jsou pro web nepraktická a reencode (nutný kvůli HEIC/velikosti) je stejně zahodí. Tak vytáhneme ty 2 užitečné věci (datum + GPS) do DB a ukážeme je na detailu fotky; soubor je čistý.
LiipImagine (GD driver, WebP in/out) pak z WebP masteru generuje thumb/medium/full + gallery_thumb (inset 1600×480 — výška pro ~240px řádek galerie ve 2× retina, poměr stran zachovaný; cachované v public/media/cache/). Disk-conscious schválně — běžíme na nejlevnějším Hetzner VPS (40 GB sdílených). Ladicí knoby (MAX_EDGE, QUALITY) jsou konstanty v PhotoNormalizer. Originály nedržíme (jsou v mobilech lidí).
Assert\File na uploadu povoluje jpeg/png/webp/heic/heif do 30 MB (syrový vstup; downscale řeší velikost). HEIC detekuje finfo jako image/heic (libmagic v Alpine).
Feed (slackTV)
Dvě paralelní vrstvy nad stejným YouTube Data API v3 (googleapis.com/youtube/v3). API key v .env.local jako YOUTUBE_API_KEY (gitignored); .env ji deklaruje prázdnou (dokumentace).
1) Homepage teaser — plochý feed (FeedFetcherInterface), panel na indexu (pages/index.html.twig), pár nejnovějších videí.
2) Sekcová stránka /tv (TvController → app_tv + AJAX app_tv_more, pages/tv.html.twig) — rozdělená na sekce Kanály / Playlisty / Hashtagy, každý zdroj = horizontální slider karet. tv_controller.js zůstává click-to-play facade (náhled → youtube-nocookie embed až po kliknutí, neload desítek iframů + privacy). VideoID se v Twigu derivuje z FeedItem.id (yt:VIDEOID → |slice(3)).
Plochý feed (homepage)
- Config
feed.youtube.channels(channel IDs) +feed.youtube.queries(search queries). FeedFetcherInterface→CachedFeedFetcher(cache.app, TTL 6 h) →FeedFetcherDispatcher(real když je key, jinak mock) →YoutubeFeedFetcher/MockFeedFetcher.- Fallback kaskáda: real fetch → last-known-good (7 dní, jen z reálných úspěšných fetchů) → mock (necachuje se). Prázdný real fetch se cachuje jen 60 s, aby se po obnovení kvóty rychle vrátila reálná data.
Sekcový feed (/tv)
- Config v
feed.yaml:feed.tv.channels(@handley — v YAML escapovat jako@@— samotné@je reference na službu),feed.tv.playlists(PL ids),feed.tv.hashtags. Přidání zdroje = jeden řádek; stránka i load-more endpoint jsou nad ním generické. Položka kanálu/playlistu je buď holé id, nebo mapa{ id, sort }, kdesort: asc= od nejstarších,desc= od nejnovějších (default). Mixovat formy lze;@@se un-escapuje i uvnitř mapy. NormalizujeTvSource::fromConfig. src/Feed/Tv/:FeedGroup— pojmenovaná řada videí (kindchannel/playlist/hashtag,title,url,items,nextPageToken).key= stabilní identifikátor zdroje (channel:@x/playlist:PL…/hashtag:#x), kterým AJAX adresuje další stránku.TvFeedInterface—sections()(první stránka všech zdrojů, seskupené) +page(key, token)(další stránka jednoho zdroje).YoutubeClient— raw wrapper:resolveChannel(@handle→ uploads playlist přesforHandle),resolvePlaylist,playlistItems/searchse stránkováním přespageToken.allPlaylistItemsprojde celý playlist (loop přespageTokendokud token nedojde — končí i na chybě API, kdycall()vrátí[]) pro oldest-first zdroje:playlistItemsneumí reverse order a nejstarší videa jsou až na poslední stránce. Chyba volání → prázdný výsledek (logged), neshodí stránku.TvSource—{ id, oldestFirst }, výsledekfromConfig(holý string nebo{id, sort}).YoutubeTvFeed— skládá sekce; padlý zdroj se přeskočí, ne fatal. Oldest-first zdroj (TvSource::oldestFirst) stáhne celý výpis, otočí ho a stránkuje lokálně — page token je prostý integer offset počítaný od nejstaršího (stabilní i když přibydou nová videa), ne YouTubepageToken.CachedTvFeed— decorator přescache.app: cachuje celý balík (tv.sections) i jednotlivé load-more stránky (tv.page.<md5>). Stejná fallback kaskáda jako plochý feed (last-known-good 7 dní + mock SLACKHOVOR group jako poslední záchrana).
- Stránkování: první stránka každého slideru je server-side; „load more" karta volá
GET /tv/more?key=&page=→ JSON{html, nextPage}(_tv_cards.html.twig, sdílený s prvním renderem). Když zdroj dojde, button se v JS přemění na „Vše na YouTube" odkaz. - Stimulus:
tv-more(AJAX donačtení další stránky),tabs(hashtag taby — obecná komponenta,.tabs-pillsvarianta),tv(přehrávání). - Validace klíče:
/tv/morepřijme jen klíč zTvFeedInterface::knownKeys()(nakonfigurované zdroje).CachedTvFeed::page()odmítne neznámý klíč ještě před cache i API voláním (+YoutubeTvFeed::page()totéž jako defense-in-depth), takže veřejný endpoint nejde zneužít k pálení kvóty (search = 100 units) ani k zaplevelenícache.app.
Quota economics
YouTube Data API daily free tier = 10 000 units / GCP project / den (reset v PT půlnoc ≈ 09:00 CEST). Cena endpointů, které používáme:
| Endpoint | Cost | Kde |
|---|---|---|
search.list |
100 units / call | feed.youtube.queries, feed.tv.hashtags |
channels.list |
1 unit / call | lookup uploads playlistu (handle/id) |
playlists.list |
1 unit / call | název playlistu (/tv) |
playlistItems.list |
1 unit / call | skutečná videa (kanály i playlisty) |
Drahé jsou jen hashtagy/queries (search = 100 units); kanály i playlisty stojí ~1–2 units bez ohledu na to, kolik videí stránka vrátí.
- Plochý feed (homepage): (queries × 100 + kanály × 2) × (86400 / TTL). Aktuálně 1 query, 0 kanálů, TTL 6 h = 400 units/den.
- Sekcový
/tv— jedentv.sectionsmiss: 5 kanálů × 2 + 2 playlisty × 2 + 5 hashtagů × 100 = ~514 units; při TTL 6 h (4 missy/den) = ~2 056 units/den. Load-more stránky jsou navíc, cachované per(key, token): kanál/playlist +1–2 units/stránka, hashtag +100 units/stránka. - Oldest-first zdroj (
sort: asc) přitáhne při každém fetchi celý playlist (ceil(videí/50)units), ne jednu stránku — a to i u load-more, který otáčí celý výpis znovu (každá stránka cachovaná zvlášť). U našich malých kanálů 1–2 units/fetch, takže v rámci šumu; u stovek videí to roste lineárně.
Součet obou vrstev ≈ 2,5 k units/den z 10 000 — pohodlná rezerva, ale hashtagy nesou ~95 % nákladu. Přidání hashtagu = +100 units × (86400/TTL)/den; přidání kanálu/playlistu prakticky zdarma.
Historicky jsme limit přepálili s feed.youtube.queries: [#czechslackline, czech slackline, czech highline] a feed.cache.ttl_seconds: 1800 (30 min):
3 queries × 48 fetchů/den × 100 units = 14 400 units/den — kvóta vyčerpaná každé odpoledne. Plus každý bin/console cache:clear během vývoje vyhodí cache.app a vyvolá další 300 units instantně. Comment v config/packages/feed.yaml má aktuální vzorec, používej ho při ladění queries listu.
Recovery po vyčerpání kvóty / rotaci klíče
# vyhodit 60s "empty fetch" lockout, aby další request hned re-tryoval
docker compose exec -T php bin/console cache:pool:clear cache.app
# načíst nový YOUTUBE_API_KEY z .env.local
docker compose exec -T php bin/console cache:clear --env=dev
GCP "new project" search gate (gotcha)
Čerstvě založený GCP projekt s povolenou YouTube Data API: /channels jede (1 unit, 200 OK), ale /search vrací 403 Forbidden i s plnou kvótou. To je standardní GCP posture pro neověřené projekty — search je gated dokud (a) nezapneš billing, nebo (b) projde quota-extension request přes Cloud Console. Rotace klíče na nový projekt sama o sobě /search nerozjede.
Diagnostika v var/log/dev.log: quotaExceeded → starý projekt vyčerpal kvótu, počkat na PT reset; accessNotConfigured / 403 jen na /search → nový projekt čeká na verifikaci.
Auth
App\Entity\User(email, password, roles, isVerified)- Symfony security s form_login (login_path/check_path:
app_login) - Reset password přes
symfonycasts/reset-password-bundle symfonycasts/verify-email-bundlezůstává zapojený, ale odeslání ověřovacího e-mailu je v registraci dočasně vypnuté; nový účet se po uložení rovnou přihlásíUserRepositoryimplementujePasswordUpgraderInterfaceROLE_ADMIN— kurátorská role (mark verified, schvalovat proposals, mazat verified lajny). Granted přesbin/console app:admin:grant <email> [--revoke]. Aktuální admin:p***@***l.com. Symfony role hierarchy: ROLE_ADMIN ⇒ ROLE_USER auto.- Konzolové utility pro správu uživatelů:
app:user:list(id/email/nick/verified/active, volitelné-ssubstring filter,--unverified) aapp:user:reset-password <email|id>(vygeneruje absolutní password-reset URL přesResetPasswordHelperInterface::generateResetToken()). Workaround dokud nejede mailer na betě — host pro URL bere router zframework.router.default_uri(=DEFAULT_URIenv). Návod vdev.md§ Účty / správa uživatelů adeploy.md§ Reset hesla / aktivace účtu, když nechodí maily.
Registrace — 2-krokový flow (aktualizováno 2026-07-20)
Registrace (RegistrationController::register()) a doplnění profilu (PagesController::editProfile()) jsou záměrně dva oddělené formuláře/kroky — zůstává tak i po revizi, jen se přeskládalo, co je kde:
RegistrationForm(/registrace):email,nick(NotBlank+Length(max:30),UniqueEntitynaUserhlídá kolizi), emoji zApp\UserEmoji,password(NewPasswordType) aagreeTerms. Heslo má živý indikátor síly (password_strength_controller.js). Email a nick se při psaní kontrolují POSTem na/registrace/dostupnost; endpoint vracíCache-Control: no-store, validuje vstup a má compound rate limiter (30/min, 200/h na IP).- Nick se nastavuje jen při registraci a pak už ho nejde v appce změnit —
UserForm(druhý krok,/profil/uprava) ho záměrně neobsahuje.User::nickzůstává PHP-nullable (?string) i po zavedeníNotBlankna formu — DB sloupec je pořádNOT NULL-free (nullable), validace na formu je jediná pojistka; legacy/importovaní useři bez nicku existují dál agetDisplayName()pro ně padá na email (viz níže). - Po úspěšné registraci — zápis má vlastní limiter 5 pokusů/h na IP, účet se rovnou přihlásí, zobrazí se česká success flash a následuje redirect na
app_profile_edit. Odeslání ověřovacího e-mailu je v controlleru zakomentované do rozhodnutí o finálním mail flow; route pro ověření zůstává zachovaná. UserForm(/profil/uprava): firstName, lastName, city, birthYear, phone — všechno nepovinné, kdykoli editovatelné. Nahoře info box (Bootstrapalert+ inline SVG ikonka, stejný vzor jako ostatní ručně kreslené ikony v appce) s textem „Vyplň dle libosti. Všechny údaje jsou nepovinné."phone— formát i viditelnost:Assert\Regex/^(\d{9}|(\+|00)?\d{12})$/— buď přesně 9 číslic bez prefixu, nebo+/00+ přesně 12 číslic (prefix se smí pojit jen s 12místným číslem). HTMLpatternatribut zrcadlí stejný regex atitleatribut nese stejnou hlášku jako server-sideRegexconstraint, takže i nativní „Please match the requested format" bublina prohlížeče je česky a srozumitelná. Telefon je jediný profilový údaj, co není veřejný — zobrazuje se jen přihlášeným uživatelům ({% if app.user %}na/denik/{id}, vizuser_diary.html.twig);firstName/lastName/city/birthYear/nickjsou tam veřejně bez podmínky.
Ochrana osobních údajů
Nová stránka /ochrana-osobnich-udaju (app_privacy, PagesController::privacy() → pages/privacy.html.twig) — plain-language shrnutí, ne právní review: co je povinné/nepovinné, co je veřejné vs. jen pro přihlášené, že se nic nepředává třetím stranám (Matomo je self-hosted bez cookies), poznámka k legacy účtům ze starého slack.cz, kontakt info@slack.cz. Prolinkovaná z /o-projektu (sekce „Osobní údaje") a z registračního checkboxu agreeTerms.
Routes (zkrácený přehled)
| Path | Name | Public? |
|---|---|---|
/ |
app_index |
✓ |
/mapa |
app_line_map |
✓ |
/mapa/data |
app_line_map_data |
✓ JSON — všechny lajny (markery na mapě) |
/mapa/feed |
app_line_map_feed |
✓ JSON — N posledních přechodů (default RECENT_LIMIT); s ?date=YYYY-MM-DD&days=7 vrací time-travel okno |
/mapa/timeline-data |
app_line_map_timeline |
✓ JSON — vše pro time-travel playback (lajny + crossings chronologicky) |
/lajna/{slug} |
app_line_detail |
✓ |
/lajna/pridat |
app_line_new |
ROLE_USER — formulář nové lajny (priority 10 kvůli kolizi s /lajna/{slug}) |
/lajna/{slug}/uprava |
app_line_edit |
ROLE_USER — direct edit (owner of unverified / admin) nebo proposal (verified + non-admin); detail v docs/line-edits.md |
/lajna/{slug}/smazat |
app_line_delete |
ROLE_USER — owner-of-unverified nebo admin |
/lajna/{slug}/overeni |
app_line_verify |
ROLE_ADMIN — flag isVerified=true |
/lajna/{slug}/historie |
app_line_history |
✓ — veřejný audit log editů (APPLIED + REJECTED) |
/lajna/{slug}/historie/{editId}/smazat |
app_line_history_delete |
ROLE_ADMIN POST — stack-pop poslední revize |
/lajna/{slug}/fotky/pridat |
app_line_photo_new |
ROLE_USER — upload fotky |
/lajna/{slug}/fotky/{id} |
app_line_photo_detail |
✓ — photo detail (like, komentáře, prev/next) |
/lajna/foto/{id}/libi |
app_line_photo_like |
ROLE_USER POST — toggle like |
/lajna/foto/{id}/smazat |
app_line_photo_delete |
ROLE_USER POST — owner uploadu / admin |
/lajna/komentar/{id}/smazat |
app_line_photo_comment_delete |
ROLE_USER POST — owner komentu / admin |
/lajna/{slug}/prechod/pridat |
app_crossing_new |
ROLE_USER — přidat přechod |
/prechod/{id}/uprava |
app_crossing_edit |
ROLE_USER — vlastní přechody |
/prechod/{id}/smazat |
app_crossing_delete |
ROLE_USER — vlastní přechody, CSRF |
/longline/new |
app_longline_new |
ROLE_USER — přidat longline přechod (disciplína = EN) |
/longline/{id}/edit |
app_longline_edit |
ROLE_USER — vlastní longline přechody |
/longline/{id}/delete |
app_longline_delete |
ROLE_USER — vlastní, CSRF |
/admin/navrhy |
app_admin_proposals |
ROLE_ADMIN — fronta pending proposals + diff tabulky |
/admin/navrhy/{id}/schvalit |
app_admin_proposal_approve |
ROLE_ADMIN |
/admin/navrhy/{id}/zamitnout |
app_admin_proposal_reject |
ROLE_ADMIN |
/data-report |
app_data_report |
✓ veřejná, ale odkaz v menu jen adminovi — proklik na legacy detail (LineController::PRODUCTION_URL) |
/denik/{id} |
app_user_diary |
✓ deník konkrétního uživatele (taby Deník / Longline deník — generický obsah pro libovolný typ lajny, ne jen highline; deep-link #denik/#longline) |
/denicky |
app_user_directory |
✓ adresář deníčků (žebříček počtu přechodů, sloupec „Přechody" = LineCrossing libovolného typu, ne jen highline) |
/galerie |
app_gallery |
✓ kronika všech fotek po letech (justified grid, scrollspy roků) |
/o-projektu |
app_about |
✓ |
/intro |
app_intro |
✓ splash overlay (zatím nelinkováno z nav) |
/ochrana-osobnich-udaju |
app_privacy |
✓ GDPR shrnutí, viz § Auth → Ochrana osobních údajů |
/profil/uprava |
app_profile_edit |
login required — formulář: jméno/příjmení/město/ročník/telefon |
/prihlaseni, /registrace, /odhlaseni |
auth | ✓ — registrace je 2-krokový flow, viz § Auth → Registrace |
/obnova-hesla, /obnova-hesla/zmena/{token} |
reset | ✓ |
/overeni-emailu |
email verify | login required |
/wiki, /wiki/{slug} |
app_wiki_* |
✓ highline guidebook (17 kapitol z wiki/, čte se z lokálního checkoutu, NN-slug.md konvence, žádný frontmatter) |
/docs, /docs/{slug} |
app_docs_* |
✓ technická dokumentace (interní), čte se z docs/*.md v repu |
Hotové features
- ✅ Line CRUD + verifikační flow —
LineCrudController(new/edit/delete/verify + admin proposal queue). Trust model: kdokoli logged-in přidá lajnu (unverified+createdBy=user), edituje vlastní unverified lajny direct; cokoli jiného (cizí/legacy/verified lajna) jde přesLineEditproposal queue (návrh smí poslat kdokoli přihlášený, schvaluje admin). Pozn.: všech 254 legacy lajn je aktuálněunverified(flag-migrace je no-op kvůli pořadí migrate→import — schválně, admin je verifikuje při pročištění dat; vizdocs/line-edits.md). Form má 2-endpoint GPS picker (Stimulus),lengthse počítá z point1/point2 přes haversine (lokace lajny = ty dva body, žádný separátní střed). Hodnocení (rating, 1–5★, klikací star-picker widget místo<select>, sdílený s formulářem přechodu — viz níž) a titulní fotka (coverPhotoFile,PhotoNormalizerpipeline) přidány 2026-07-09; cover foto jde mimo audit/proposal systém (vizdocs/line-edits.md§ Form).Line::heightje od 2026-07-09 PHP-nullable (?int, DB sloupec zůstává NOT NULL) — sjednoceno se vzoremname, aby se na formuláři nové lajny nepředvyplňovala zavádějící0. ROLE_ADMIN přesapp:admin:grant. Detail vdocs/line-edits.md. - ✅ Crossing CRUD —
CrossingController+LineCrossingForm. „Přidat přechod" na detailu lajny (logged-in), edit/delete vlastních přechodů z deníku (show_actionsflag v_recent_crossings.html.twig). Hodnocení přechodu sdílí stejný rating-picker star widget jako formulář lajny. - ✅
rating-picker— klikací hvězdičkové hodnocení (session 2026-07-09) — sdílený partialtemplates/_partials/_rating_picker.html.twig+ Stimulusrating_picker_controller.js, používá hoLineFormiLineCrossingForm. Vizuál sladěný s.hl-rating-largena detailu lajny přes stejné tokeny ($brand,var(--bs-border-color)), ne přes půjčené třídy (BEM pravidlo v repu zakazuje sdílet třídy cizího bloku) — vlastní blok.rating-picker__star--active. Klik nastaví hodnocení, klik na už aktivní nejvyšší hvězdu ho vyčistí; hover preview čistě v CSS (flex-direction: row-reverse+ obecný sourozenecký selektor). Field type v obou formulářích jeHiddenType(neChoiceType) — kritické: pokud se field nikdy nevykreslí přesform_widget()/form_row(), Symfonyhoform_end()ho tiše dorenderuje navíc jako<select>přesrender_rest(dva prvky se stejnýmname) — protoform_widget(form.rating, {attr: {...}})v partialu, ne ruční<input>tag. - ✅ Line import — 254 / 254 lajn z legacy MySQL do Postgres (re-runnable s
--truncate, GPS fallback přesgpstable). Detaily vdocs/migration.md. - ✅ User import — 441 unique-email userů (440 nových + 1 obohacený dev účet), 6 dropped legacy řádků mergováno. MD5 hesla 1:1 zachována,
migrate_from: legacy_md5přehashuje na bcrypt při prvním přihlášení. Crossings remap přes merge mapu. - ✅ Crossings import — 993 / 995 přechodů (2 skipy kvůli
0000-00-00datu). Style enum (App\Enum\CrossingStyle, 9 hodnot, vizdocs/crossing-styles.md), neznámé legacy hodnoty se reportují jako warning. - ✅ Longline deník (session 2026-06-13) — druhý tab na
/denik/{id}vedle highline obsahu.LonglineCrossingentita (bez lajny/GPS — longline se nezapisuje k highline,placeje volný text),LonglineCrossingController(plný CRUD, owner-gated + CSRF, vzorCrossingController). Importapp:import:longline-crossingsz legacylongline(414 / 435, 21 skipů kvůli0000-00-00), idempotentní přeslegacyId. Styl sdílí stejný enum jako highline — protoHighlineCrossingStylepřejmenován naApp\Enum\CrossingStyle; longline picker filtruje leash-only styly (swami/solo/kotník) přesCrossingStyle::appliesToLongline(). Vznikly u toho dvě obecné UI komponenty: Stimulustabscontroller + page-agnostic CSS (.tabs/.tab/.tab-panel,.tabs-pillsvarianta, deep-link přes URL hash;/tvna ni převedeno,tv-tabssmazán) a generická.table(+.table-wrap/.table-num/.table-actions/.table-subtext), kterou používá longline tabulka. - ✅ Line mapa s 254 lajnami (Leaflet, OSM + Esri ortofoto přepínač, fullscreen, linka mezi body od zoom ≥ 14, popup linkuje na detail)
- ✅ Line detail
/lajna/{slug}— slug unikátně v DB (gen. přesAsciiSlugger), info tabulka, mini-mapa s polyline mezi kotvícími body, list všech přechodů - ✅ Index page (single-column rework): sloučený panel Mapa (
hp_map_controller.js, identifierhp-map) = levý scrollovatelný sidebar posledních 10 přechodů + mapa, která ukazuje jen aktivní přechod (na load první). Aktivní přechod vykreslí reálnou linku (polyline point1↔point2), zazoomuje na ni a animuje ikonku (emoji walker) po posledním úseku lajny (WALK_FRACTION/WALK_DURATION, do budoucna chování dle typu přechodu). Po dojití se na ikonce spustí náhodná oslavná animace (CELEBRATIONS→ CSShp-cel-*: bounce/flip/spin/pop/wobble, respektujeprefers-reduced-motion). Má-li přechod komentář (data-comment), po dokončení chůze se nad ikonou objeví decentní „thought" bublina (showThought()injektuje.hp-thoughtdo DOM markeru až na konci, ne během walku; clamp 3 řádky, fade-in,textContent= auto-escape). Stejná geometrie po sobě (víc přechodů na téže lajně) let přeskočí (lastGeoKey), aby mapa necukala; jiná lajnaflyToBoundsglide. Úvodní zobrazení po načtení je instantní (activate(0, {animate:false})+ mapa se rovnou seedne u prvního přechodu) — jinak by velký z7→z17 fly-through nafoukl vrstvy a rozmazal tiles. Homepage běží jako auto-showcase: po dokončení přechodu se počkáDWELL(7 s) ascheduleAdvance()přepne na další přechod dokola (cyklus ≈ fly 1,6 s + walk 5 s + dwell 7 s ≈ 14 s). Timer je vázaný na generaci (gen) a maže se vactivate, takže manuální klik cyklus jen převezme z nového místa. Geometrie se čte zedata-*na<li>(server-render, žádný extra endpoint). Pod tím panel „Z galerie" a na konci horizontální slackTV strip. Gotcha opravená 2026-07-09:activate()volaloitem.scrollIntoView({block:'nearest'})na aktivaci aktivní položky (i při automatickémscheduleAdvance()) —scrollIntoView()ale prolézá všechny scrollovatelné předky včetnědocument, takže když byl widget odscrollovaný mimo viewport (user čte dál na stránce), auto-advance vytáhl celou stránku zpátky nahoru. NahrazenoscrollItemIntoView(), který scrolluje ručně jen.hp-map__crossings(vlastníoverflow-y: autobox), page scroll nikdy netýká. - ✅ slackTV — YouTube feed, hashtag/search/channel zdroje, in-memory cache, dedikovaná stránka
/tvs inline přehráváním - ✅ slackTV redesign na sekce (session 2026-06-11) —
/tvrozdělena na Kanály / Playlisty / Hashtagy, horizontální slidery, AJAX „load more" se stránkováním (/tv/more), hashtag taby. Druhá feed vrstvaApp\Feed\Tv\*(TvFeedInterface,YoutubeClientsforHandle+pageToken,CachedTvFeed). Viz § Feed (slackTV). - ✅ slackTV per-source řazení (session 2026-06-13) — kanál/playlist v
feed.yamlumí{ id, sort }(asc= od nejstarších,descdefault). YouTubeplaylistItemsneumí reverse, takžeasczdroj se přesYoutubeClient::allPlaylistItemsstáhne celý, otočí a stránkuje lokálně integer offsetem. Config normalizujeTvSource. Viz § Feed (slackTV). - ✅ About / O projektu s historií slacklive 2007 → slack.cz 2010 → ČAS 2011 → dnes, vč. archivního Kolouchova úvodního slova
- ✅ Hlavní vizuál — světlý theme, magenta accent (
#e91e63z původního slack.cz loga) - ✅ Auth (registrace, login, reset, email verify) — základ předvyřízený před začátkem této vývojové větve; registrační flow (nick, 2-krokový redirect, souhlas se zpracováním osobních údajů) a profil (viditelnost telefonu, validace) revidované 2026-07-09, detail v § Auth
- ✅ slackvibes 📻 audio player — persistent přes Turbo, docked v hlavičce / floating expandable / hidden, equalizer animace, draggable; viz
docs/audio-player.md - ✅ Intro splash overlay — fullscreen logo + „Vstoupit" button (component
intro-overlay), magenta glow, single-action vstup do appky + spuštění audio playeru - ✅ Time-travel mapa — historický playback lajn + crossings v čase, controly v
.map-tt-panel(z-index 500) - ✅ Tabový
.map-panelna/mapa(2026-07-07, nahradil dřívější news-bar sidebar) — jeden panel vlevo nahoře s taby Lajny (seznam lajn ve výřezu) a Přechody (posledních N přechodů, sdílí data s emoji markery; filtr posledních N / rozsah dat; eye toggle markerů — default schované). Taby = Bootstrap buttony: rozbalený panel má vybraný tab.active(filled), sbalený = jen outline button Lajny + šipka; button Přechody nese počet přechodů, nebo přeškrtnuté oko, když jsou markery schované. Výška je user-driven tažením úchytu (clamp ≤ ½ mapy). Desktop i mobil stejně — přechody jsou tím poprvé dostupné na mobilu. V time-travel režimu se obsah přepíná na okno -7 dní zpět od virtuálního času. - ✅ Deník uživatele
/denik/{id}— hlavička (nick, město, ročník, datum prvního přechodu), mini-mapa s navštívenými lajnami, list všech přechodů - ✅ Markdown sections
/docs+/wiki— sjednocený subsystém pro MD obsah z repa (čte se z disku). Detail níže. - ✅ Line foto galerie + sociální vrstva —
LinePhoto(line FK, uploadedBy FK SET NULL, filename, caption, createdAt) +LinePhotoLike(UNIQUE photo+user) +LinePhotoComment(photo FK CASCADE, author FK SET NULL, text, createdAt). Upload přesvich/uploader-bundle(mappingline_photo→public/uploads/line/{id}/<uniqid>.webp; vstup JPG/PNG/WebP/HEIC do 30 MB, ukládá se vždy WebP master zPhotoNormalizer). Thumby on-demand přesliip/imagine-bundle(filter setyline_thumb320×240 outbound,gallery_thumb1600×480 inset,line_medium800×600 inset,line_full2400 inset; všechny sauto_rotate+strip). EXIF (datum + GPS) jde do DB sloupců, soubor je čistý — viz § Foto galerie — upload pipeline. Per-photo detail/lajna/{slug}/fotky/{id}s AJAX like-toggle (Stimulusphoto_like_controller, fetch sAccept: application/json, endpoint vrací{liked, count}), plain-text flat komentáři (owner/admin delete), prev/next navigací. Grid v_line_gallery.html.twigzobrazuje overlay badges (likes ❤, komenty 💬). Homepage panel „Z galerie" rotuje N fotek z posledních 7 dní (fallback all-time top-liked) + „Otevřít →" na/galerie. Cover lajny je self-hostovaný přesLine.coverPhotoFK (legacy hotlink zrušen 2026-06-16). Legacy import cover + galerie hotový (app:import:line-photos, vizmigration.md§ Line photos);highline_media(externí odkazy) zůstává deferred. - ✅ Galerie
/galerie— kronika po letech (2026-07-04) — všech ~376 fotek v ročních sekcích 2026→2004 + „Bez data", sticky lišta roků se scrollspy (gallery_nav_controller.js). Justified grid čistě v CSS (_gallery.scss):flex-growiflex-basispoložky škálované poměrem stran (--arinline zLinePhoto.getAspectRatio()), takže řádek má jednotnou výšku a layout je stabilní ještě před načtením obrázků (nulový CLS — výška dokumentu se s obrázky nemění);::afterspacer hlídá poslední řádek. Hover overlay se jménem lajny + popiskem (na touch skrytý), klik → stávající photo detail. Koncept „čas jako jediná osa" je rozhodnutí usera — u legacy fotek jecreatedAtdatum 1. napnutí lajny (aproximace), s user uploady (reálný EXIF) se kronika zpřesňuje sama. Seskupení po lajnách/místech záměrně ne: ~1,6 fotky na lajnu,area/regionprázdné.
Markdown sections (/docs, /wiki)
Jeden generický subsystém v App\Markdown\Section\* slouží jak technické dokumentaci (/docs = docs/*.md v repu), tak highline guidebooku (/wiki = wiki/NN-skupina/NN-slug.md v repu). Obsah se čte z lokálního checkoutu (ne přes GitHub API) — deploy je git pull-ne na server. Žádný per-sekci kód, žádný frontmatter — všechno řídí filename + obsah markdownu.
Komponenty
src/Markdown/Section/
Page.php # value object: slug, filename, body, GH urls, title (z prvního H1)
Entry.php # lightweight DTO pro sidebar (slug, filename, label, group)
Config.php # per-sekci konfigurace (owner/repo/branch/path/prefix)
FetcherInterface.php # list() + get(slug)
FilesystemFetcher.php # čte MD z lokálního checkoutu (wiki/, docs/), slug/folder label parsing
src/Controller/MarkdownSectionController.php # 4 routes (docs index/show, wiki index/show)
templates/pages/_section/
_sidebar.html.twig # shared partial — README link + chapter list, group separators
index.html.twig # README.md as index body + sidebar
show.html.twig # detail body + sidebar (vše v `.md-prose`, H1 nese sám body)
config/packages/markdown.yaml # per-sekci service wiring
Service wiring
Každá sekce = dvojice services v config/packages/markdown.yaml:
| Service ID | Třída | Účel |
|---|---|---|
app.section.<name>.config |
Config |
GitHub coordinates (už jen pro blob/edit URL) + route prefix |
app.section.<name>.fetcher |
FilesystemFetcher |
čte MD z %kernel.project_dir%/{path}, injected do controlleru |
Přidání nové sekce = další dvojice services + 2 route metody v controlleru. Controller binduje fetchery + configs přes #[Autowire('@app.section.<name>.fetcher')].
Obsah se čte z lokálního checkoutu (deploy ho git pull-ne), takže žádný GitHub token, rate-limit ani cache vrstva. owner/repo/branch v Configu slouží už jen ke skládání blob/edit odkazů do GitHub UI (tlačítka „zobrazit/editovat na GitHubu").
Konvence MD souborů (žádný frontmatter)
Po dropu YAML frontmatteru řídí všechno chování dva vstupy: filename a obsah markdownu.
- Filename
NN-slug.md—NNurčuje pořadí v sidebaru (lexikografický sort relativního path, takže01-foo/02-bar.md<01-foo/03-baz.md<02-x/...). Slug = filename bez^\d+-prefixu a.mdpřípony —02-bezpecnost.mdje dostupné na/wiki/bezpecnost. URL slug musí být unikátní napříč subtree, jinak první vyhrává. - Label v sidebaru = první
#H1 v souboru (fallback slug). Title v<title>tagu taky. - Pull-quote = první
>blockquote hned po H1, stylovaný přes CSS.md-prose h1 + blockquote(větší písmo, italic, accent background). Žádný speciální HTML tag, čistý markdown blockquote. - Skupinové separátory v sidebaru se derivují z root
README.mdsekce:## H2 nadpiszaštiťuje skupinu, foldery linkované pod ním (přes[text](NN-folder/...)) dostanou ten H2 jako group label. Diakritika +&se zachovají, žádný extra soubor.
Layout v GH repu
- Docs flat (
docs/*.md), bez subfolderů, bez group separátorů (jejichREADME.mdnemá H2 + linky → mapa folderů je prázdná). - Wiki nested (
wiki/NN-skupina/NN-slug.md). Rootwiki/README.mdudržuje index + definuje group labely. Fetcher prochází adresář sekce rekurzivně (RecursiveDirectoryIterator).
README.md
Index /docs resp. /wiki rendrují README.md z root sekce (pulluje se přes get('README'), mimo slugMap). /docs/README resp. /wiki/README redirectují 301 na index. Subfolder README.md se ignorují (slug mapě se vyhnou — nemají v současném modelu žádný účel).
CommonMark gotcha (relevantní pro inline base64 obrázky ve Wiki)
Wiki kapitoly můžou mít base64 obrázky inline jako MD reference-style:
Text s ![alt][image1] obrázkem.
[image1]: data:image/png;base64,iVBOR...
MUSÍ být [image1]: data:... (bare URL). Pokud obalíš angular brackets — [image1]: <data:...> — CommonMark to fallne na autolink + odmítne parsovat při velkých URL (>10 KB). Výsledek: <img> tag se vůbec neudělá, ref-def se vypíše jako text.
Žádná cache (čtení z disku)
Obsah žije v deployovaném checkoutu, takže fetcher čte přímo z disku — žádná cache vrstva ani last-known-good fallback (řešily by výpadek/ rate-limit GitHubu, který už nehrozí). list() čte u každého souboru jen hlavičku po první H1 (label do sidebaru), aby netahal MB-velké inline base64 přílohy; plný body se načítá až v get() pro samotnou stránku. slugMap + folderLabels jsou memoizované per-request.
Internal MD link rewriting
MarkdownRenderer::render($body, $internalRoutePrefix) přepisuje relativní *.md linky (i v subfolderech: 01-pouzivani-highline/02-bezpecnost.md) na /{prefix}/{slug} (/wiki/bezpecnost). Slug stripuje ^\d+- přes FilesystemFetcher::slugFromFilename() (sjednocený zdroj transformace). Externí URL (s schemem http:, mailto: atd.) se ponechávají.