Files
tykkaa.fi/admin.html
Jukka Lampikoski 74c31f898c Add subcategory management to admin panel + Kala/Liha defaults
Categories now show their subcategories as tags with remove buttons,
plus an input field to add new subcategories directly from admin.
Added Kala and Liha as default subcategories for Reseptit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:48:01 +02:00

919 lines
38 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hallinta / Admin — tykkää.fi</title>
<link rel="stylesheet" href="style.css" />
<style>
body { background: #f0e8dc; }
.admin-header {
background: #3b2a1a;
padding: 20px 32px;
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.admin-header .logo { gap: 10px; }
.admin-header h1 { font-size: 1.4rem; color: #fff; }
.admin-header a { color: #e8b88a; font-family: Arial, sans-serif; font-size: 0.9rem; text-decoration: none; }
.admin-header a:hover { color: #fff; }
.admin-main {
display: grid;
grid-template-columns: 1fr 1.4fr;
gap: 28px;
max-width: 1200px;
margin: 36px auto;
padding: 0 24px;
}
@media (max-width: 860px) { .admin-main { grid-template-columns: 1fr; } }
.admin-panel {
background: #fff9f2;
border: 1px solid #e8d5c0;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 16px rgba(124,74,30,0.1);
margin-bottom: 24px;
}
.admin-panel h2 {
color: #7c4a1e;
font-size: 1.2rem;
margin-bottom: 18px;
padding-bottom: 10px;
border-bottom: 2px solid #e8d5c0;
}
.form-group { margin-bottom: 14px; }
.form-group label {
display: block;
font-family: Arial, sans-serif;
font-size: 0.8rem;
font-weight: bold;
color: #7a5c3e;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 5px;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 9px 13px;
border: 2px solid #e8d5c0;
border-radius: 8px;
font-size: 0.95rem;
font-family: Georgia, serif;
background: #fff;
color: #3b2a1a;
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: #e07b39; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
.type-toggle { display: flex; gap: 10px; margin-bottom: 14px; }
.type-btn {
flex: 1;
padding: 10px;
border: 2px solid #e8d5c0;
border-radius: 8px;
background: #fff;
color: #7a5c3e;
font-family: Arial, sans-serif;
font-size: 0.88rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.type-btn.active { background: #e07b39; border-color: #e07b39; color: #fff; }
.recipe-fields, .post-fields { display: none; }
.recipe-fields.visible, .post-fields.visible { display: block; }
.dynamic-list { display: flex; flex-direction: column; gap: 7px; }
.dynamic-item { display: flex; gap: 8px; align-items: center; }
.dynamic-item input { flex: 1; margin: 0; }
.remove-btn {
background: none; border: none; color: #c0856a;
font-size: 1.1rem; cursor: pointer; padding: 4px 8px;
border-radius: 6px; transition: all 0.2s; flex-shrink: 0;
}
.remove-btn:hover { background: #fde8d0; color: #c4612a; }
.add-item-btn {
background: none; border: 2px dashed #c28b5a; border-radius: 8px;
color: #c28b5a; font-family: Arial, sans-serif; font-size: 0.85rem;
padding: 7px; cursor: pointer; width: 100%; transition: all 0.2s; margin-top: 5px;
}
.add-item-btn:hover { background: #fde8d0; border-color: #e07b39; color: #e07b39; }
.save-btn {
width: 100%; padding: 13px; background: #e07b39; color: #fff;
border: none; border-radius: 10px; font-family: Arial, sans-serif;
font-size: 1rem; font-weight: bold; cursor: pointer; transition: all 0.2s; margin-top: 8px;
}
.save-btn:hover { background: #c4612a; transform: translateY(-2px); }
/* Post list */
.post-list { display: flex; flex-direction: column; gap: 10px; }
.post-item {
background: #fff; border: 1px solid #e8d5c0; border-radius: 10px;
padding: 13px 16px; display: flex; align-items: center; gap: 12px;
}
.post-item-emoji { font-size: 1.8rem; }
.post-item-info { flex: 1; }
.post-item-info h3 { font-size: 0.95rem; color: #7c4a1e; margin-bottom: 2px; }
.post-item-info p { font-size: 0.78rem; color: #7a5c3e; font-family: Arial, sans-serif; }
.post-item-actions { display: flex; gap: 7px; flex-shrink: 0; }
.edit-btn {
padding: 6px 12px; background: none; border: 2px solid #c28b5a;
border-radius: 7px; color: #7c4a1e; font-family: Arial, sans-serif;
font-size: 0.8rem; cursor: pointer; transition: all 0.2s;
}
.edit-btn:hover { background: #fde8d0; }
.delete-btn {
padding: 6px 12px; background: none; border: 2px solid #e8c0c0;
border-radius: 7px; color: #c04040; font-family: Arial, sans-serif;
font-size: 0.8rem; cursor: pointer; transition: all 0.2s;
}
.delete-btn:hover { background: #fde8e8; border-color: #c04040; }
/* Category management */
.cat-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.cat-item {
display: flex; align-items: center; gap: 10px;
background: #fff; border: 1px solid #e8d5c0; border-radius: 8px; padding: 10px 14px;
}
.cat-emoji { font-size: 1.4rem; }
.cat-info { flex: 1; font-family: Arial, sans-serif; font-size: 0.88rem; color: #3b2a1a; }
.cat-info span { color: #7a5c3e; font-size: 0.78rem; }
/* Subcategory tags */
.subcat-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; padding-left: 38px; }
.subcat-tag {
display: inline-flex; align-items: center; gap: 4px;
background: #f5ece0; color: #7c4a1e; padding: 3px 10px; border-radius: 12px;
font-family: Arial, sans-serif; font-size: 0.78rem; font-weight: bold;
}
.subcat-remove {
background: none; border: none; color: #c0856a; cursor: pointer;
font-size: 0.7rem; padding: 0 2px; line-height: 1;
}
.subcat-remove:hover { color: #c04040; }
/* User list */
.user-table { width: 100%; border-collapse: collapse; font-family: Arial, sans-serif; font-size: 0.88rem; }
.user-table th { text-align: left; color: #7a5c3e; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 10px; border-bottom: 2px solid #e8d5c0; }
.user-table td { padding: 9px 10px; border-bottom: 1px solid #f0e8dc; color: #3b2a1a; }
.user-table tr:last-child td { border-bottom: none; }
.user-table .user-nick { font-weight: bold; color: #7c4a1e; }
.user-table .badge { display: inline-block; background: #f0e8dc; color: #7a5c3e; padding: 2px 8px; border-radius: 10px; font-size: 0.78rem; }
.empty-state { text-align: center; color: #7a5c3e; font-style: italic; padding: 20px 0; }
.toast {
position: fixed; bottom: 28px; left: 50%;
transform: translateX(-50%) translateY(80px);
background: #7c4a1e; color: #fff; padding: 13px 26px; border-radius: 30px;
font-family: Arial, sans-serif; font-size: 0.92rem;
box-shadow: 0 6px 24px rgba(0,0,0,0.3); transition: transform 0.3s; z-index: 2000;
}
.toast.show { transform: translateX(-50%) translateY(0); }
.section-right { display: flex; flex-direction: column; }
/* Login overlay */
.login-overlay {
position: fixed; inset: 0; background: rgba(59,42,26,0.85);
display: flex; align-items: center; justify-content: center;
z-index: 9999;
}
.login-box {
background: #fff9f2; border-radius: 20px; padding: 40px 36px;
box-shadow: 0 16px 48px rgba(0,0,0,0.4); text-align: center;
min-width: 320px;
}
.login-box h2 { color: #7c4a1e; margin-bottom: 8px; font-size: 1.4rem; }
.login-box p { color: #7a5c3e; font-family: Arial, sans-serif; font-size: 0.9rem; margin-bottom: 20px; }
.login-box input {
width: 100%; padding: 11px 14px; border: 2px solid #e8d5c0; border-radius: 10px;
font-size: 1rem; font-family: Georgia, serif; color: #3b2a1a; outline: none;
margin-bottom: 12px; box-sizing: border-box;
}
.login-box input:focus { border-color: #e07b39; }
.login-box button {
width: 100%; padding: 12px; background: #e07b39; color: #fff;
border: none; border-radius: 10px; font-family: Arial, sans-serif;
font-size: 1rem; font-weight: bold; cursor: pointer; transition: background 0.2s;
}
.login-box button:hover { background: #c4612a; }
.login-error { color: #c04040; font-family: Arial, sans-serif; font-size: 0.88rem; margin-top: 8px; min-height: 18px; }
</style>
</head>
<body>
<!-- LOGIN OVERLAY -->
<div class="login-overlay" id="loginOverlay">
<div class="login-box">
<div style="font-size:2.4rem;margin-bottom:10px">🍳</div>
<h2>tykkää.fi — Hallinta</h2>
<p>Kirjaudu sisään jatkaaksesi</p>
<input type="password" id="adminPwInput" placeholder="Salasana" onkeydown="if(event.key==='Enter')doLogin()" />
<button onclick="doLogin()">🔐 Kirjaudu</button>
<div class="login-error" id="loginError"></div>
</div>
</div>
<header class="admin-header">
<div class="logo">
<span class="logo-icon">🍳</span>
<h1>tykkää.fi — Hallinta</h1>
</div>
<div style="display:flex;gap:16px;align-items:center;">
<button onclick="seedPosts()" style="background:#e8a000;color:#fff;border:none;padding:8px 14px;border-radius:8px;cursor:pointer;font-size:0.85rem;">🌱 Lataa esimerkkisisältö</button>
<a href="index.html" id="backLink">← Takaisin blogiin</a>
</div>
</header>
<main class="admin-main">
<!-- LEFT: ADD / EDIT FORM -->
<div>
<section class="admin-panel">
<h2 id="formTitle">Lisää uusi julkaisu</h2>
<!-- Type toggle -->
<div class="type-toggle">
<button class="type-btn active" id="typeRecipeBtn" onclick="setType('recipe')">🍳 Resepti</button>
<button class="type-btn" id="typePostBtn" onclick="setType('post')">📝 Julkaisu</button>
</div>
<div class="form-row">
<div class="form-group" style="grid-column:1/3">
<label id="lbl_title">Otsikko</label>
<input type="text" id="postTitle" placeholder="esim. Mustikkamuffinit" />
</div>
</div>
<div class="form-group">
<label id="lbl_category">Kategoria</label>
<select id="postCategory" onchange="updateAdminSubcategories()"></select>
</div>
<div class="form-group" id="adminSubcatGroup" style="display:none">
<label>Alikategoriat <small style="color:#7a5c3e;font-weight:normal;text-transform:none">(voit valita useamman)</small></label>
<div id="adminSubcatCheckboxes" style="display:flex;flex-wrap:wrap;gap:8px;margin-top:4px"></div>
</div>
<div class="form-group">
<label id="lbl_author">Kirjoittaja</label>
<input type="text" id="postAuthor" placeholder="esim. Anna K." />
</div>
<div class="form-group">
<label id="lbl_desc">Kuvaus</label>
<textarea id="postDesc" rows="2" placeholder="Lyhyt houkutteleva kuvaus..."></textarea>
</div>
<div class="form-group">
<label>Kuvat <small style="color:#7a5c3e;font-weight:normal;text-transform:none">(valinnainen, max 3)</small></label>
<div class="img-upload-slots">
<div class="img-upload-slot">
<label class="img-upload-btn" id="adm-lbl1">📷 Kuva 1
<input type="file" accept="image/*" onchange="uploadImgAdmin(this,'postImg1','adm-prev1','adm-lbl1')" style="display:none" />
</label>
<img class="img-preview" id="adm-prev1" alt="" />
<input type="hidden" id="postImg1" />
</div>
<div class="img-upload-slot">
<label class="img-upload-btn" id="adm-lbl2">📷 Kuva 2
<input type="file" accept="image/*" onchange="uploadImgAdmin(this,'postImg2','adm-prev2','adm-lbl2')" style="display:none" />
</label>
<img class="img-preview" id="adm-prev2" alt="" />
<input type="hidden" id="postImg2" />
</div>
<div class="img-upload-slot">
<label class="img-upload-btn" id="adm-lbl3">📷 Kuva 3
<input type="file" accept="image/*" onchange="uploadImgAdmin(this,'postImg3','adm-prev3','adm-lbl3')" style="display:none" />
</label>
<img class="img-preview" id="adm-prev3" alt="" />
<input type="hidden" id="postImg3" />
</div>
</div>
</div>
<!-- RECIPE FIELDS -->
<div class="recipe-fields visible" id="recipeFields">
<div class="form-row">
<div class="form-group">
<label id="lbl_time">Valmistusaika</label>
<input type="text" id="postTime" placeholder="esim. 30 min" />
</div>
<div class="form-group">
<label id="lbl_servings">Annoksia</label>
<input type="text" id="postServings" placeholder="esim. 4 annosta" />
</div>
</div>
<div class="form-group">
<label id="lbl_ingredients">Ainekset</label>
<div class="dynamic-list" id="ingredientsList">
<div class="dynamic-item">
<input type="text" placeholder="esim. 2 dl vehnäjauhoja" />
<button class="remove-btn" onclick="removeItem(this)"></button>
</div>
</div>
<button class="add-item-btn" onclick="addIngredient()">+ Lisää aines</button>
</div>
<div class="form-group">
<label id="lbl_steps">Ohjeet</label>
<div class="dynamic-list" id="stepsList">
<div class="dynamic-item">
<input type="text" placeholder="Vaihe 1..." />
<button class="remove-btn" onclick="removeItem(this)"></button>
</div>
</div>
<button class="add-item-btn" onclick="addStep()">+ Lisää vaihe</button>
</div>
</div>
<!-- POST FIELDS -->
<div class="post-fields" id="postFields">
<div class="form-group">
<label id="lbl_body">Sisältö <small style="color:#7a5c3e;font-weight:normal;text-transform:none">(HTML sallittu)</small></label>
<textarea id="postBody" rows="8" placeholder="<p>Kirjoita sisältö tähän...</p>"></textarea>
</div>
</div>
<button class="save-btn" id="saveBtn" onclick="savePost()">💾 Tallenna</button>
</section>
</div>
<!-- RIGHT: Users + Categories + Post list -->
<div class="section-right">
<!-- USERS -->
<section class="admin-panel">
<h2>Käyttäjät</h2>
<div id="userList"><p class="empty-state">Ladataan...</p></div>
</section>
<!-- CATEGORIES -->
<section class="admin-panel">
<h2 id="lbl_categories">Kategoriat</h2>
<div class="cat-list" id="catList"></div>
<div class="form-row-3">
<div class="form-group">
<label>Suomeksi</label>
<input type="text" id="newCatFi" placeholder="esim. Leivonta" />
</div>
<div class="form-group">
<label>Englanniksi</label>
<input type="text" id="newCatEn" placeholder="e.g. Baking" />
</div>
<div class="form-group">
<label>Emoji</label>
<input type="text" id="newCatEmoji" placeholder="🥐" maxlength="4" />
</div>
</div>
<button class="save-btn" onclick="addCategory()" style="margin-top:0">+ Lisää kategoria</button>
</section>
<!-- POST LIST -->
<section class="admin-panel">
<h2 id="lbl_all_posts">Kaikki julkaisut</h2>
<input type="text" id="postSearch" placeholder="Hae julkaisuja..." oninput="filterPostList()"
style="width:100%;padding:9px 13px;border:2px solid #e8d5c0;border-radius:8px;font-size:0.95rem;
font-family:Georgia,serif;color:#3b2a1a;outline:none;margin-bottom:12px;box-sizing:border-box;" />
<div class="post-list" id="postList"></div>
</section>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
// ===========================
// MINI i18n FOR ADMIN
// ===========================
const AT = {
back: '← Takaisin blogiin',
form_add: 'Lisää uusi julkaisu', form_edit: 'Muokkaa julkaisua',
save: '💾 Tallenna', saved: '✅ Tallennettu!', updated: '✅ Päivitetty!',
deleted: '🗑️ Poistettu.', confirm_del: 'Haluatko varmasti poistaa tämän?',
empty: 'Ei julkaisuja vielä.', no_title: '⚠️ Anna julkaisulle otsikko.',
muokkaa: 'Muokkaa', poista: 'Poista',
cat_empty: 'Ei kategorioita. Lisää ensimmäinen!',
cat_no_name: '⚠️ Anna kategorialle nimi (suomeksi + englanniksi).',
cat_deleted: '🗑️ Kategoria poistettu.',
cat_added: '✅ Kategoria lisätty!',
};
function at(k) { return AT[k] ?? k; }
// ===========================
// API
// ===========================
async function apiGet(action, params = {}) {
const qs = new URLSearchParams({ action, ...params }).toString();
const res = await fetch(`api.php?${qs}`);
return res.json();
}
async function apiPost(action, body = {}) {
const res = await fetch(`api.php?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json();
}
// ===========================
// ADMIN STATE
// ===========================
const ADMIN = { posts: [], categories: [] };
// ===========================
// LOGIN
// ===========================
async function seedPosts() {
if (!confirm('Tämä korvaa KAIKKI nykyiset julkaisut ja kategoriat oletussisällöllä. Jatketaanko?')) return;
// Seed categories first (needed for subcategories to work)
await apiPost('seed_categories', {});
const data = await apiPost('seed_posts', {});
if (data.ok) {
const postsData = await apiGet('posts');
ADMIN.posts = postsData.posts || [];
renderPostList();
showToast('✅ Ladattu ' + data.count + ' julkaisua + kategoriat päivitetty!');
} else {
showToast('⚠️ ' + (data.error || 'Virhe'));
}
}
async function doLogin() {
const pw = document.getElementById('adminPwInput').value;
const err = document.getElementById('loginError');
err.textContent = '';
const data = await apiPost('admin_login', { password: pw });
if (data.ok) {
document.getElementById('loginOverlay').style.display = 'none';
await loadAdminData();
} else {
err.textContent = data.error || 'Kirjautuminen epäonnistui.';
document.getElementById('adminPwInput').value = '';
document.getElementById('adminPwInput').focus();
}
}
async function loadAdminData() {
const [postsData, catsData, usersData] = await Promise.all([
apiGet('posts'),
apiGet('categories'),
apiGet('admin_users'),
]);
ADMIN.posts = postsData.posts || [];
ADMIN.categories = catsData.categories || [];
ADMIN.users = usersData.users || [];
document.getElementById('backLink').textContent = at('back');
document.getElementById('saveBtn').textContent = at('save');
populateCategorySelect();
renderCatList();
renderPostList();
renderUserList();
}
// ===========================
// IMAGE UPLOAD
// ===========================
async function resizeImage(file, maxPx = 1920, quality = 0.85) {
if (file.type === 'image/gif') return file;
return new Promise(resolve => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const ratio = Math.min(maxPx / img.naturalWidth, maxPx / img.naturalHeight, 1);
if (ratio === 1 && file.size < 1.5 * 1024 * 1024) { resolve(file); return; }
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.naturalWidth * ratio);
canvas.height = Math.round(img.naturalHeight * ratio);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
blob => resolve(new File([blob], 'image.jpg', { type: 'image/jpeg' })),
'image/jpeg', quality
);
};
img.src = url;
});
}
async function uploadImgAdmin(input, hiddenId, previewId, labelId) {
const file = input.files[0];
if (!file) return;
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowed.includes(file.type)) {
showToast('⚠️ Tuetut muodot: JPG, PNG, GIF, WebP');
input.value = '';
return;
}
const lbl = document.getElementById(labelId);
lbl.style.opacity = '0.5';
let resized;
try { resized = await resizeImage(file); } catch (e) { lbl.style.opacity = ''; showToast('⚠️ Kuvan käsittely epäonnistui: ' + e.message); return; }
const formData = new FormData();
formData.append('file', resized, resized.name || 'image.jpg');
try {
const res = await fetch('upload.php', { method: 'POST', body: formData });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch(e) { throw new Error('Palvelin vastasi (HTTP ' + res.status + '): ' + (text.slice(0, 200) || '(tyhjä)')); }
if (data.error) throw new Error(data.error);
document.getElementById(hiddenId).value = data.url;
const prev = document.getElementById(previewId);
prev.src = data.url;
prev.style.display = 'block';
lbl.classList.add('has-image');
} catch (e) {
showToast('⚠️ ' + e.message);
} finally {
lbl.style.opacity = '';
}
}
function setAdmImgPreview(idx, url) {
const prev = document.getElementById(`adm-prev${idx}`);
const lbl = document.getElementById(`adm-lbl${idx}`);
if (url) {
document.getElementById(`postImg${idx}`).value = url;
prev.src = url; prev.style.display = 'block';
lbl.classList.add('has-image');
} else {
document.getElementById(`postImg${idx}`).value = '';
prev.src = ''; prev.style.display = 'none';
lbl.classList.remove('has-image');
}
}
// ===========================
// CATEGORY HELPERS
// ===========================
function getCatLabel(id) {
const cat = ADMIN.categories.find(c => c.id === id);
return cat ? cat.fi : id;
}
// ===========================
// CATEGORY MANAGEMENT
// ===========================
function renderCatList() {
const cats = ADMIN.categories;
const el = document.getElementById('catList');
if (!cats.length) { el.innerHTML = `<p class="empty-state">${at('cat_empty')}</p>`; return; }
el.innerHTML = cats.map(c => {
const subs = (c.subcategories || []).map(s =>
`<span class="subcat-tag">${s.fi} <button onclick="removeSubcat('${c.id}','${s.id}')" class="subcat-remove">✕</button></span>`
).join('');
return `
<div class="cat-item" style="flex-wrap:wrap">
<span class="cat-emoji">${c.emoji || '📁'}</span>
<div class="cat-info">
<strong>${c.fi}</strong> / ${c.en}
<span> · id: ${c.id}</span>
</div>
<button class="delete-btn" onclick="deleteCategory('${c.id}')">${at('poista')}</button>
<div class="subcat-row" style="width:100%;margin-top:6px">
${subs || '<span style="color:#bbb;font-size:0.8rem">Ei alikategorioita</span>'}
<div class="subcat-add" style="display:flex;gap:6px;margin-top:6px">
<input type="text" id="newSub_${c.id}" placeholder="Uusi alikategoria..." style="flex:1;padding:5px 10px;border:1px solid #e8d5c0;border-radius:6px;font-size:0.82rem" />
<button onclick="addSubcat('${c.id}')" style="padding:5px 12px;background:#e07b39;color:#fff;border:none;border-radius:6px;font-size:0.82rem;cursor:pointer">+ Lisää</button>
</div>
</div>
</div>`;
}).join('');
populateCategorySelect();
}
async function addSubcat(catId) {
const input = document.getElementById('newSub_' + catId);
const name = input.value.trim();
if (!name) return;
const cat = ADMIN.categories.find(c => c.id === catId);
if (!cat) return;
if (!cat.subcategories) cat.subcategories = [];
const id = name.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'');
if (cat.subcategories.some(s => s.id === id)) { showToast('⚠️ Alikategoria on jo olemassa.'); return; }
cat.subcategories.push({ id, fi: name });
await apiPost('save_categories', { categories: ADMIN.categories });
renderCatList();
showToast('✅ Alikategoria lisätty!');
}
async function removeSubcat(catId, subId) {
const cat = ADMIN.categories.find(c => c.id === catId);
if (!cat) return;
cat.subcategories = (cat.subcategories || []).filter(s => s.id !== subId);
await apiPost('save_categories', { categories: ADMIN.categories });
renderCatList();
showToast('🗑️ Alikategoria poistettu.');
}
async function addCategory() {
const fi = document.getElementById('newCatFi').value.trim();
const en = document.getElementById('newCatEn').value.trim();
const emoji = document.getElementById('newCatEmoji').value.trim() || '📁';
if (!fi || !en) { showToast(at('cat_no_name')); return; }
const id = fi.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now();
ADMIN.categories.push({ id, fi, en, emoji });
await apiPost('save_categories', { categories: ADMIN.categories });
document.getElementById('newCatFi').value = '';
document.getElementById('newCatEn').value = '';
document.getElementById('newCatEmoji').value = '';
renderCatList();
showToast(at('cat_added'));
}
async function deleteCategory(id) {
if (!confirm(at('confirm_del'))) return;
ADMIN.categories = ADMIN.categories.filter(c => c.id !== id);
await apiPost('save_categories', { categories: ADMIN.categories });
renderCatList();
showToast(at('cat_deleted'));
}
function populateCategorySelect() {
const sel = document.getElementById('postCategory');
const current = sel.value;
sel.innerHTML = ADMIN.categories.map(c => `<option value="${c.id}">${c.emoji || ''} ${c.fi} / ${c.en}</option>`).join('');
if (current) sel.value = current;
updateAdminSubcategories();
}
function updateAdminSubcategories(checked = []) {
const catId = document.getElementById('postCategory').value;
const cat = ADMIN.categories.find(c => c.id === catId);
const subs = cat?.subcategories || [];
const group = document.getElementById('adminSubcatGroup');
const box = document.getElementById('adminSubcatCheckboxes');
if (!subs.length) { group.style.display = 'none'; box.innerHTML = ''; return; }
group.style.display = '';
box.innerHTML = subs.map(s => `
<label style="display:flex;align-items:center;gap:5px;cursor:pointer;background:#f5f0eb;padding:5px 10px;border-radius:6px;font-size:0.88rem">
<input type="checkbox" value="${s.id}" ${checked.includes(s.id) ? 'checked' : ''} style="accent-color:#8b5e3c">
${s.fi}
</label>`).join('');
}
function getCheckedSubcategories() {
return [...document.querySelectorAll('#adminSubcatCheckboxes input[type=checkbox]:checked')].map(cb => cb.value);
}
// ===========================
// POST TYPE TOGGLE
// ===========================
let currentType = 'recipe';
function setType(type) {
currentType = type;
document.getElementById('recipeFields').classList.toggle('visible', type === 'recipe');
document.getElementById('postFields').classList.toggle('visible', type === 'post');
document.getElementById('typeRecipeBtn').classList.toggle('active', type === 'recipe');
document.getElementById('typePostBtn').classList.toggle('active', type === 'post');
}
// ===========================
// DYNAMIC INGREDIENT/STEP LISTS
// ===========================
function addIngredient() {
const list = document.getElementById('ingredientsList');
const div = document.createElement('div');
div.className = 'dynamic-item';
div.innerHTML = `<input type="text" placeholder="esim. 1 dl sokeria" /><button class="remove-btn" onclick="removeItem(this)">✕</button>`;
list.appendChild(div);
}
function addStep() {
const list = document.getElementById('stepsList');
const n = list.children.length + 1;
const div = document.createElement('div');
div.className = 'dynamic-item';
div.innerHTML = `<input type="text" placeholder="Vaihe ${n}..." /><button class="remove-btn" onclick="removeItem(this)">✕</button>`;
list.appendChild(div);
}
function removeItem(btn) {
if (btn.parentElement.parentElement.children.length > 1) btn.parentElement.remove();
}
// ===========================
// SAVE / EDIT POST
// ===========================
let editingId = null;
async function savePost() {
const title = document.getElementById('postTitle').value.trim();
const category = document.getElementById('postCategory').value;
const subcategory = getCheckedSubcategories();
const cat = ADMIN.categories.find(c => c.id === category);
const emoji = cat?.emoji || '📝';
const author = document.getElementById('postAuthor').value.trim();
const desc = document.getElementById('postDesc').value.trim();
if (!title) { showToast(at('no_title')); return; }
const images = [
document.getElementById('postImg1').value.trim(),
document.getElementById('postImg2').value.trim(),
document.getElementById('postImg3').value.trim(),
].filter(Boolean);
const post = { title, emoji, category, subcategory, author, desc, images, type: currentType };
if (currentType === 'recipe') {
post.time = document.getElementById('postTime').value.trim();
post.servings = document.getElementById('postServings').value.trim();
post.ingredients = [...document.getElementById('ingredientsList').querySelectorAll('input')].map(i => i.value.trim()).filter(Boolean);
post.steps = [...document.getElementById('stepsList').querySelectorAll('input')].map(i => i.value.trim()).filter(Boolean);
} else {
post.body = document.getElementById('postBody').value.trim();
}
const btn = document.getElementById('saveBtn');
btn.disabled = true;
if (editingId) {
post.id = editingId;
const idx = ADMIN.posts.findIndex(p => p.id === editingId);
if (idx !== -1) ADMIN.posts[idx] = { ...ADMIN.posts[idx], ...post };
await apiPost('update_post', { post: ADMIN.posts[idx] || post });
showToast(at('updated'));
editingId = null;
document.getElementById('formTitle').textContent = at('form_add');
} else {
post.id = title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now();
ADMIN.posts.push(post);
await apiPost('add_post', { post });
showToast(at('saved'));
}
btn.disabled = false;
resetForm();
renderPostList();
}
function editPost(id) {
const p = ADMIN.posts.find(p => p.id === id);
if (!p) return;
editingId = id;
document.getElementById('formTitle').textContent = at('form_edit');
document.getElementById('postTitle').value = p.title;
document.getElementById('postCategory').value = p.category;
document.getElementById('postAuthor').value = p.author || '';
// Load subcategory checkboxes with existing values
const existingSubs = Array.isArray(p.subcategory) ? p.subcategory : (p.subcategory ? [p.subcategory] : []);
updateAdminSubcategories(existingSubs);
document.getElementById('postDesc').value = p.desc || '';
setAdmImgPreview(1, p.images?.[0] || '');
setAdmImgPreview(2, p.images?.[1] || '');
setAdmImgPreview(3, p.images?.[2] || '');
setType(p.type || 'recipe');
if (p.type === 'recipe') {
document.getElementById('postTime').value = p.time || '';
document.getElementById('postServings').value = p.servings || '';
const ingList = document.getElementById('ingredientsList');
ingList.innerHTML = (p.ingredients?.length ? p.ingredients : ['']).map(ing =>
`<div class="dynamic-item"><input type="text" value="${ing}" placeholder="Aines" /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`
).join('');
const stpList = document.getElementById('stepsList');
stpList.innerHTML = (p.steps?.length ? p.steps : ['']).map((s, i) =>
`<div class="dynamic-item"><input type="text" value="${s}" placeholder="Vaihe ${i+1}..." /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`
).join('');
} else {
document.getElementById('postBody').value = p.body || '';
}
window.scrollTo({ top: 0, behavior: 'smooth' });
}
async function deletePost(id) {
if (!confirm(at('confirm_del'))) return;
ADMIN.posts = ADMIN.posts.filter(p => p.id !== id);
await apiPost('delete_post', { id });
renderPostList();
showToast(at('deleted'));
}
function resetForm() {
editingId = null;
document.getElementById('postTitle').value = '';
document.getElementById('postAuthor').value = '';
updateAdminSubcategories([]);
document.getElementById('postDesc').value = '';
document.getElementById('postTime').value = '';
document.getElementById('postServings').value = '';
document.getElementById('postBody').value = '';
setAdmImgPreview(1, '');
setAdmImgPreview(2, '');
setAdmImgPreview(3, '');
document.getElementById('ingredientsList').innerHTML = `<div class="dynamic-item"><input type="text" placeholder="esim. 2 dl vehnäjauhoja" /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`;
document.getElementById('stepsList').innerHTML = `<div class="dynamic-item"><input type="text" placeholder="Vaihe 1..." /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`;
setType('recipe');
document.getElementById('formTitle').textContent = at('form_add');
}
// ===========================
// RENDER POST LIST
// ===========================
function renderPostList() {
const posts = ADMIN.posts;
const el = document.getElementById('postList');
if (!posts.length) { el.innerHTML = `<p class="empty-state">${at('empty')}</p>`; return; }
el.innerHTML = posts.map(p => `
<div class="post-item">
<span class="post-item-emoji">${p.emoji}</span>
<div class="post-item-info">
<h3>${p.title}</h3>
<p>${getCatLabel(p.category)} · ${p.type === 'recipe' ? (p.time || '') : '📝'} ${p.author ? '· ✍️ ' + p.author : ''}</p>
</div>
<div class="post-item-actions">
<button class="edit-btn" onclick="editPost('${p.id}')">${at('muokkaa')}</button>
<button class="delete-btn" onclick="deletePost('${p.id}')">${at('poista')}</button>
</div>
</div>
`).join('');
}
function filterPostList() {
const q = document.getElementById('postSearch').value.toLowerCase();
document.querySelectorAll('#postList .post-item').forEach(el => {
const text = el.querySelector('h3').textContent.toLowerCase();
el.style.display = text.includes(q) ? '' : 'none';
});
}
// ===========================
// USER LIST
// ===========================
function renderUserList() {
const users = ADMIN.users || [];
const el = document.getElementById('userList');
if (!users.length) { el.innerHTML = '<p class="empty-state">Ei rekisteröityneitä käyttäjiä.</p>'; return; }
el.innerHTML = `
<table class="user-table">
<thead><tr><th>Nimimerkki</th><th>Sähköposti</th><th>Julkaisut</th><th>Tykkäykset</th><th>Liittynyt</th></tr></thead>
<tbody>${users.map(u => `
<tr>
<td class="user-nick">${u.nickname}</td>
<td>${u.email || '<span style="color:#bbb"></span>'}</td>
<td><span class="badge">${u.postCount}</span></td>
<td><span class="badge">${u.likes}</span></td>
<td>${u.created || ''}</td>
</tr>`).join('')}
</tbody>
</table>`;
}
// ===========================
// TOAST
// ===========================
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
// ===========================
// INIT — check if already logged in
// ===========================
(async () => {
const data = await apiGet('admin_check');
if (data.loggedIn) {
document.getElementById('loginOverlay').style.display = 'none';
await loadAdminData();
} else {
document.getElementById('adminPwInput').focus();
}
})();
</script>
</body>
</html>