Całość działa w chmurze Cloudflare, bez własnego serwera. Cztery warstwy: urządzenie → frontend → backend → dane (+ usługi zewnętrzne). Kliknij dowolny kafelek, by zobaczyć opis i ścieżkę pliku.
Po lewej cztery warstwy: urządzenie → frontend → backend → dane. Tu pojawi się opis i ścieżka pliku.
GET /api/me (cookie JWT).verify-pin ustawia JWT (7 dni).accept-regulamin (KV).Nowa domena zarejestrowana w Cloudflare: czwarta-b.com. Aplikacja działa na subdomenie
foto-konkurs.czwarta-b.com. Domena czwarta-b.com jest stroną www klasy IVb, a zarazem hubem grupującym dostęp do usług i aplikacji.
Admin = jeden z uczestników (też wgrywa i głosuje, też nie na własne). Dodatkowo:
Admin też startuje, więc NIE widzi cudzych ocen w trakcie — tylko postęp (kto już zagłosował, liczby). Pełne rankingi pokazują się wszystkim dopiero po odsłonięciu.
Wszystkie trzy role wgrywają zdjęcia i głosują; różnią się dostępem do funkcji administracyjnych:
| Rola | widzi ustawienia | dodaje kategorie | dane testowe |
|---|---|---|---|
| Właściciel (owner) | tak | tak | tak |
| Administrator (admin) | tak | nie | nie |
| Uczestnik (user) | nie | nie | nie |
Trzy role — od najszerszych uprawnień do najwęższych:
Każda rola ma niezależny przełącznik zgłaszania zdjęć (w panelu „zdjęcia +/−"; w bazie odwrotne pole voter_only). Domyślnie jest włączony — osoba zgłasza własne zdjęcia i głosuje. Po wyłączeniu osoba tylko głosuje, nie zgłasza zdjęć; przydaje się dla zaproszonych gości albo szerszego grona oceniającego.
W skrócie: rola mówi, co ktoś może robić w konkursie, a przełącznik zgłaszania zdjęć — czy sam staje do rywalizacji ze swoimi zdjęciami. Jedno z drugim nie jest powiązane, więc można je łączyć dowolnie: nawet administratorowi (czy właścicielowi) można wyłączyć zgłaszanie zdjęć — wtedy prowadzi konkurs, ale sam w nim nie startuje, jedynie ocenia prace innych. W ustawieniach odpowiadają temu dwa niezależne przełączniki: jeden dla roli, drugi dla zgłaszania zdjęć.
upload) — uczestnicy wgrywają zdjęcia; dostępne „Zapytaj Claude".voting) — admin zamyka zgłoszenia, otwiera układanie TOP3; można modyfikować oceny.results) — admin kończy ocenę; progresywne odsłanianie wyników + wyróżnienia komisji.Progresywne odsłanianie wyników — szczegóły w sekcji 14.
| Element | Rola |
|---|---|
| Pages | frontend statyczny pod foto-konkurs.czwarta-b.com |
| Pages Functions | backend serverless w /functions — bez osobnego Workera |
| D1 | dane (uczestnicy, zdjęcia, głosy, recenzje AI) — env.DB |
| R2 | pliki zdjęć: oryginały + lekkie kopie — env.BUCKET |
| KV | faza, terminy, parametry, akceptacje regulaminu, dzienny limit maili, tryb testowy, ulubione komisji, liczniki dławienia |
| Resend | maile: PIN + potwierdzenia/przypomnienia + zaproszenie |
| Anthropic API | funkcja „Zapytaj Claude" (Opus 4.8) — patrz sekcja 9 |
Domena czwarta-b.com weryfikowana w Resend → wysyłka z dowolnego adresu
(no-reply@ dla automatów, admin@/konkurs@ dla korespondencji), bez zakładania skrzynek.
Dostawca maili — Resend (wdrożone). Moduł email.js jest provider-agnostyczny (aplikacja woła tylko sendEmail), więc podmiana dostawcy = zmiana tego modułu + sekrety, bez zmian w logice. Strażnik dziennego limitu maili w KV. SES rozważany na przyszłość (własna domena), ale niezaimplementowany (placeholder zwraca provider_not_implemented:ses).
Odbiór poczty (skrzynka admin@) — osobno, niższy priorytet (Cloudflare Email Routing → Gmail).
users). Brak → odmowa.PIN ponownie tylko gdy: sesja wygaśnie, wylogowanie, czyszczenie ciasteczek, nowe urządzenie. WhatsApp jako kanał — odrzucony.
Ochrona przed nadużyciem (wdrożone): prośba o kod — maks. 10 kodów/h na e-mail i 30 żądań/h na IP; weryfikacja — 5 prób na kod + twardy licznik 20 nietrafień/h na e-mail (domyka brute-force). Liczniki w KV, przy awarii KV fail-open (nie blokuje logowania). Podgląd PIN na ekranie (tryb dev) tylko z localhost/LAN.
Konkurs (foto-konkurs.czwarta-b.com) i portal czwarta-b.com dzielą jedną sesję. Zalogowanie w jednej aplikacji uznaje druga; wylogowanie kończy sesję po obu stronach.
AUTH_SECRET (ustawiony jako sekret Pages w obu projektach, ta sama wartość) — nim podpisywana i weryfikowana jest sesja. Ciasteczko sesji cbsession ustawiane z Domain=.czwarta-b.com, więc jest współdzielone między subdomenami.getUser (lib/auth.js) rozpoznaje usera po sub=id (token konkursu) albo po e-mailu (token portalu ma sub=e-mail; token konkursu niesie pole email). Konkurs wpuszcza tylko osoby z tabeli users — bycie zalogowanym w portalu nie daje wstępu do konkursu.GET /api/me zwraca 401 { ssoEmail }, a front pokazuje na ekranie logowania komunikat „ten adres nie bierze udziału w konkursie — skontaktuj się z organizatorem".auth/logout czyści cbsession w obu wariantach (host-only i z Domain=.czwarta-b.com) oraz starą nazwę session — inaczej stare host-only ciasteczko trzymałoby zalogowanie.focus/visibilitychange) front woła /api/me i jeśli logowanie zmieniło się po drugiej stronie — odświeża widok (bez ręcznego odświeżania).AUTH_SECRET. Bez niego aplikacja działa jak wcześniej (własne ciasteczko session, podpis JWT_SECRET). Weryfikacja akceptuje oba sekrety i obie nazwy ciasteczka, więc sesje sprzed włączenia SSO nie wypadają.SameSite=Strict — u nas jest Lax, więc OK.Warstwa SSO: lib/session.js (wspólne podpisywanie/weryfikacja/kasowanie ciasteczka).
CREATE TABLE users (
id INTEGER PRIMARY KEY, name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
gender TEXT, -- poprawna odmiana / zwroty AI
is_admin INTEGER NOT NULL DEFAULT 0,
is_owner INTEGER NOT NULL DEFAULT 0, -- chroniony superadmin
active INTEGER NOT NULL DEFAULT 1, -- dezaktywacja zamiast usuwania
voter_only INTEGER NOT NULL DEFAULT 0,-- „tylko głosuje": nie wgrywa zdjęć
ai_sessions_used INTEGER NOT NULL DEFAULT 0, -- licznik sesji AI
invited_at TEXT); -- znacznik wysłania zaproszenia
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL, description TEXT); -- definiuje owner (obecnie 4)
CREATE TABLE photos (
id INTEGER PRIMARY KEY,
owner_id INTEGER NOT NULL REFERENCES users(id),
category_id INTEGER NOT NULL REFERENCES categories(id),
r2_key_original TEXT NOT NULL, -- oryginał (nietknięty)
r2_key_display TEXT, -- lekka kopia do siatki / AI (~1500 px)
title TEXT, filename TEXT, bytes INTEGER,
taken_at TEXT, -- data wykonania z EXIF (jeśli jest)
created_at TEXT DEFAULT (datetime('now')));
CREATE TABLE votes (
voter_id INTEGER NOT NULL REFERENCES users(id),
category_id INTEGER NOT NULL REFERENCES categories(id),
photo_id INTEGER NOT NULL REFERENCES photos(id),
rank INTEGER NOT NULL, -- 1..3
points INTEGER NOT NULL, -- 5-3-1
PRIMARY KEY (voter_id, category_id, rank),
UNIQUE (voter_id, category_id, photo_id));
CREATE TABLE ai_reviews ( -- cache „Zapytaj Claude" — per zdjęcie
photo_id INTEGER PRIMARY KEY REFERENCES photos(id),
review_text TEXT NOT NULL,
titles_json TEXT, -- 3 tytuły + wskazany najlepszy
followups_json TEXT, -- pytanie z 5 chipów + odpowiedź
created_at TEXT DEFAULT (datetime('now')));
Pozostałe tabele (pełny schemat w schema.sql): favorites (ulubione per głosujący), ai_usage (tokeny Anthropic), jury_awards (3 wyróżnienia: pos, photo_id, degree, reason), login_codes (kody PIN — OTP), events (log zdarzeń, retencja 60 dni).
PIN-y w D1 (login_codes, mocna spójność); sesja jako JWT w cookie. Faza/terminy/parametry/akceptacje/limit maili — w KV.
| Endpoint | Działanie |
|---|---|
POST /api/auth/request-pin | {email} → allowlist → PIN (CSPRNG), hash do D1, Resend; dławienie per IP/e-mail |
POST /api/auth/verify-pin | {email, pin} → cookie sesji (długa); limit prób + twardy licznik nietrafień |
POST /api/auth/logout | czyści ciasteczko sesji we wszystkich wariantach (host-only + Domain=.czwarta-b.com + stare session) — wspólne wylogowanie (SSO) |
GET /api/me | bieżący użytkownik + faza + terminy + czy wymagana akceptacja regulaminu; przy sesji SSO spoza konkursu → 401 { ssoEmail } (front pokazuje komunikat) |
POST /api/accept-regulamin | zapis akceptacji aktualnej wersji regulaminu |
GET /api/photos | moje zdjęcia + lista kategorii (z opisami) + limity/liczniki |
POST /api/photos/upload | oryginał + lekka kopia + miniatura, limit 3/kategorię, faza upload; blokada dla „tylko głosy" |
DELETE /api/photos/:id | usunięcie zdjęcia (właściciel; admin — moderacja) — faza upload |
PATCH /api/photos/:id | zmiana tytułu / kategorii własnego zdjęcia — faza upload |
GET /api/img/:id | strumień zdjęcia z R2 (wymaga sesji); ?t=1 — miniatura (awaryjnie lekka kopia); ?v=<token> — wersja pliku do cache (z tokenem immutable, bez — no-cache + ETag/304); chroni przed starym obrazem po odzyskaniu numeru zdjęcia (SQLite reużywa id) |
GET /api/download/:id | pobranie oryginału na dysk — tylko właściciel |
GET /api/vote | przegląd: faza + wszystkie kategorie (kandydaci, mój TOP, ulubione) |
GET /api/vote/:category | kandydaci kategorii bez własnych, stała losowa kolejność + mój TOP/ulubione |
POST /api/vote/:category | {top:[…], favorites:[…]} → punkty 5-3-1 → zapis (nadpisuje, transakcyjnie), faza voting |
GET /api/ai/:id | recenzja z cache (jeśli jest) + stan budżetu AI |
POST /api/ai/:id | recenzja strumieniowana (SSE, Opus 4.8) albo 1 dodatkowe pytanie; faza upload, własne zdjęcie |
GET /api/results | ranking per kategoria + wyróżnienia + statystyki zapowiedzi (bez rankingu zbiorczego) — pełne dane w fazie results |
GET /api/my-awards | nagrody zalogowanego (dyplomy) — faza results; ?photo=ID = podgląd cudzego dyplomu (owner/admin/DEV, do testów) |
...admin/* | uczestnicy, kategorie (owner), terminy, etapy, parametry, postęp, galeria wyboru wyróżnień, wyróżnienia, komunikacja, log, dane testowe |
Fazy upload → voting → results sterowane flagą w KV.
functions/lib/scoring.js — POINTS=[5,3,1], liczba miejsc = POINTS.length, oraz rankingOrderBy() z regułą remisu); wynik zdjęcia = suma punktów od wszystkich.created_at) → numer zdjęcia (id).
created_at przed id, bo „zgłoszone wcześniej" to reguła, a nie szczegół silnika bazy (migracja może wiersze przenumerować); id domyka remis w tej samej sekundzie./api/results i /api/my-awards wołają rankingOrderBy(), żeby podium na ekranie nie rozjechało się z wydrukiem.votes DESC) — reguła inna niż regulaminowa, potrafiła dać odwrotny wynik.Przy ~20 osobach, dobrowolnym głosowaniu i braku głosów na własne, remisy będą częste, a wyniki czułe na pojedynczy głos. Suma punktów faworyzuje kategorie z większą frekwencją głosowania — świadoma decyzja przy konkursie towarzyskim.
W kategorii może być 20+ (nawet ~100) zdjęć → przeciąganie z dużej puli jest niepraktyczne. Dlatego:
GET /api/download/:id)..heic). Lekką kopię i miniaturę robi przeglądarka, więc musi umieć zdekodować HEIC — na urządzeniach Apple (Safari) to działa; przeglądarka bez obsługi HEIC (np. Windows) takiego pliku nie wgra (blokada z jasnym komunikatem). Dla HEIC powstaje dodatkowo pełna kopia JPEG jako podgląd zastępczy dla oglądających bez HEIC (np. jury na Windows); ocena zawsze idzie z oryginału (sekcja 17).Kopie (lekka + miniatura, a dla HEIC także pełna kopia JPEG) robi przeglądarka (canvas), bez Cloudflare Images. Aplikacja przechowuje surowy HEIC (Apple nie zamienia go na JPEG przed wysłaniem), dlatego dekodowanie HEIC wymaga przeglądarki Apple (Safari), a oglądającym bez HEIC (np. Windows) służy pełna kopia JPEG. Miniatury starszych zdjęć dorabiają się w tle po zalogowaniu (przeglądarka właściciela).
Cel: autor dostaje rzeczową, ludzką recenzję własnego zdjęcia + propozycje tytułów i może „poradzić się" AI przed decyzją o zgłoszeniu.
claude-opus-4-8); odpowiedź strumieniowana (SSE).ai_reviews); ponowne otwarcie czyta z bazy (zero nowych wywołań).LOG-ZDARZEN.md.Tekst promptu zapisany dokładnie jak ustalono — nie parafrazujemy go ani nie zastępujemy własnym opisem:
Jesteś recenzentem amatorskich fotografii grupy przyjaciół z tej samej klasy maturalnej. Bądź rzeczowy i miły, okaż empatię. Zwracaj się bezpośrednio. Nie stosuj sformułowań typowych dla AI, w tym przesadnego chwalenia autora zdjęcia.
Opisz załączone zdjęcie w około ${reviewWords} słowach — to długość samego opisu, dąż do niej; 3 tytuły i 5 pytań dodajesz osobno, poza tym limitem słów. Pisz pełnymi zdaniami, nie urywaj zdania w połowie. Oceń jego walory artystyczne, pokazane emocje i warsztat twórcy. Skup się na pozytywnym przekazie. Liczbę uwag krytycznych dopasuj do jakości zdjęcia: im zdjęcie lepsze, tym mniej rzeczy do poprawy — nie szukaj wad na siłę; przy bardzo dobrym zdjęciu zasugeruj wysoką ocenę i ogranicz uwagi krytyczne do minimum. Przekaż sugestie.
Zaproponuj 3 tytuły dla tego zdjęcia, wskaż najlepiej dopasowany.
Jeśli autor chce, odpowiesz na jedno dodatkowe pytanie. Zaproponuj pięć pytań, które AUTOR mógłby zadać TOBIE o to zdjęcie i na które potrafisz odpowiedzieć — sformułowane z perspektywy autora proszącego o radę lub wyjaśnienie (np. „Jak mógłbym…?", „Jakie ustawienia…?", „Jak obrobić…?"). NIE pytaj autora o jego intencje ani decyzje. Po jednym z obszarów, w kolejności: 1) przekaz i wymowa zdjęcia (jak je wzmocnić), 2) ujęcie (kompozycja, kadr, światło), 3) ustawienia aparatu, 4) obróbka w postprodukcji, 5) lekkie, z przymrużeniem oka.
Implementacja obok promptu (bez zmiany słów): płeć / kategoria / tytuł przekazywane w wiadomości jako dane (źródło prawdy); zdjęcie jako kopia ~1500 px; z 5 chipów autor wybiera jedno pytanie.
| Zasób | Zużycie (max) | Darmowy próg |
|---|---|---|
| R2 (magazyn) | oryginały do 16 MB × 240 ≈ do ~3,8 GB; kopie — ułamek tego | 10 GB |
| D1 (dane) | głosy ~setki + ulubione + recenzje | 5 GB, mln operacji/dzień |
| Functions | kilka–kilkanaście tys. żądań | 100 tys./dzień |
| Resend (maile) | kilkadziesiąt–setka | ~100/dzień, 3 000/mies. |
Infrastruktura CF mieści się w darmowych progach (R2 z dużym zapasem — zmniejszanie do kopii robimy dla szybkości galerii, nie dla limitu).
Płatne: domena czwarta-b.com (~kilkadziesiąt zł/rok) i tokeny Claude (zmienny koszt, ograniczany budżetem sesji AI).
wrangler.toml (D1/R2/KV), sekrety (Resend, Anthropic), schema.sql + seed, flaga fazy, domena.Osobno od Rambulo — celowo. Rambulo = Vite + Express + SQLite na Railway. Konkurs = serverless w całości na Cloudflare, lekka technologia.
/Users/tomaszkossut/konkurs-foto/ (SPEC + PREVIEW + kod).wrangler pages deploy.wrangler CLI. Sekrety: .dev.vars lokalnie, wrangler secret na produkcji.konkurs-foto/
SPEC.md README.md LOG-ZDARZEN.md # specyfikacja (źródło prawdy), opis repo, typy zdarzeń logu
dokumenty/ # źródła .md dokumentów ze stopki (→ public/*.html)
PREVIEW/ # katalog do prezentacji makiet na żywo (prototypy UX, nieserwowane)
public/ # frontend (vanilla JS, lekko)
functions/api/... # Pages Functions (login, photos, vote, ai, results, admin)
schema.sql # tabele D1 + seed
scripts/ # stamp-version, tag-release
_WYDANIE.COMMAND _WDROZENIE.COMMAND # launchery wydania (dwuklik) + _STATUS / _SERWER
wrangler.toml .dev.vars .gitignore README.md
.md) — gdzie i po coWszystkie pliki .md to źródła (edytujemy je, nie rendery). Trzy z nich zasilają dokumenty widoczne w stopce aplikacji; po zmianie treści źródła ręcznie przenosimy ją do odpowiadającego pliku public/*.html (nie ma konwertera md→html — polecenie „zsynchronizuj …"). Numer wersji, tabela „Rozmiary plików" i drzewo plików trafiają do obu plików automatycznie przy wydaniu.
Dokumenty ze stopki (źródło → render serwowany):
dokumenty/przewodnik.md → public/przewodnik.html — przewodnik uczestnika (po edycji: „zsynchronizuj przewodnik").dokumenty/regulamin.md → public/regulamin.html — regulamin i harmonogram (po edycji: „zsynchronizuj regulamin").dokumenty/dokumentacja.md → public/dokumentacja.html — ta dokumentacja techniczna (po edycji: „zsynchronizuj dokumentację").dokumenty/README.md — opis samego katalogu źródeł (nie serwowany).Dokumenty projektowe w katalogu głównym (nie serwowane w aplikacji):
SPEC.md — specyfikacja i decyzje projektu (źródło prawdy).README.md — krótki opis repozytorium.LOG-ZDARZEN.md — opis typów zdarzeń zapisywanych w logu (tabela events).style.css (sekcje)Spis sekcji arkusza stylów — generowany automatycznie z banerów „SECTION:" w public/style.css (kolejność = kolejność w pliku; npm run cssmap, także przy wydaniu). Nie edytuj poniższej listy ręcznie.
1. Podstawy — zmienne, reset, paski przewijania, body
2. Układ i nawigacja — wrap, zakładki, nagłówek, panel, stopka
3. Modale wspólne — QR, potwierdzenie, bramka zgody
4. Diagram faz (Ustawienia)
5. Głosowanie — TOP, toast, filtry, pula miniatur
6. Wyniki — pulpit, podium, medale, lightbox wyników
7. Lightbox (podgląd zdjęć)
8. Logowanie
9. Moje zdjęcia — liczniki, pasek etapu, kategorie, lista zdjęć
10. Ustawienia (admin) — uczestnicy, tryb testowy, log zdarzeń
11. Wyróżnienia komisji — sloty, medale/stopnie
12. Aktywność AI (panel admina)
13. Kategorie (Ustawienia)
14. Modal pomocy
15. Modal „O aplikacji"
16. Modal „Zapytaj Claude" — recenzja, tytuły, loader, chipy
17. Mobile (≤560px)
18. Przełącznik (pigułka)
19. Filtr uczestników
20. Treść maili (Ustawienia) — akordeon, podgląd, wysyłka
21. Baner instalacji PWA
22. Dyplomy i odznaka zakładki
Zamiast osobnych skryptów COMMIT / RELEASE / DEPLOY / PUSH są dwa launchery .command (dwuklik w Finderze):
_WYDANIE.COMMAND — przygotowanie wydania: zapisuje wszystkie zmiany jako jedno „wydanie". Po kolei: bramka „brak zmian" → wybór typu [1] patch / [2] minor → opis → podbicie wersji + npm run stamp (+ wpis wersji do SPEC.md i tej dokumentacji) → commit chore(release): WERSJA - opis. Na końcu pyta „Wdrożyć teraz?" i po zgodzie sam uruchamia wdrożenie (całość jednym przebiegiem)._WDROZENIE.COMMAND — publikacja na produkcję: npm run deploy (stempel + wrangler pages deploy + tag git-WERSJA) → kontrola /api/health (porównanie wersji) → push commitów i tagów na GitHub. Uruchomione z _WYDANIE (flaga --potwierdzono) nie pyta ponownie i dziedziczy okno.Bramką „nie wdrażaj tej samej wersji dwa razy" jest tag git-WERSJA (tworzony w postdeploy) — przy powtórnym wdrożeniu tej samej wersji predeploy przerywa i odsyła do _WYDANIE. W odróżnieniu od Rambulo nie ma tu maszyny stanów .deploy-state, testów E2E, mirrora GitLab ani purge Cloudflare — konkurs wdraża bezpośrednio przez wrangler. Pomocniczo: _STATUS.COMMAND (podgląd stanu) i _SERWER.COMMAND (serwer lokalny).
npm test — testy jednostkowe, bez sieci i bazy (szybkie, wpięte w wydanie): scripts/filesizes.test.mjs + scripts/imgver.test.mjs (token wersji obrazka imgVer — sedno ochrony przed pokazaniem starego zdjęcia po odzyskaniu numeru).
Cięższy scenariusz end‑to‑end (obrazy, głosowanie, wyniki, dyplomy, panel admina) uruchamiamy na życzenie w izolowanej piaskownicy — świadomie poza npm test i poza wydaniem, bo zasiewa i mutuje dane. Nigdy nie ruszamy żywego serwera dev ani danych produkcyjnych.
git worktree add -b test-sandbox ../konkurs-foto-sandbox.npm --prefix ../konkurs-foto-sandbox install..dev.vars w piaskownicy: JWT_SECRET, DEV=1, MAIL_PROVIDER/MAIL_FROM/MAIL_DAILY_CAP oraz AUTH_SECRET — bez niego serwer używa ciasteczka session, które na localhost bywa przesłaniane przez stare cbsession z innych lokalnych aplikacji (ciasteczka ignorują port) i logowanie w przeglądarce nie działa.wrangler d1 execute konkurs --local --persist-to ../konkurs-foto-sandbox/.wrangler/state --file schema.sql, potem wstaw usera i kategorię.npm --prefix ../konkurs-foto-sandbox run dev -- --port 8790 --persist-to ../konkurs-foto-sandbox/.wrangler/state.POST /api/admin/test-mode {on:true}, rola owner) → 10 osób × 3 zdjęcia SVG; etap: POST /api/admin/phase {phase:"upload|voting|results"}./dyplom.html?photo=ID).Pułapki: konto @test.test blokuje seed (testDataExists uznaje je za dane testowe — na czas seedu zmień e‑mail konta na nietestowy; sesja działa po numerze konta, nie po e‑mailu) · sprzątanie: POST /api/admin/test-mode {on:false} lub DELETE (purge) · obrazy loading="lazy" poza ekranem mają chwilowo naturalWidth=0 — to nie błąd. Po testach: zatrzymaj serwer i git worktree remove --force ../konkurs-foto-sandbox.
Komunikaty ludzkie: e-mail spoza listy → „Nie znaleźliśmy tego adresu — sprawdź pisownię albo napisz do organizatora"; zły/wygasły PIN → „PIN niepoprawny lub wygasł (pozostały próby: N)". Ochrona (limit prób, hash, TTL, jednorazowość) zostaje.
Wyzwalacz: przycisk tekstowy (bez ikony) — ustalone (rezygnacja z gwiazdki Claude).
U góry przyciski tylko dla admina: Wyróżnienia, Kategoria 1, …, ostatnia kategoria. Naciśnięcie → dana sekcja odsłania się ładnie, od najniższego miejsca podium w górę. „Wyróżnienia" pokazują uznaniowe wyróżnienia komisji.
is_owner.extraAi), długość recenzji i odpowiedzi AI (słowa), tempo animacji interfejsu (0–200%). Resztę (TTL PIN, progi dławienia) trzymać w kodzie.kategorie × limit; głosowanie → głosy wobec kompletu osiągalnego. Oba przełączniki znikają w fazie wyników i gdy nie ma kategorii.votes_max w api/admin/overview.js): Σ min(miejsc TOP, liczba cudzych zdjęć w kategorii), liczony osobno dla każdego uczestnika i tą samą regułą puli co api/vote.js (nie swoje, autor aktywny i widoczny w bieżącym trybie).kategorie × 3 ktoś, kto oddał wszystko, co mógł, wisiałby w „nie skończyli" na zawsze — przy czterech kategoriach po 4, 1, 30 i 2 zdjęcia osiągalne komplety wynoszą pojedynczo 3–6, a nie 12. Ten sam mianownik ma metryka „głosy X/Y" w wierszu, więc filtr i wiersz nie mogą się rozjechać.votingLaggards) używa reguły pokrewnej, ale nie identycznej — pomija kategorie, w których pełnego TOP-u i tak nie da się oddać. Filtr jest więc nieco szerszy od listy adresatów: służy do patrzenia, mail do ponaglania.PRZYKLAD- w nazwie CSV). Administrator sam startuje w konkursie i nie może widzieć rankingów przed wszystkimi; próbka pokazuje format, nic nie zdradzając.rankingOrderBy() co podium i dyplomy; kolejność w protokole nie może rozminąć się z ogłoszonym wynikiem.GET /api/admin/protokol (?format=csv), moduł settings-protokol.js.html2canvas wklejana przez jsPDF na stronę 210 × 297 mm — dokładnie jedna strona A4, niezależnie od ustawień okna druku.| plik | krój | użycie na dyplomie | latin | latin-ext |
|---|---|---|---|---|
cinzel-v26-700-* | Cinzel 700 | nagłówek, „MIEJSCE", kategoria, nazwisko laureata | 15,2 kB | 8,3 kB |
cormorant-garamond-v21-700italic-* | Cormorant Garamond italic 700 | tytuł zdjęcia, uzasadnienie Komisji, podziękowanie, nazwisko bez faksymile (gałąź zapasowa) | 22,4 kB | 19,2 kB |
montserrat-v31-500-* | Montserrat 500 | dolna belka (adres, data) | 18,7 kB | 32,3 kB |
montserrat-v31-600-* | Montserrat 600 | etykiety „OTRZYMUJE", „ZA ZDJĘCIE" | 18,7 kB | 33,2 kB |
curl -A "Mozilla/5.0 AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36" "https://fonts.googleapis.com/css2?family=Cinzel:wght@700"rodzina-vNN-odmiana-podzbiór.woff2 w public/vendor/fonts/ — wersja w nazwie jest obowiązkowa przy rocznym immutable.unicode-range dokładnie takim, jak w arkuszu Google — to on decyduje, że latin-ext pobiera się tylko wtedy, gdy jest potrzebny.document.fonts.check('700 24pt "Cinzel"', "ĄĆĘŁŃÓŚŹŻ ąćęłńóśźż") musi dać true, a szerokość tego napisu mierzona na canvas krojem docelowym musi różnić się od zastępczego — inaczej glify idą z podmiany systemowej, a nie z pobranego pliku.?uczestnik=glosy), bo inaczej oba kafelki prowadziłyby do tego samego dyplomu.console.log). Obowiązuje tak samo w części uczestnika i w panelu admina.Większość przejść treści steruje parametr admina „Tempo animacji" (0–200%, sekcja Parametry): 100% = wartości bazowe, mniej = szybciej, 0 = bez animacji, więcej = wolniej. Parametr trafia do każdego uczestnika na starcie (/api/me), ustawia zmienną CSS --anim, a czasy liczone są jako bazowy × (Tempo/100). Wyjątek: rozsuwanie galerii miniatur (Wyróżnienia + „Moje głosy") ma osobny, rozłączny parametr „Prędkość rozsuwania galerii" (patrz niżej). Odczyt tempa w jednym miejscu: public/js/dom.js → animScale(). Część animacji robi CSS (skalowany przez calc()), część JS (Web Animations / transition, z pominięciem animacji przy 0%).
| Animacja | Baza (100%) | Sterowanie |
|---|---|---|
| Zmiana zakładki — zanikanie starej treści | 0,2 s | baza × Tempo |
| Zmiana zakładki — pojawienie nowej treści | 0,2 s | baza × Tempo |
| Podświetlenie aktywnej zakładki | 0,2 s | baza × Tempo |
| Natywne rozwijaki („jak to działa?", belki maili) | 0,2 s | baza × Tempo |
| Zdjęcie na pełny ekran (powiększenie + tło) | 0,3 s | baza × Tempo |
| Rozwijanie/zwijanie kategorii (Ustawienia, „Moje zdjęcia") | 0,3 s | baza × Tempo |
| Rozsuwanie galerii miniatur (Wyróżnienia, kategorie i „własne" w „Moje głosy") | zależny od ekranu | Prędkość galerii (px/ms, A′) |
| TOP3 — przesuwanie kafelków (FLIP) | 0,3 s | baza × Tempo |
| TOP3 — wysokość slotu | 0,3 s | baza × Tempo |
| „Dymki" powiadomień (toast) | 0,2 s | stała |
| Powiększenie logo pod kursorem | 0,2 s | stała |
| Kropki „ładowania" AI | cykl 1 s | stała |
Trzy pozycje „stała" są celowo poza parametrem: toast i logo to drobiazgi, a kropki „ładowania" AI muszą się kręcić niezależnie (wskaźnik postępu — nie znika przy 0%).
Prędkość rozsuwania galerii — parametr rozłączny z Tempem animacji. Rozsuwanie galerii miniatur (galeria wyboru Wyróżnień oraz kategorie i sekcja „własne" w „Moje głosy") ma własny parametr admina „Prędkość rozsuwania galerii" (1–8 px/ms, skok 0,5, domyślnie 4; sekcja Parametry) — rozłączny z „Tempem animacji": tempo nie wpływa na tę prędkość, a prędkość nie wpływa na resztę animacji. Parametr trafia do każdego uczestnika na starcie (/api/me → revealSpeed) i ustawia REVEAL.V w public/js/accordion.js. Model „A′" (viewport-aware): animowana jest tylko widoczna część rozsuwania — ze stałą prędkością V — a część poniżej dołu ekranu domyka się szybkim „ogonem", więc odczuwalna prędkość jest stała niezależnie od liczby zdjęć, a czas ograniczony wysokością ekranu. Zamykanie symetryczne. Podgląd/strojenie: PREVIEW/_demo-anim.html.
_konkurs-foto/
public/ # frontend (serwowany)
_headers
app.js
dokumentacja.html
dyplom.html
icon_180.png
icon_192.png
icon_512.png
index.html
manifest.webmanifest
portal-4b.svg
przewodnik.html
regulamin.html
style.css
sw.js
trophy_192.png
trophy_512.png
js/ # moduły ES
about.js
accordion.js
ai.js
auth.js
confirm.js
consent.js
diag.js
dom-anim.js
dom.js
help.js
infobar.js
inspect.js
install-banner.js
lightbox.js
phone.js
photo-image.js
photos-view.js
photos.js
reason-limit.js
results.js
session.js
settings-comm.js
settings-dyplomy.js
settings-events.js
settings-forms.js
settings-handlers.js
settings-help.js
settings-jury.js
settings-protokol.js
settings-views.js
settings.js
tabs.js
toast.js
update.js
version.js
voting-view.js
voting.js
vendor/ # biblioteki PDF + fonts/ (kroje dyplomu)
html2canvas-1.4.1.min.js
jspdf-2.5.2.umd.min.js
functions/
api/ # Pages Functions (endpointy)
accept-regulamin.js
admin/categories.js
admin/categories/[id].js
admin/check-delivery.js
admin/deadlines.js
admin/derivatives.js
admin/dyplom-text.js
admin/dyplomy.js
admin/events.js
admin/hashes.js
admin/jury-favorites.js
admin/jury-oglos.js
admin/jury.js
admin/maintenance-summary.js
admin/notify.js
admin/orphans.js
admin/overview.js
admin/params.js
admin/phase.js
admin/photos.js
admin/protokol.js
admin/test-mode.js
admin/users.js
admin/users/[id].js
ai/[id].js
auth/logout.js
auth/request-pin.js
auth/verify-pin.js
diag-mode.js
diag.js
download/[id].js
dyplom-text.js
full/[id].js
health.js
img/[id].js
me.js
my-awards.js
photos.js
photos/[id].js
photos/upload.js
results.js
thumbs/[id].js
thumbs/missing.js
vote.js
vote/[category].js
lib/ # wspólna logika
ai.js
auth.js
diag.js
dyplom-text.js
email.js
events.js
exif.js
imgver.js
jwt.js
keys.js
notify.js
params.js
phase.js
ratelimit.js
regulamin.js
scoring.js
session.js
testmode.js
version.js
wyroznienia.js
schema.sql wrangler.toml package.json scripts/ dokumenty/
.js i .css (KB + linie, stan 2026-10-13)| Plik | KB | Linie |
|---|---|---|
functions/api/ | ||
accept-regulamin.js | 0.8 | 13 |
admin/categories.js | 1.2 | 28 |
admin/categories/[id].js | 2.4 | 58 |
admin/check-delivery.js | 2.3 | 57 |
admin/deadlines.js | 1.8 | 39 |
admin/derivatives.js | 2.9 | 68 |
admin/dyplom-text.js | 0.9 | 20 |
admin/dyplomy.js | 9.9 | 187 |
admin/events.js | 1.8 | 42 |
admin/hashes.js | 4.0 | 89 |
admin/jury-favorites.js | 2.4 | 53 |
admin/jury-oglos.js | 2.1 | 44 |
admin/jury.js | 6.7 | 146 |
admin/maintenance-summary.js | 2.5 | 53 |
admin/notify.js | 6.3 | 139 |
admin/orphans.js | 4.5 | 98 |
admin/overview.js | 5.8 | 105 |
admin/params.js | 0.8 | 20 |
admin/phase.js | 0.9 | 22 |
admin/photos.js | 1.0 | 26 |
admin/protokol.js | 20.1 | 293 |
admin/test-mode.js | 1.8 | 42 |
admin/users.js | 1.7 | 48 |
admin/users/[id].js | 4.6 | 91 |
ai/[id].js | 9.5 | 205 |
auth/logout.js | 0.4 | 8 |
auth/request-pin.js | 6.3 | 147 |
auth/verify-pin.js | 4.8 | 121 |
diag-mode.js | 2.2 | 49 |
diag.js | 2.4 | 46 |
download/[id].js | 1.4 | 31 |
dyplom-text.js | 0.5 | 10 |
full/[id].js | 9.1 | 169 |
health.js | 0.3 | 7 |
img/[id].js | 4.0 | 81 |
me.js | 2.5 | 54 |
my-awards.js | 8.4 | 177 |
photos.js | 1.8 | 46 |
photos/[id].js | 5.8 | 127 |
photos/upload.js | 9.3 | 181 |
results.js | 8.1 | 137 |
thumbs/[id].js | 1.9 | 34 |
thumbs/missing.js | 1.3 | 33 |
vote.js | 3.4 | 83 |
vote/[category].js | 5.4 | 137 |
functions/lib/ | ||
ai.js | 7.4 | 141 |
auth.js | 1.3 | 30 |
diag.js | 5.2 | 111 |
dyplom-text.js | 6.1 | 128 |
email.js | 6.6 | 159 |
events.js | 6.7 | 110 |
exif.js | 10.3 | 245 |
imgver.js | 1.4 | 18 |
jwt.js | 2.6 | 77 |
keys.js | 1.7 | 25 |
notify.js | 14.9 | 298 |
params.js | 2.6 | 51 |
phase.js | 4.5 | 97 |
ratelimit.js | 3.2 | 59 |
regulamin.js | 3.4 | 57 |
scoring.js | 7.8 | 127 |
session.js | 2.2 | 45 |
testmode.js | 7.4 | 153 |
version.js | 0.1 | 2 |
wyroznienia.js | 2.0 | 38 |
public/js/ | ||
about.js | 7.0 | 143 |
accordion.js | 8.8 | 195 |
ai.js | 9.4 | 265 |
auth.js | 2.3 | 77 |
confirm.js | 3.8 | 100 |
consent.js | 2.5 | 57 |
diag.js | 10.3 | 172 |
dom-anim.js | 1.1 | 27 |
dom.js | 2.4 | 56 |
help.js | 10.5 | 98 |
infobar.js | 4.0 | 121 |
inspect.js | 22.9 | 473 |
install-banner.js | 6.6 | 137 |
lightbox.js | 30.8 | 545 |
phone.js | 2.3 | 67 |
photo-image.js | 4.3 | 105 |
photos-view.js | 8.6 | 176 |
photos.js | 24.4 | 564 |
reason-limit.js | 9.5 | 180 |
results.js | 19.1 | 375 |
session.js | 2.8 | 58 |
settings-comm.js | 8.9 | 182 |
settings-dyplomy.js | 27.6 | 582 |
settings-events.js | 11.4 | 263 |
settings-forms.js | 9.9 | 213 |
settings-handlers.js | 32.1 | 660 |
settings-help.js | 22.7 | 92 |
settings-jury.js | 13.8 | 287 |
settings-protokol.js | 12.7 | 230 |
settings-views.js | 16.7 | 277 |
settings.js | 25.9 | 473 |
tabs.js | 3.7 | 92 |
toast.js | 1.1 | 35 |
update.js | 5.4 | 115 |
version.js | 0.1 | 2 |
voting-view.js | 7.7 | 162 |
voting.js | 15.6 | 384 |
public/ | ||
app.js | 7.3 | 129 |
style.css | 98.9 | 1347 |
sw.js | 2.0 | 27 |
Razem: JS — 104 plików, 13831 linii, ~693 KB · CSS — 1 plik, 1347 linii, ~99 KB.
wyróżnione = pliki ponad 400 linii (kandydaci do podziału).
Sekcja Ustawienia → Konserwacja, widoczna wyłącznie dla właściciela (owner). Służy do wykrywania i naprawy rozjazdów między bazą (rekordy zdjęć w D1) a magazynem (pliki w R2). Zawiera trzy narzędzia oraz proaktywny znacznik.
Przypomnienie o plikach zdjęcia. Każde zdjęcie ma oryginał (nietknięte archiwum pracy) oraz pliki pochodne liczone z niego: lekką kopię (~2048 px), miniaturę (~640 px) i — tylko dla HEIC — pełną kopię JPEG. Pełna kopia to podgląd zastępczy: ocena zawsze idzie z oryginału, a kopia wchodzi jedynie wtedy, gdy przeglądarka nie pokaże oryginału HEIC (np. Windows). Klucze wszystkich pochodnych liczy jedno źródło prawdy (functions/lib/keys.js) — używane wszędzie (upload, serwowanie, kasowanie, konserwacja), żeby ten sam plik zawsze miał ten sam klucz.
Pliki, które zostały w magazynie po usuniętych zdjęciach (nie wskazuje ich żaden rekord).
/api/admin/orphans, kasowanie ?potwierdz=USUN) — świadomie żadnego linku GET, żeby obcy adres nie odpalił kasowania Twoim ciasteczkiem.Odwrotność sierot: rekord zdjęcia jest, ale brakuje mu pliku — cichy, częściowy upload (na ekranie wygląda dobrze). Dla każdego zdjęcia sprawdzane są: oryginał, lekka kopia, miniatura oraz — dla HEIC — pełna kopia.
/api/admin/derivatives (sam podgląd, nic nie zmienia); zapis odbudowanych plików przez POST /api/thumbs/:id, POST /api/full/:id, POST /api/img/:id.Każde zdjęcie ma sygnaturę — skrót treści oryginału (SHA-256), dzięki któremu to samo zdjęcie bierze udział w Konkursie tylko raz (także w innym rozmiarze czy formacie pliku — patrz Regulamin §3). Nowe zgłoszenia dostają sygnaturę od razu przy wysyłce; to narzędzie dolicza ją zdjęciom sprzed wprowadzenia blokady (mają sygnaturę pustą).
/api/admin/hashes (podgląd { probe } — sam odczyt; wstawianie partiami po kursorze). Podstawa w bazie: kolumna content_hash z globalnym unikalnym indeksem, który twardo blokuje powtórki także przy wgrywaniu.Przy każdym wejściu w Ustawienia (dla ownera) jedno szybkie sprawdzenie liczy naraz sieroty, braki pochodnych i brakujące sygnatury. Gdy jest robota, zapala się czerwona kropka:
Sprawdzenie jest ciche i nieblokujące (widok pojawia się od razu, kropka dochodzi po odpowiedzi). Endpoint: POST /api/admin/maintenance-summary — tylko odczyt, jedno przejście po magazynie, zwraca liczbę sierot, braków pochodnych i brakujących sygnatur oraz todo.
Ograniczenie z natury rzeczy: odbudowa jest po stronie przeglądarki, więc pełne/lekkie kopie HEIC naprawisz tylko na urządzeniu Apple. Oryginał to chronione archiwum — jego utrata jest nieodwracalna (dlatego Konserwacja nigdy sama nie kasuje oryginałów zdjęć, a jedynie osierocone pliki wskazane przez Ciebie).