# Admin rozhranie + Formspree pre HTML stránky
## Kompletný návod na základe Computer Club SK projektu

---

## Čo tento systém robí

Statická HTML stránka (bez backendu) s plnohodnotným CMS admin rozhraním:

- **`index.html`** — verejná stránka, načítava obsah z `content.json`
- **`content.json`** — všetok editovateľný obsah (texty, obrázky, URL)
- **`admin.html`** — admin panel, ukladá zmeny priamo na GitHub cez API
- **Cloudflare Pages** — hosting, automatický deploy pri každom commite
- **Formspree** — príjem správ z kontaktného formulára

---

## Čo Claude potrebuje od teba

Pred začatím práce mu daj:

1. **HTML súbor** stránky ktorú chceš upraviť
2. **GitHub repozitár** kde bude kód (napr. `username/nazov-repo`)
3. **GitHub Fine-grained token** s oprávnením `Contents → Read and write`
4. **Formspree endpoint** (`https://formspree.io/f/xxxxxxxx`)
5. **Email** na ktorý majú chodiť správy z formulára

---

## Ako to funguje — architektúra

```
Návštevník → index.html → načíta content.json → zobrazí obsah
Admin → admin.html → GitHub API → uloží content.json → Cloudflare deploy → live stránka
Formulár → Formspree API → email
```

### Prečo content.json a nie priamo v HTML?

Keby boli texty priamo v HTML, každá zmena by vyžadovala editáciu kódu. Takto:
- Admin edituje obsah cez pekné rozhranie
- Obsah je oddelený od kódu
- Žiadna databáza, žiadny server — len súbory

---

## KROK 1 — Pripraviť content.json

### Čo patrí do content.json

**Všetok obsah ktorý sa mení** — texty, nadpisy, URL obrázkov, kontakty:

```json
{
  "meta": {
    "title": "Názov stránky – popis | Mesto",
    "description": "Meta description pre Google...",
    "og_title": "Názov pre Facebook/LinkedIn",
    "og_description": "Popis pre sociálne siete",
    "canonical_url": "https://www.vasadomena.sk/",
    "logo_src": "/images/logo.webp",
    "logo_alt": "Názov firmy logo"
  },
  "contact": {
    "phone": "+421900000000",
    "phone_display": "0900 000 000",
    "email": "info@firma.sk",
    "address_street": "Ulica 1",
    "address_city": "Mesto",
    "address_zip": "000 00",
    "opening_hours": "Po–Pi 8:00–17:00",
    "facebook_url": "https://www.facebook.com/...",
    "maps_url": "https://maps.google.com/?q=...",
    "google_reviews_url": "https://www.google.com/maps/...",
    "footer_copy": "© 2024 Firma s.r.o."
  },
  "hero": {
    "badge": "Malý text nad nadpisom",
    "heading_line1": "Prvý riadok nadpisu,",
    "heading_line2": "druhý riadok (kurzíva)",
    "heading_line3": "tretí riadok",
    "subtext": "Perex pod nadpisom...",
    "cta_primary_text": "📞 Zavolajte nám",
    "cta_secondary_text": "✉ Napísať dopyt",
    "trust_items": ["Item 1", "Item 2", "Item 3"],
    "image_src": "/images/hero.webp",
    "image_alt": "Popis obrázka"
  },
  "services": { ... },
  "products": { ... },
  "reviews": { ... },
  "faq": { ... },
  "contact_section": {
    "form_submit_text": "Odoslať správu",
    "form_success_text": "✅ Správa odoslaná! Ozveme sa čo najskôr."
  }
}
```

**Čo NEpatrí do content.json:** navigácia, CSS štýly, HTML štruktúra, JavaScript logika.

---

## KROK 2 — Upraviť index.html

### 2a. Pridať sanitizačné helpery na začiatok scriptu

```javascript
// Escapuje text vkladaný do innerHTML — chráni pred XSS
function s(str) {
  if (str === null || str === undefined) return '';
  return String(str)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#x27;');
}

// Bezpečná URL — blokuje javascript:, data:, // a iné nebezpečné protokoly
// Povoľuje: https://, http://, tel:, mailto:, relatívne cesty /images/...
function safeUrl(url) {
  if (!url) return '#';
  const u = String(url).trim();
  if (/^(https?:|tel:|mailto:|\/[^/])/.test(u)) return u;
  if (u === '/') return u;
  return '#';
}
```

