// ───────────────────────────────────────────────────────────────────────── // admin.jsx — Comprehensive admin panel with secure login // Tile manager, articles CMS, bank rates editor, FX editor, AI generator, // theme/colors, navigation, footer content, ad slots, inbox, auto-update // ───────────────────────────────────────────────────────────────────────── const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React; const ADMIN_API_BASE = "/api/admin"; let adminCsrfToken = null; async function getAdminCsrf() { if (adminCsrfToken) return adminCsrfToken; const res = await fetch(`${ADMIN_API_BASE}/csrf.php`, { credentials: "include" }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.csrf) { throw new Error(data.error || "Nie udalo sie pobrac tokenu bezpieczenstwa."); } adminCsrfToken = data.csrf; return adminCsrfToken; } async function adminFetch(path, options = {}) { const method = (options.method || "GET").toUpperCase(); const headers = { ...(options.headers || {}) }; if (method !== "GET") { headers["Content-Type"] = "application/json"; headers["X-CSRF-Token"] = await getAdminCsrf(); } const res = await fetch(`${ADMIN_API_BASE}${path}`, { ...options, method, headers, credentials: "include", }); const data = await res.json().catch(() => ({})); if (!res.ok) { throw new Error(data.error || `Blad API (${res.status}).`); } return data; } function isStrongAdminPassword(password) { return password.length >= 16 && /[A-Z]/.test(password) && /[a-z]/.test(password) && /[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password); } function AdminSetup({ onInstalled }) { const [form, setForm] = useStateA({ username: "admin", password: "", confirm: "" }); const [error, setError] = useStateA(""); const [pending, setPending] = useStateA(false); const [showPw, setShowPw] = useStateA(false); const submit = async (e) => { e.preventDefault(); if (pending) return; setError(""); if (!isStrongAdminPassword(form.password)) { setError("Haslo musi miec min. 16 znakow oraz duza litere, mala litere, cyfre i znak specjalny."); return; } if (form.password !== form.confirm) { setError("Hasla nie sa takie same."); return; } setPending(true); try { await adminFetch("/install.php", { method: "POST", body: JSON.stringify({ username: form.username, password: form.password, password_confirm: form.confirm, }), }); onInstalled(form.username); } catch (err) { setError(err.message || "Nie udalo sie utworzyc konta admina."); } finally { setPending(false); } }; return (
Pierwsza konfiguracja

Utworz konto admina

Konto zostanie zapisane w bazie MySQL jako bcrypt + pepper. Po zapisaniu endpoint instalacyjny sam sie zablokuje.

