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ą.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) |
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 | podium per kategoria + zbiorczo + wyróżnienia — faza results |
GET /api/my-awards | nagrody zalogowanego (dyplomy) — faza results |
...admin/* | uczestnicy, kategorie (owner), terminy, etapy, parametry, postęp, moderacja zdjęć, 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); wynik zdjęcia = suma punktów od wszystkich.id — praktycznie ex aequo (ORDER BY points DESC, votes DESC, id).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).Kopie (lekka + miniatura) robi przeglądarka (canvas), bez Cloudflare Images. Urządzenia Apple i tak zamieniają HEIC na JPEG przed wysłaniem, więc do aplikacji trafia JPEG — potwierdzone na iPhonie i Macu, w Safari i Chrome. 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ń).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 9 MB × 240 ≈ do ~2,1 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 PREVIEW/ # dokumentacja + makiety
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
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).
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).
PREVIEW/wyniki-warianty.html (do wyboru).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). Resztę (TTL PIN, progi dławienia) trzymać w kodzie.console.log). Obowiązuje tak samo w części uczestnika i w panelu admina.konkurs-foto/
public/ # frontend (serwowany)
app.js
dev-lan.json
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
dom-anim.js
dom.js
help.js
infobar.js
install-banner.js
lightbox.js
phone.js
photo-image.js
photos.js
results.js
session.js
settings-comm.js
settings-events.js
settings-forms.js
settings-handlers.js
settings-help.js
settings-jury.js
settings-views.js
settings.js
tabs.js
toast.js
version.js
voting.js
functions/
api/ # Pages Functions (endpointy)
accept-regulamin.js
admin/categories.js
admin/categories/[id].js
admin/check-delivery.js
admin/deadlines.js
admin/events.js
admin/jury-favorites.js
admin/jury.js
admin/notify.js
admin/overview.js
admin/params.js
admin/phase.js
admin/photos.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
download/[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
email.js
events.js
jwt.js
notify.js
params.js
phase.js
ratelimit.js
regulamin.js
scoring.js
session.js
testmode.js
version.js
schema.sql wrangler.toml package.json scripts/ dokumenty/
.js i .css (KB + linie, stan 2026-07-17)| Plik | KB | Linie |
|---|---|---|
functions/api/ | ||
accept-regulamin.js | 0.5 | 10 |
admin/categories.js | 1.2 | 28 |
admin/categories/[id].js | 2.4 | 58 |
admin/check-delivery.js | 1.7 | 44 |
admin/deadlines.js | 1.8 | 39 |
admin/events.js | 1.8 | 42 |
admin/jury-favorites.js | 1.6 | 41 |
admin/jury.js | 3.9 | 99 |
admin/notify.js | 5.5 | 136 |
admin/overview.js | 3.3 | 72 |
admin/params.js | 0.8 | 20 |
admin/phase.js | 0.9 | 22 |
admin/photos.js | 1.4 | 35 |
admin/test-mode.js | 1.8 | 42 |
admin/users.js | 1.7 | 48 |
admin/users/[id].js | 4.6 | 91 |
ai/[id].js | 7.9 | 183 |
auth/logout.js | 0.4 | 8 |
auth/request-pin.js | 6.2 | 147 |
auth/verify-pin.js | 4.6 | 118 |
download/[id].js | 1.4 | 31 |
health.js | 0.3 | 7 |
img/[id].js | 1.9 | 41 |
me.js | 1.5 | 39 |
my-awards.js | 4.4 | 122 |
photos.js | 1.6 | 40 |
photos/[id].js | 4.7 | 109 |
photos/upload.js | 4.9 | 114 |
results.js | 2.8 | 71 |
thumbs/[id].js | 1.4 | 29 |
thumbs/missing.js | 1.2 | 32 |
vote.js | 2.9 | 72 |
vote/[category].js | 5.2 | 136 |
functions/lib/ | ||
ai.js | 6.9 | 135 |
auth.js | 1.3 | 30 |
email.js | 5.4 | 140 |
events.js | 4.3 | 72 |
jwt.js | 2.6 | 77 |
notify.js | 12.5 | 264 |
params.js | 1.5 | 37 |
phase.js | 1.7 | 46 |
ratelimit.js | 2.1 | 46 |
regulamin.js | 0.7 | 19 |
scoring.js | 0.3 | 4 |
session.js | 2.2 | 45 |
testmode.js | 7.4 | 153 |
version.js | 0.1 | 2 |
public/js/ | ||
about.js | 6.5 | 137 |
accordion.js | 3.3 | 114 |
ai.js | 9.4 | 265 |
auth.js | 2.3 | 77 |
confirm.js | 3.8 | 100 |
consent.js | 2.3 | 56 |
dom-anim.js | 1.0 | 24 |
dom.js | 0.7 | 26 |
help.js | 7.0 | 82 |
infobar.js | 4.0 | 121 |
install-banner.js | 6.6 | 137 |
lightbox.js | 1.4 | 44 |
phone.js | 2.2 | 66 |
photo-image.js | 3.3 | 87 |
photos.js | 18.4 | 466 |
results.js | 10.5 | 272 |
session.js | 0.5 | 17 |
settings-comm.js | 9.7 | 204 |
settings-events.js | 9.4 | 225 |
settings-forms.js | 7.3 | 166 |
settings-handlers.js | 12.8 | 324 |
settings-help.js | 8.3 | 50 |
settings-jury.js | 8.5 | 202 |
settings-views.js | 16.0 | 320 |
settings.js | 18.4 | 347 |
tabs.js | 2.2 | 55 |
toast.js | 1.1 | 35 |
version.js | 0.1 | 2 |
voting.js | 21.3 | 525 |
public/ | ||
app.js | 3.4 | 73 |
style.css | 51.3 | 755 |
sw.js | 1.6 | 22 |
Razem: JS — 78 plików, 7837 linii, ~340 KB · CSS — 1 plik, 755 linii, ~51 KB.
wyróżnione = pliki ponad 400 linii (kandydaci do podziału).