> ⚠️ **Dôležité:** `safeUrl` musí povoľovať aj relatívne cesty `/images/...` inak sa obrázky nahrané cez admin nezobrazia.

### 2b. Načítanie content.json

```javascript
async function loadContent() {
  try {
    const res = await fetch('content.json?v=' + Date.now());
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const d = await res.json();
    applyContent(d);
  } catch(e) {
    console.error('Failed to load content.json:', e);
  }
}
```

`?v=' + Date.now()` zabraňuje cache-ovaniu — stránka vždy načíta najnovší content.json.

### 2c. Funkcia applyContent — pravidlá

```javascript
function applyContent(d) {
  // textContent pre jednoduché texty — NIKDY nepoužívaj innerHTML pre plain text
  document.getElementById('hero-badge').textContent = d.hero.badge;

  // innerHTML len pre HTML obsah — VŽDY escapuj cez s()
  document.getElementById('hero-heading').innerHTML =
    `${s(d.hero.heading_line1)}<br><em>${s(d.hero.heading_line2)}</em>`;

  // href VŽDY cez safeUrl()
  document.getElementById('header-phone').href = safeUrl('tel:' + d.contact.phone);

  // src obrázkov VŽDY cez safeUrl()
  document.getElementById('hero-image').src = safeUrl(d.hero.image_src);

  // Fallback ak obrázok chýba
  heroImg.onerror = () => heroImg.closest('.hero-img').style.display = 'none';
}
```

**Pravidlo:** Každá hodnota z content.json ktorá ide do `innerHTML` musí byť obalená v `s()`. Každá URL musí ísť cez `safeUrl()`.

### 2d. Formspree formulár

```html
<form id="cf" action="https://formspree.io/f/XXXXXXXX" method="POST" onsubmit="hf(event)">
  <!-- Honeypot — zachytí boty -->
  <input type="text" name="_gotcha" style="display:none">
  
  <!-- Polia — name= atribút určuje názov v emaili -->
  <input type="text" name="meno" required placeholder="Ján Novák">
  <input type="email" name="email" required placeholder="jan@firma.sk">
  <input type="tel" name="telefon" placeholder="+421 9xx xxx xxx">
  <textarea name="sprava" required></textarea>
  
  <button type="submit">Odoslať správu</button>
</form>
<div id="fs" style="display:none">✅ Správa odoslaná!</div>
```

```javascript
// AJAX submit — zostane na stránke, zobrazí potvrdenie
async function hf(e) {
  e.preventDefault();
  const form = e.target;
  const btn = form.querySelector('button[type="submit"]');
  const successEl = document.getElementById('fs');
  
  btn.textContent = 'Odosielam...';
  btn.disabled = true;
  
  try {
    const res = await fetch(form.action, {
      method: 'POST',
      body: new FormData(form),
      headers: { Accept: 'application/json' }
    });
    if (res.ok) {
      form.reset();
      form.style.display = 'none';
      successEl.style.display = 'block';
    } else {
      throw new Error('Chyba odoslania');
    }
  } catch(err) {
    btn.textContent = '❌ Chyba – skúste znova';
    btn.disabled = false;
  }
}
```

> ⚠️ **Prvé odoslanie:** Formspree pošle aktivačný email na adresu účtu. Treba kliknúť na potvrdenie, až potom začnú chodiť správy.

### 2e. Google Maps iframe

```html
<iframe
  src="https://www.google.com/maps/embed?pb=!1m18!..."
  width="100%" height="280"
  style="border:0;display:block;border-radius:12px;"
  allowfullscreen="" loading="lazy"
  referrerpolicy="no-referrer-when-downgrade"
  title="Názov firmy – adresa">
</iframe>
```

Embed URL získaš na `maps.google.com` → nájdi miesto → Zdieľať → Vložiť mapu → skopíruj `src=` z iframe.