setForm({ ...form, username: e.target.value })} autoComplete="username" required />
setForm({ ...form, password: e.target.value })} autoComplete="new-password" required style={{ paddingRight: 56, fontFamily: "var(--font-mono)" }} />
setForm({ ...form, confirm: e.target.value })} autoComplete="new-password" required style={{ fontFamily: "var(--font-mono)" }} /> {error && (
{error}
)}
); } function AdminLogin({ onLogin, initialUsername = "admin" }) { const [username, setUsername] = useStateA(initialUsername); const [password, setPassword] = useStateA(""); const [totp, setTotp] = useStateA(""); const [totpRequired, setTotpRequired] = useStateA(false); const [error, setError] = useStateA(""); const [pending, setPending] = useStateA(false); const [showPw, setShowPw] = useStateA(false); const [attempts, setAttempts] = useStateA(0); const submit = async (e) => { e.preventDefault(); if (pending) return; setPending(true); setError(""); try { const data = await adminFetch("/login.php", { method: "POST", body: JSON.stringify({ username, password, totp }), }); if (data.totp_required) { setTotpRequired(true); setError("Podaj kod 2FA z aplikacji uwierzytelniajacej."); return; } onLogin(); } catch (err) { setAttempts(a => a + 1); setError(err.message || "Blad uwierzytelniania."); setPassword(""); } finally { setPending(false); } }; return (
Panel admina

KreditEU

Logowanie dziala przez bezpieczna sesje backendu, CSRF i haslo bcrypt zapisane w MySQL.

setUsername(e.target.value)} autoComplete="username" required />
setPassword(e.target.value)} autoComplete="current-password" required style={{ paddingRight: 56, fontFamily: "var(--font-mono)" }} />
{totpRequired && ( setTotp(e.target.value)} inputMode="numeric" autoComplete="one-time-code" placeholder="123456" /> )} {error && (
{error}
)}
{attempts >= 3 && (
Pozostale proby przed blokada IP: {Math.max(0, 5 - attempts)}
)}
); } function AdminPage({ navigate, tiles, setTiles, tweaks, setTweak }) { const [auth, setAuth] = useStateA({ loading: true, authed: false, installed: true, username: "admin", error: "" }); const refreshAuth = async () => { setAuth(a => ({ ...a, loading: true, error: "" })); try { const status = await adminFetch("/status.php"); if (!status.installed) { setAuth({ loading: false, authed: false, installed: false, username: "admin", error: "" }); return; } const me = await adminFetch("/me.php"); setAuth({ loading: false, authed: !!me.authenticated, installed: true, username: me.user?.username || "admin", error: "", }); } catch (err) { setAuth({ loading: false, authed: false, installed: true, username: "admin", error: err.message || "Nie udalo sie sprawdzic sesji." }); } }; useEffectA(() => { refreshAuth(); }, []); if (auth.loading) { return (
Sprawdzam sesje admina...
); } if (!auth.installed) { return setAuth({ loading: false, authed: false, installed: true, username, error: "" })} />; } if (!auth.authed) { return ( <> {auth.error && (
{auth.error}
)} ); } const logout = async () => { try { await adminFetch("/logout.php", { method: "POST", body: "{}" }); } catch {} adminCsrfToken = null; setAuth(a => ({ ...a, authed: false })); }; return ; } function AdminShell({ navigate, tiles, setTiles, tweaks, setTweak, logout }) { const [tab, setTab] = useStateA("dashboard"); const [inbox, setInbox] = useStateA(() => { try { return JSON.parse(localStorage.getItem("kr_inbox") || "[]"); } catch { return []; } }); // Refresh inbox count periodically useEffectA(() => { const sync = () => { try { setInbox(JSON.parse(localStorage.getItem("kr_inbox") || "[]")); } catch {} }; const id = setInterval(sync, 2000); window.addEventListener("storage", sync); return () => { clearInterval(id); window.removeEventListener("storage", sync); }; }, []); const unreadCount = inbox.filter(m => m.status === "new").length; const tabs = [ { k: "dashboard", l: "📊 Dashboard", desc: "Przegląd serwisu" }, { k: "tiles", l: "🧩 Kafelki", desc: "Drag & drop, edycja" }, { k: "articles", l: "📝 Artykuły", desc: "CMS, generator AI obrazów" }, { k: "ai", l: "🤖 Generator AI", desc: "Claude / Gemini" }, { k: "banks", l: "🏦 Oprocentowanie", desc: "Edycja stóp banków" }, { k: "fx", l: "💱 Kursy walut", desc: "NBP / ECB" }, { k: "autoupdate",l: "🔄 Auto-update", desc: "Tygodniowy harmonogram" }, { k: "inbox", l: "📬 Skrzynka", desc: "Wiadomości od użytkowników", badge: unreadCount }, { k: "theme", l: "🎨 Motyw", desc: "Kolory i layout" }, { k: "ads", l: "📢 Reklamy", desc: "Slot AdSense" }, { k: "i18n", l: "🌐 Języki", desc: "PL · EN · DE · FR …" }, { k: "settings", l: "⚙️ Ustawienia", desc: "Hasło, API keys" }, ]; return (
⚙️ Panel admina

Zarządzaj KreditEU

Sesja aktywna
{/* Stats strip */}
0 ? "accent" : "default"} /> 0 ? "warn" : "muted"} />
{/* Layout: sidebar + content */}
{tab === "dashboard" && } {tab === "tiles" && } {tab === "articles" && } {tab === "ai" && setTab("articles")} />} {tab === "banks" && } {tab === "fx" && } {tab === "autoupdate" && } {tab === "inbox" && } {tab === "theme" && } {tab === "ads" && } {tab === "i18n" && } {tab === "settings" && }
); } function AdminStat({ value, label, tone }) { const colors = { accent: { bg: "var(--accent-soft)", text: "var(--accent)" }, warn: { bg: "color-mix(in oklch, var(--warn) 14%, var(--surface))", text: "var(--warn)" }, default: { bg: "var(--surface)", text: "var(--text)" }, muted: { bg: "var(--surface)", text: "var(--text-3)" }, }; const c = colors[tone] || colors.default; return (
{value}
{label}
); } /* ────────── Dashboard ────────── */ function AdminDashboard({ tiles, navigate, setTab, unread = 0 }) { return (
{unread > 0 && (
📬 Nowe wiadomości
Masz {unread} {unread === 1 ? "nieprzeczytaną wiadomość" : "nieprzeczytanych wiadomości"} w skrzynce
)}
{[ { i: "📝", t: "Dodaj artykuł", d: "Ręcznie + zdjęcie AI", a: () => setTab("articles") }, { i: "🧩", t: "Edytuj kafelki", d: "Zmień układ home page", a: () => setTab("tiles") }, { i: "🏦", t: "Zaktualizuj oprocentowanie", d: "Stopy banków", a: () => setTab("banks") }, { i: "🔄", t: "Auto-update", d: "Harmonogram tygodniowy", a: () => setTab("autoupdate") }, { i: "💱", t: "Pobierz kursy NBP", d: "Aktualne dane FX", a: () => setTab("fx") }, { i: "📬", t: "Skrzynka", d: unread > 0 ? `${unread} nowych wiadomości` : "Wszystko przeczytane", a: () => setTab("inbox") }, { i: "🎨", t: "Zmień motyw", d: "Kolory, ciemny/jasny", a: () => setTab("theme") }, { i: "🤖", t: "Generuj AI", d: "Artykuł z Claude/Gemini", a: () => setTab("ai") }, ].map((s, i) => ( ))}
📰 Ostatnia aktualizacja: 12.05.2026 06:00
  • PKO BP obniżył oprocentowanie kredytów hipotecznych o 0.10 pp do 6.49%
  • EBC pozostawił stopy bez zmian — EURIBOR 3M ustabilizował się na 2.61%
  • Santander podwyższył marżę kredytów gotówkowych o 0.15 pp
  • Nowy program: Bezpieczny Kredyt 2% wystartuje od 1 lipca 2026
); } /* ────────── Tiles editor — drag & drop ────────── */ function AdminTiles({ tiles, setTiles }) { const [editing, setEditing] = useStateA(null); const dragIdx = useRefA(null); const handleDragStart = (i) => { dragIdx.current = i; }; const handleDragOver = (e) => e.preventDefault(); const handleDrop = (i) => { if (dragIdx.current === null) return; const next = [...tiles]; const [moved] = next.splice(dragIdx.current, 1); next.splice(i, 0, moved); setTiles(next); dragIdx.current = null; }; const updateTile = (id, patch) => setTiles(tiles.map(t => t.id === id ? { ...t, ...patch } : t)); const deleteTile = (id) => setTiles(tiles.filter(t => t.id !== id)); const addTile = () => { const id = "custom-" + Math.random().toString(36).slice(2, 7); setTiles([...tiles, { id, kind: "ai", size: "md", title: "Nowy kafelek", sub: "Edytuj treść" }]); setEditing(id); }; const resetTiles = () => setTiles([...window.DEFAULT_TILES]); return (
} />
{tiles.map((t, i) => (
handleDragStart(i)} onDragOver={handleDragOver} onDrop={() => handleDrop(i)} style={{ display: "grid", gridTemplateColumns: "auto 1fr auto auto auto auto", gap: 12, alignItems: "center", padding: 14, background: editing === t.id ? "var(--surface-2)" : "var(--surface)", border: "1px solid " + (editing === t.id ? "var(--accent)" : "var(--border-soft)"), borderRadius: 12, cursor: "grab", }} > ⋮⋮
{editing === t.id ? ( updateTile(t.id, { title: e.target.value })} style={{ padding: "6px 10px", fontSize: 13 }} /> ) : (
{t.title}
{t.sub &&
{t.sub}
}
)}
{t.kind}
))}

