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).Kupujemy domenę w Cloudflare: czwarta-b.com. Aplikacja działa na subdomenie
foto-konkurs.czwarta-b.com. Sama czwarta-b.com nie daje dostępu do 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.
| Rola | Wgrywanie | Głosowanie | Ustawienia | Kategorie | Dane testowe |
|---|---|---|---|---|---|
| owner (chroniony) | tak | tak | tak | dodaje | tak |
| admin | tak | tak | tak | nie | nie |
| uczestnik | tak | tak | nie | nie | nie |
| tylko głosy (voter_only) | NIE | tak | nie | nie | nie |
Ustawienia widoczne tylko dla admina i ownera. Dodawanie kategorii oraz przyciski „Dane testowe / Usuń dane testowe" — tylko owner. Rola „tylko głosy" realnie blokuje wgrywanie zdjęć (twarda reguła w API + ukryte „wstaw zdjęcie"). Owner/superadmin nadaje i odbiera admina, nie może być zdegradowany (is_owner).
Owner (właściciel) — jeden, chroniony superadministrator (zwykle organizator). Tylko on: nadaje i odbiera rolę administratora, dodaje kategorie oraz włącza/usuwa dane testowe. Ownera nie da się zdegradować (is_owner), więc konkurs zawsze ma właściciela; w pozostałych czynnościach działa jak zwykły admin.
Uczestnicy „tylko głosujący" (voter_only) biorą udział wyłącznie w głosowaniu — nie wgrywają własnych zdjęć. Blokada jest twarda: API odrzuca próbę uploadu, a w interfejsie znika „wstaw zdjęcie". Rola dla osób, które mają tylko oceniać (np. zaproszeni goście, szersze grono jury), bez startu w konkursie. Głosują na tych samych zasadach co pozostali (TOP3, bez głosów na własne — których i tak nie mają).
Komisja = admin + 2 osoby, bez uprawnień w systemie. Wyróżnienia ustalane są poza systemem, a wpisuje je wyłącznie admin (uznaniowe, ujawniane w trakcie prezentacji wyników). Bez osobnej roli i logowania dla komisji.
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.Szczegóły progresywnego odsłaniania — do zaprojektowania.
| 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 | kody PIN (TTL) + flaga fazy konkursu |
| Resend | maile: PIN + potwierdzenia/przypomnienia |
| 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.
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
ai_sessions_used INTEGER NOT NULL DEFAULT 0); -- licznik sesji AI
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL); -- 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,
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 3 chipów + odpowiedź
created_at TEXT DEFAULT (datetime('now')));
PIN-y w KV z TTL; sesja jako JWT w cookie.
| Endpoint | Działanie |
|---|---|
POST /api/auth/request-pin | {email} → allowlist → PIN, hash do KV, Resend |
POST /api/auth/verify-pin | {email, code} → cookie sesji (długa) |
GET /api/me | bieżący użytkownik + stan licznika AI |
GET /api/categories | kategorie (definiowane przez admina) |
POST /api/photos | upload: oryginał + lekka kopia, limit 3/kategorię; blokada dla roli „tylko głosy" — faza upload |
DELETE /api/photos/:id | usunięcie własnego zdjęcia — tylko przed głosowaniem |
GET /api/photos?category=X | kategoria bez własnych, anonimowo, losowa stała kolejność |
POST /api/vote | {category_id, ranking:[...]} → punkty → zapis (nadpisuje) — faza voting |
POST /api/ai/review | {photo_id} → strumieniowana recenzja Opus 4.8; zużywa 1 sesję AI; cache |
POST /api/ai/followup | {photo_id, question} → jedno pytanie z 3 zaproponowanych, w obrębie sesji |
GET /api/results | podium per kategoria + zbiorczo + wyróżnienia — po odsłonięciu |
...admin | uczestnicy, kategorie, postęp, usuwanie zdjęć, etapy, wyróżnienia, odsłanianie |
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.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:
Konwersja HEIC i generowanie kopii: w przeglądarce (najtaniej, zależne od urządzenia/biblioteki WASM) czy przez Cloudflare Images (płatne, „samo działa"). Rekomendacja: konwersja+zmniejszanie w przeglądarce, z CF Images jako planem B. Przetestować na realnym iPhonie w etapie 1.
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, oceń płeć po imieniu. Koncentruj się na pozytywnym przekazie, ale wskaż też rzeczy do poprawy. Nie stosuj sformułowań typowych dla AI, w tym przesadnego chwalenia autora zdjęcia.
Opisz załączone zdjęcie w 300 słowach. Oceń jego walory artystyczne, pokazane emocje i warsztat twórcy. Przekaż sugestie.
Zaproponuj 3 tytuły dla tego zdjęcia, wskaż najlepiej dopasowany.
Jeśli autor chce, odpowiesz na dodatkowe pytanie - zaproponuj trzy do wyboru w formie chipów.
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 3 chipów autor wybiera jedno pytanie.
| Zasób | Zużycie (max) | Darmowy próg |
|---|---|---|
| R2 (magazyn) | oryginały <5 MB × 240 ≈ do ~1,2 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
wrangler.toml .dev.vars .gitignore README.md
PREVIEW/regulamin-harmonogram.html.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.konkurs-foto/
public/ # frontend (serwowany)
app.js
dla-komisji.html
dyplom.html
index.html
konkurs-foto.html
manifest.webmanifest
przewodnik.html
regulamin.html
style.css
sw.js
js/ # moduły ES
about.js
accordion.js
ai.js
auth.js
confirm.js
consent.js
dom.js
help.js
infobar.js
lightbox.js
phone.js
photos.js
results.js
session.js
settings-events.js
settings-handlers.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/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
health.js
img/[id].js
jury/img/[pos].js
me.js
my-awards.js
photos.js
photos/[id].js
photos/upload.js
results.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
regulamin.js
scoring.js
testmode.js
version.js
schema.sql wrangler.toml package.json scripts/ PREVIEW/
.js i .css (KB + linie, stan 2026-06-29)| Plik | KB | Linie |
|---|---|---|
functions/api/accept-regulamin.js | 0.5 | 10 |
functions/api/admin/categories.js | 1.2 | 28 |
functions/api/admin/categories/[id].js | 2.2 | 58 |
functions/api/admin/deadlines.js | 1.8 | 39 |
functions/api/admin/events.js | 1.3 | 32 |
functions/api/admin/jury-favorites.js | 1.5 | 41 |
functions/api/admin/jury.js | 3.6 | 96 |
functions/api/admin/notify.js | 4.9 | 126 |
functions/api/admin/overview.js | 3.4 | 72 |
functions/api/admin/params.js | 0.8 | 20 |
functions/api/admin/phase.js | 0.9 | 22 |
functions/api/admin/photos.js | 1.3 | 35 |
functions/api/admin/test-mode.js | 1.8 | 42 |
functions/api/admin/users.js | 1.7 | 48 |
functions/api/admin/users/[id].js | 4.4 | 91 |
functions/api/ai/[id].js | 7.8 | 183 |
functions/api/auth/logout.js | 0.4 | 11 |
functions/api/auth/request-pin.js | 3.8 | 99 |
functions/api/auth/verify-pin.js | 3.6 | 103 |
functions/api/health.js | 0.3 | 7 |
functions/api/img/[id].js | 1.1 | 28 |
functions/api/jury/img/[pos].js | 0.9 | 24 |
functions/api/me.js | 1.3 | 39 |
functions/api/my-awards.js | 4.4 | 122 |
functions/api/photos.js | 1.6 | 40 |
functions/api/photos/[id].js | 4.5 | 109 |
functions/api/photos/upload.js | 4.3 | 102 |
functions/api/results.js | 2.8 | 71 |
functions/api/vote.js | 2.9 | 72 |
functions/api/vote/[category].js | 4.8 | 132 |
functions/lib/ai.js | 6.9 | 135 |
functions/lib/auth.js | 0.8 | 22 |
functions/lib/email.js | 4.8 | 133 |
functions/lib/events.js | 4.2 | 71 |
functions/lib/jwt.js | 2.6 | 77 |
functions/lib/notify.js | 8.2 | 188 |
functions/lib/params.js | 1.5 | 37 |
functions/lib/phase.js | 1.7 | 46 |
functions/lib/regulamin.js | 0.7 | 19 |
functions/lib/scoring.js | 0.3 | 4 |
functions/lib/testmode.js | 6.7 | 148 |
functions/lib/version.js | 0.1 | 2 |
public/app.js | 1.3 | 34 |
public/js/about.js | 4.6 | 101 |
public/js/accordion.js | 2.0 | 71 |
public/js/ai.js | 9.4 | 265 |
public/js/auth.js | 2.3 | 77 |
public/js/confirm.js | 3.8 | 100 |
public/js/consent.js | 2.3 | 56 |
public/js/dom.js | 0.7 | 26 |
public/js/help.js | 6.8 | 88 |
public/js/infobar.js | 3.5 | 114 |
public/js/lightbox.js | 1.4 | 44 |
public/js/phone.js | 1.5 | 47 |
public/js/photos.js | 14.7 | 405 |
public/js/results.js | 9.1 | 241 |
public/js/session.js | 0.6 | 23 |
public/js/settings-events.js | 6.4 | 156 |
public/js/settings-handlers.js | 21.4 | 553 |
public/js/settings-jury.js | 9.3 | 223 |
public/js/settings-views.js | 16.3 | 325 |
public/js/settings.js | 22.1 | 351 |
public/js/tabs.js | 1.5 | 38 |
public/js/toast.js | 1.1 | 35 |
public/js/version.js | 0.1 | 2 |
public/js/voting.js | 16.4 | 432 |
public/style.css | 49.5 | 742 |
public/sw.js | 1.2 | 18 |
Razem: JS — 67 plików, 6609 linii, ~274 KB · CSS — 1 plik, 742 linii, ~49 KB.