---

## KROK 3 — Vytvoriť admin.html

### 3a. Bezpečnostné hlavičky

```html
<meta http-equiv="Content-Security-Policy"
  content="default-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com https://api.github.com;
           script-src 'self' 'unsafe-inline';
           style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
           img-src * data:;
           connect-src https://api.github.com;">
<meta name="referrer" content="no-referrer">
<meta name="robots" content="noindex, nofollow">
```

### 3b. Konfigurácia (hardcoded — repozitár musí byť PRIVATE)

```javascript
// Repozitár je private + admin chránený Cloudflare Access OAuth2
const GH_TOKEN = 'github_pat_11XXXXXXXXX...';  // Fine-grained token
const GH_REPO  = 'username/nazov-repo';
const GH_PATH  = 'content.json';
let CONTENT = null;
let GH_SHA  = '';
let hasUnsavedChanges = false;
```

> ⚠️ **Token nikdy neukladaj do public repozitára.** Repozitár musí byť private.

### 3c. Načítanie content.json z GitHubu (UTF-8 safe)

```javascript
async function fetchContentFromGitHub() {
  const res = await fetch(
    `https://api.github.com/repos/${GH_REPO}/contents/${GH_PATH}`,
    { headers: {
        Authorization: 'Bearer ' + GH_TOKEN,
        Accept: 'application/vnd.github.v3+json',
        'X-GitHub-Api-Version': '2022-11-28'
    }}
  );
  if (res.status === 401) throw new Error('Neplatný token');
  if (res.status === 404) throw new Error('Repozitár alebo súbor nenájdený');
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  
  const data = await res.json();
  GH_SHA = data.sha;
  
  // DÔLEŽITÉ: atob() nie je UTF-8 safe — použiť TextDecoder
  const binary = atob(data.content.replace(/\n/g, ''));
  const bytes = Uint8Array.from(binary, c => c.charCodeAt(0));
  CONTENT = JSON.parse(new TextDecoder('utf-8').decode(bytes));
}
```

> ⚠️ **UTF-8 problém:** `atob()` nesprávne dekóduje diakritiku (č, š, ž...). Vždy použi `TextDecoder('utf-8')`.

### 3d. Uloženie content.json na GitHub

```javascript
async function saveContent() {
  collectAllFormData(); // zozbiera hodnoty z formulárových polí do CONTENT
  
  const jsonStr = JSON.stringify(CONTENT, null, 2);
  
  // DÔLEŽITÉ: TextEncoder pre správne UTF-8 enkódovanie
  const encBytes = new TextEncoder().encode(jsonStr);
  const encBinary = Array.from(encBytes).map(b => String.fromCharCode(b)).join('');
  const encoded = btoa(encBinary);
  
  const res = await fetch(
    `https://api.github.com/repos/${GH_REPO}/contents/${GH_PATH}`,
    {
      method: 'PUT',
      headers: {
        Authorization: 'Bearer ' + GH_TOKEN,
        'Content-Type': 'application/json',
        'X-GitHub-Api-Version': '2022-11-28'
      },
      body: JSON.stringify({
        message: 'Admin: aktualizácia obsahu stránky',
        content: encoded,
        sha: GH_SHA  // POVINNÉ — bez tohto dostaneš 409 Conflict
      })
    }
  );
  
  if (res.status === 409) throw new Error('Konflikt SHA — obnovte stránku');
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  
  const result = await res.json();
  GH_SHA = result.content.sha; // aktualizuj SHA pre ďalší save
}
```

> ⚠️ **SHA konflikt:** GitHub vyžaduje SHA aktuálnej verzie súboru. Po každom nahratí obrázku (nový commit) treba obnoviť SHA volaním `refreshSHA()`, inak dostaneš 409 Conflict pri ďalšom uložení.

### 3e. Obnovenie SHA po nahratí obrázku

```javascript
async function refreshSHA() {
  try {
    const res = await fetch(
      `https://api.github.com/repos/${GH_REPO}/contents/${GH_PATH}`,
      { headers: { Authorization: 'Bearer ' + GH_TOKEN, 'X-GitHub-Api-Version': '2022-11-28' }}
    );
    if (res.ok) {
      const data = await res.json();
      GH_SHA = data.sha;
    }
  } catch(e) {
    console.warn('SHA refresh failed:', e);
  }
}
```

### 3f. Upload obrázkov → WebP konverzia → GitHub

```javascript
// 1. Vyber súbor
function triggerUpload(inputId, previewId) {
  const fileInput = document.createElement('input');
  fileInput.type = 'file';
  fileInput.accept = 'image/*';
  fileInput.onchange = e => convertToWebP(e.target.files[0], inputId, previewId);
  fileInput.click();
}