💡 Zmiany zapisują się automatycznie i widać je na stronie głównej w czasie rzeczywistym.

); } function iconBtn2() { return { width: 32, height: 32, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "var(--bg-2)", border: "1px solid var(--border-soft)", borderRadius: 8, color: "var(--text-2)", cursor: "pointer", fontSize: 12, }; } /* ────────── Inbox (internal messages from contact form) ────────── */ function AdminInbox({ inbox, setInbox }) { const [selected, setSelected] = useStateA(inbox[0] || null); const [filter, setFilter] = useStateA("all"); const [search, setSearch] = useStateA(""); // Sync to localStorage const updateMessages = (next) => { setInbox(next); localStorage.setItem("kr_inbox", JSON.stringify(next)); }; const markStatus = (id, status) => { updateMessages(inbox.map(m => m.id === id ? { ...m, status } : m)); if (selected && selected.id === id) setSelected({ ...selected, status }); }; const deleteMessage = (id) => { if (!confirm("Usunąć wiadomość?")) return; const next = inbox.filter(m => m.id !== id); updateMessages(next); if (selected && selected.id === id) setSelected(next[0] || null); }; const insertDemo = () => { const demos = [ { id: Date.now() + 1, name: "Anna Kowalska", email: "anna@example.com", topic: "general", msg: "Dzień dobry, mam pytanie odnośnie kalkulatora hipotecznego. Jak wprowadzić oprocentowanie zmienne? Pozdrawiam.", date: new Date().toISOString(), status: "new" }, { id: Date.now() + 2, name: "Tomasz Nowak", email: "tomasz.nowak@firma.pl", topic: "ads", msg: "Witam, jesteśmy zainteresowani współpracą reklamową na Państwa stronie. Prosimy o kontakt zwrotny w sprawie cennika.", date: new Date(Date.now() - 3600000).toISOString(), status: "new" }, { id: Date.now() + 3, name: "Piotr Wiśniewski", email: "p.wisniewski@gmail.com", topic: "bug", msg: "Kalkulator nieprawidłowo przelicza ratę dla okresu 30 lat. Sprawdziłem w Excelu i wychodzą inne wartości.", date: new Date(Date.now() - 86400000).toISOString(), status: "read" }, ]; const next = [...demos, ...inbox]; updateMessages(next); setSelected(demos[0]); }; const filtered = inbox.filter(m => { if (filter !== "all" && m.status !== filter) return false; if (search && !(m.name.toLowerCase().includes(search.toLowerCase()) || m.email.toLowerCase().includes(search.toLowerCase()) || m.msg.toLowerCase().includes(search.toLowerCase()))) return false; return true; }); const topicLabels = { general: "Pytanie ogólne", bug: "Zgłoszenie błędu", ads: "Reklama / partnerstwo", rodo: "Usunięcie danych (RODO)", }; return (
} />
{/* List */}
setSearch(e.target.value)} style={{ flex: "1 1 100%", padding: "8px 12px", fontSize: 12, marginBottom: 8 }} /> {[["all", "Wszystkie", inbox.length], ["new", "Nowe", inbox.filter(m => m.status === "new").length], ["read", "Przeczytane", inbox.filter(m => m.status === "read").length], ["replied", "Odpowiedziane", inbox.filter(m => m.status === "replied").length]].map(([k, l, n]) => ( ))}
{filtered.length === 0 && (
📭
{inbox.length === 0 ? "Skrzynka pusta" : "Brak wyników"}
{inbox.length === 0 &&
Wiadomości z formularza pojawią się tu automatycznie.
}
)} {filtered.map(m => ( ))}
{/* Preview */}
{!selected ? (
✉️
Wybierz wiadomość, aby ją przeczytać
) : ( <>
{topicLabels[selected.topic] || selected.topic}

{selected.name}

{selected.email} {new Date(selected.date).toLocaleString("pl-PL")}
{selected.status === "new" ? "🟡 Nowa" : selected.status === "read" ? "🔵 Przeczytana" : "🟢 Odpowiedziana"}
{selected.msg}
markStatus(selected.id, "replied")}> ↩ Odpowiedz przez email {selected.status !== "replied" && ( )} {selected.status === "new" && ( )}
)}
); } function formatRel(iso) { const ms = Date.now() - new Date(iso).getTime(); const m = Math.floor(ms / 60000); if (m < 1) return "teraz"; if (m < 60) return m + " min"; const h = Math.floor(m / 60); if (h < 24) return h + " godz."; const d = Math.floor(h / 24); if (d < 7) return d + " dni"; return new Date(iso).toLocaleDateString("pl-PL"); } /* ────────── Auto-update of bank rates and FX ────────── */ function AdminAutoUpdate() { const [running, setRunning] = useStateA(false); const [log, setLog] = useStateA([ { time: "2026-05-12 06:00:14", msg: "✅ Pobrano kursy NBP — 6 par zaktualizowanych", ok: true }, { time: "2026-05-12 06:00:18", msg: "✅ EURIBOR / ECB — pobrano z api.ecb.europa.eu", ok: true }, { time: "2026-05-12 06:00:25", msg: "✅ WIBOR 3M zaktualizowany do 5.86%", ok: true }, { time: "2026-05-12 06:01:02", msg: "✅ Banki PL — 10 cenników odczytanych", ok: true }, { time: "2026-05-12 06:01:38", msg: "✅ Banki EU — 96 cenników odczytanych", ok: true }, { time: "2026-05-12 06:01:42", msg: "📬 Raport wysłany na kontakt@krediteu.com", ok: true }, ]); const [schedule, setSchedule] = useStateA({ enabled: true, frequency: "weekly", day: "monday", hour: "06:00", sources: { nbp: true, ecb: true, banks: true, ai_news: true }, }); const appendLog = (msg, ok = true) => { const now = new Date().toLocaleTimeString("pl-PL"); setLog(l => [{ time: now, msg, ok }, ...l]); }; const runNow = async () => { setRunning(true); const steps = [ "Lacze z api.nbp.pl...", "Lacze z ECB / Stooq...", "Sprawdzam stopy referencyjne...", "Przeliczam oprocentowanie bankow z aktualnych benchmarkow...", ]; steps.forEach((step, idx) => { window.setTimeout(() => appendLog(step, true), idx * 180); }); try { const data = await adminFetch("/refresh.php", { method: "POST", body: "{}" }); const fx = data.results?.fx; const refs = data.results?.reference_rates; const banks = data.results?.bank_rates; appendLog(`OK: kursy walut ${fx?.ok ? "pobrane" : "sprawdzone"} (${fx?.elapsed ?? 0}s)`, !!fx?.ok); appendLog(`OK: stopy referencyjne ${refs?.ok ? "pobrane" : "sprawdzone"} (${refs?.elapsed ?? 0}s)`, !!refs?.ok); appendLog(`OK: przeliczono ${banks?.updated ?? 0} stawek bankowych`, true); appendLog("Aktualizacja zakonczona. Odswiez ranking bankow, aby zobaczyc nowe wartosci.", true); } catch (err) { appendLog(`Blad aktualizacji: ${err.message || "nieznany blad"}`, false); } finally { setRunning(false); } }; const next = useMemoA(() => { const now = new Date(); const day = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }[schedule.day]; const next = new Date(now); let diff = (day - now.getDay() + 7) % 7 || 7; next.setDate(now.getDate() + diff); const [h, m] = schedule.hour.split(":"); next.setHours(+h, +m, 0, 0); return next.toLocaleString("pl-PL", { dateStyle: "medium", timeStyle: "short" }); }, [schedule]); return (
{running ? "⏳ Aktualizuję…" : "⚡ Uruchom teraz"} } /> {/* Schedule card */}
Status
{schedule.enabled ? "Aktywny" : "Wstrzymany"}
Ostatnia aktualizacja
12.05.2026 06:00
Następna aktualizacja
{next}
Częstotliwość
1× w tygodniu
{/* Settings */}

⚙️ Harmonogram

setSchedule({ ...schedule, hour: e.target.value })} style={{ fontFamily: "var(--font-mono)" }} />

📡 Źródła danych

{[ ["nbp", "Kursy NBP", "api.nbp.pl/api/exchangerates"], ["ecb", "ECB Data", "data.ecb.europa.eu/api"], ["banks", "Cenniki banków", "scraper (10 PL + 96 EU)"], ["ai_news", "AI podsumowanie", "Claude / Gemini"], ].map(([k, name, src]) => ( ))}
{/* Log */}

📜 Log aktualizacji

{log.map((l, i) => (
{l.time} {l.msg}
))}
{/* Tech setup */}

🛠️ Jak to działa technicznie

W produkcji harmonogram realizuje cron job, który uruchamia skrypt aktualizacyjny w wybrane dni i godziny. Skrypt pobiera dane z API NBP/ECB i parsuje cenniki banków, a następnie zapisuje do bazy.

{`# /etc/cron.d/krediteu
# Co poniedziałek o 06:00 — aktualizacja wszystkiego
0 6 * * 1   www-data  /usr/local/bin/krediteu-update.sh

# /usr/local/bin/krediteu-update.sh
#!/bin/bash
node /var/www/krediteu/scripts/fetch-nbp.js
node /var/www/krediteu/scripts/fetch-ecb.js
node /var/www/krediteu/scripts/scrape-banks.js
node /var/www/krediteu/scripts/ai-summary.js
curl -X POST https://krediteu.com/api/admin/refresh \\
     -H "X-API-Key: $KREDIT_API_KEY"`}
); } function AdminArticles({ navigate }) { const emptyForm = { id: null, title: "", cat: "Kredyt hipoteczny", desc: "", body: "", img: "", imgAlt: "", aiPrompt: "" }; const [form, setForm] = useStateA(emptyForm); const [list, setList] = useStateA(() => readStoredArticles()); const [aiGen, setAiGen] = useStateA(false); const [preview, setPreview] = useStateA(false); const [notice, setNotice] = useStateA(""); const fileRef = useRefA(null); useEffectA(() => { const loadDraft = () => { const raw = localStorage.getItem("kr_admin_article_draft"); if (!raw) return; try { const draft = JSON.parse(raw); setForm(f => ({ ...f, ...draft, id: null })); setPreview(true); setNotice("Wczytano artykul z generatora AI. Sprawdz tresc, dodaj zdjecie i opublikuj."); localStorage.removeItem("kr_admin_article_draft"); } catch (err) { setNotice("Nie udalo sie wczytac szkicu z generatora AI."); } }; loadDraft(); window.addEventListener("kr-admin-article-draft", loadDraft); return () => window.removeEventListener("kr-admin-article-draft", loadDraft); }, []); function persist(next) { setList(next); localStorage.setItem("kr_admin_articles", JSON.stringify(next)); window.ARTICLES = mergeArticleLists(next, window.ARTICLES || []); } const publish = () => { if (!form.title.trim()) { setNotice("Dodaj tytul artykulu przed publikacja."); return; } const existing = list.find(a => a.id === form.id); const article = buildArticle(form, existing?.id || nextArticleId(list)); const next = existing ? list.map(a => a.id === article.id ? article : a) : [article, ...list]; persist(next); setForm(emptyForm); setPreview(false); setNotice("Artykul opublikowany. Otwieram podglad na stronie."); navigate?.({ name: "article", id: article.id }); }; const editArticle = (article) => { setForm({ id: article.id, title: article.title || "", cat: article.cat || article.tag || "Kredyt hipoteczny", desc: article.excerpt || "", body: article.body || "", img: article.img || "", imgAlt: article.imgAlt || "", aiPrompt: article.aiPrompt || "", }); setPreview(true); setNotice("Tryb edycji: po poprawkach kliknij Opublikuj zmiany."); }; const removeArticle = (article) => { if (!article.custom) { setNotice("Artykuly systemowe nie sa usuwane z panelu lokalnego. Mozesz edytowac tylko nowe wpisy."); return; } persist(list.filter(x => x.id !== article.id)); }; const generateAiImage = () => { setAiGen(true); window.setTimeout(() => { setAiGen(false); setForm(f => ({ ...f, img: randomGradient(f.aiPrompt || f.title || "kredyt") })); }, 700); }; const randomStock = () => { const id = Math.floor(Math.random() * 1000); setForm(f => ({ ...f, img: "center/cover url(https://picsum.photos/seed/krediteu-" + id + "/1200/720)" })); }; const handleUpload = (e) => { const file = e.target.files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => setForm(f => ({ ...f, img: "center/cover url(" + ev.target.result + ")" })); reader.readAsDataURL(file); }; return (
{notice && (
{notice}
)}

{form.id ? "Edytuj artykul" : "Nowy artykul"}

setForm({ ...form, title: e.target.value })} placeholder="np. Jak obnizyc rate kredytu w 2026 r." />