// 2. Konvertuj na WebP cez Canvas API (max 1600px, kvalita 85%)
function convertToWebP(file, inputId, previewId) {
  const reader = new FileReader();
  reader.onload = evt => {
    const img = new Image();
    img.onload = () => {
      const MAX = 1600;
      let w = img.width, h = img.height;
      if (w > MAX) { h = Math.round(h * MAX / w); w = MAX; }
      
      const canvas = document.createElement('canvas');
      canvas.width = w; canvas.height = h;
      canvas.getContext('2d').drawImage(img, 0, 0, w, h);
      
      canvas.toBlob(blob => {
        uploadToGitHub(blob, file.name, inputId, previewId);
      }, 'image/webp', 0.85);
    };
    img.src = evt.target.result;
  };
  reader.readAsDataURL(file);
}

// 3. Nahraj na GitHub do /images/ priečinka
async function uploadToGitHub(blob, originalName, inputId, previewId) {
  const baseName = originalName.replace(/\.[^.]+$/, '').replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
  const fileName = `images/${baseName}-${Date.now()}.webp`;
  
  const arrayBuffer = await blob.arrayBuffer();
  const bytes = new Uint8Array(arrayBuffer);
  const base64 = btoa(Array.from(bytes).map(b => String.fromCharCode(b)).join(''));
  
  await fetch(`https://api.github.com/repos/${GH_REPO}/contents/${fileName}`, {
    method: 'PUT',
    headers: { Authorization: 'Bearer ' + GH_TOKEN, 'Content-Type': 'application/json' },
    body: JSON.stringify({ message: `Upload: ${fileName}`, content: base64 })
  });
  
  // KRITICKÉ: obnoviť SHA po každom upload commite
  await refreshSHA();
  
  // Relatívna URL — funguje na akejkoľvek deploy URL
  const url = `/${fileName}`;
  document.getElementById(inputId).value = url;
  document.getElementById(previewId).src = url;
}
```

> ⚠️ **Relatívna URL pre obrázky:** Vždy ulož `/images/nazov.webp` nie absolútnu `https://xyz.pages.dev/images/nazov.webp`. Cloudflare Pages generuje unikátne hash URL pre každý deploy — absolútna URL by sa zlomila.

### 3g. Sanitizácia URL obrázkov pred uložením

```javascript
// Konvertuje absolútnu pages.dev URL na relatívnu cestu
function sanitizeImageUrl(url) {
  if (!url) return '';
  const m = url.match(/https?:\/\/[^/]*pages\.dev(\/.*)/);
  if (m) return m[1];
  return url;
}

// Použiť pri collectAllFormData pre každé image_src pole:
CONTENT.hero.image_src = sanitizeImageUrl(val('hero-img'));
```

---

## KROK 4 — GitHub nastavenia

### Fine-grained Personal Access Token

1. `github.com/settings/personal-access-tokens/new`
2. **Token name:** `[NazovProjektu] Admin`
3. **Repository access:** Only select repositories → vyber repozitár
4. **Permissions → Contents:** Read and write
5. Skopíruj token — zobrazí sa len raz

### Repozitár musí byť Private

```
GitHub → Settings → Danger Zone → Change visibility → Make private
```

### Cloudflare Pages Deploy Hook

Po každom uložení cez admin treba spustiť production deploy:

```
Cloudflare Pages → Settings → Build → Deploy Hooks → Add deploy hook
→ Hook name: "Admin deploy" → Branch: main → Save → skopíruj URL
```

```javascript
// Zavolaj po úspešnom uložení content.json
fetch('https://api.cloudflare.com/client/v4/pages/webhooks/deploy_hooks/XXXX',
  { method: 'POST' }).catch(() => {});
```

---

## KROK 5 — Cloudflare Pages nastavenia

### _redirects súbor

Cloudflare Pages automaticky servuje `index.html` pre `/` a `admin.html` pre `/admin`. **Nepotrebuješ žiadne redirect pravidlá** — prázdny alebo neexistujúci `_redirects` je správny.

> ⚠️ Pravidlo `/ /index.html 200` spôsobuje infinite loop a Cloudflare ho ignoruje.

### Ochrana admin.html cez Cloudflare Access (OAuth2)

```
Cloudflare Dashboard → Zero Trust → Access → Applications
→ Add application → Self-hosted
→ Application domain: vasadomena.pages.dev/admin.html
→ Policy: Allow → Include: Emails → zadaj svoj email
```

Po nastavení Cloudflare Access sa pri otvorení `/admin.html` zobrazí prihlasovacia stránka (email OTP alebo Google/GitHub OAuth). Len ty sa dostaneš dovnútra.

---

## KROK 6 — Formspree nastavenie

### Vytvorenie formulára

1. `formspree.io/register` → vytvor účet
2. New Form → zadaj email na doručenie správ
3. Skopíruj endpoint: `https://formspree.io/f/xxxxxxxx`

### Aktivácia

Pri **prvom odoslaní** z novej domény príde aktivačný email. Klikni na potvrdenie — až potom chodia správy.

### Overenie odoslaní

`formspree.io/forms/XXXXXXXX/submissions` — história všetkých odoslaní.

---

## Časté chyby a riešenia

| Chyba | Príčina | Riešenie |
|-------|---------|----------|
| Obrázky sa nezobrazujú | `safeUrl()` blokuje `/images/...` | Pridaj `\/[^/]` do regex v safeUrl |
| 409 Conflict pri uložení | SHA je zastaraná po upload commite | Volaj `refreshSHA()` po každom upload |
| Diakritika sa zobrazuje ako `Ä½Â¾` | `atob()` nie je UTF-8 safe | Použi `TextDecoder('utf-8')` |
| Admin vracia ERR_TOO_MANY_REDIRECTS | Zlé pravidlo v `_redirects` | Vyprázdni alebo zmaž `_redirects` |
| Obrázok je 404 po deployi | Absolútna hash URL (`13f422aa.pages.dev/...`) | Ulož relatívnu cestu `/images/...` |
| Formulár odošle ale správa nepríde | Formspree nepotvrdený | Skontroluj email, potvrď aktiváciu |
| Admin nefunguje na mobile | Sidebar skrytý na úzkej obrazovke | Normálne — admin je desktop only |

---

## Bezpečnostné zásady

- **Token vždy v private repozitári** — nikdy v public
- **Admin chránený Cloudflare Access** — OAuth2 pred HTML súborom
- **`s()` na všetok obsah z content.json** vkladaný do innerHTML
- **`safeUrl()` na všetky href a src** z content.json
- **`robots: noindex`** na admin.html — nevystupuje vo vyhľadávačoch
- **Fine-grained token** s oprávnením len na jeden repozitár a len Contents R/W

---

## Zhrnutie — čo dáš Claudovi

```
1. index.html     ← stránka ktorú chceš upraviť
2. GitHub repozitár (username/repo) ← kde bude kód
3. GitHub token   ← Fine-grained, Contents R/W
4. Formspree endpoint ← https://formspree.io/f/xxxxxxxx
5. Email pre formulár ← kam chodia správy
6. Cloudflare Deploy Hook URL ← z Pages → Settings → Deploy Hooks
```

Claude na základe týchto vstupov:
1. Extrahuje všetok editovateľný obsah do `content.json`
2. Upraví `index.html` aby načítaval obsah dynamicky
3. Vytvorí `admin.html` s plným CMS rozhraním
4. Zapojí Formspree formulár
5. Pridá WebP upload obrázkov

---

*Vytvorené na základe projektu Computer Club SK — compclubsk.pages.dev*
