Initial commit: tykkää.fi sivusto

- Julkaisualusta resepteille, neuloville, vinkeille
- PHP-backend (api.php) palvelinpuolen datalle
- Admin-paneeli salasanasuojauksella
- Kuvaupload (upload.php)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jukka Lampikoski
2026-03-08 00:20:17 +02:00
commit 4248e69ab7
11 changed files with 2684 additions and 0 deletions

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
# Data-kansio (palvelimen omat tiedot, ei versiohallintaan)
data/*.json
# Kuvat (isot tiedostot, ei versiohallintaan)
images/*
!images/.gitkeep
# Claude Code -asetukset
.claude/
# macOS
.DS_Store
# Editorit
.vscode/
*.swp

756
admin.html Normal file
View File

@@ -0,0 +1,756 @@
<!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; }
.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 / Admin</h1>
</div>
<div style="display:flex;gap:16px;align-items:center;">
<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 / Recipe</button>
<button class="type-btn" id="typePostBtn" onclick="setType('post')">📝 Julkaisu / Post</button>
</div>
<div class="form-row">
<div class="form-group" style="grid-column:1/3">
<label id="lbl_title">Otsikko / Title</label>
<input type="text" id="postTitle" placeholder="esim. Mustikkamuffinit" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label id="lbl_category">Kategoria / Category</label>
<select id="postCategory"></select>
</div>
<div class="form-group">
<label id="lbl_emoji">Emoji</label>
<input type="text" id="postEmoji" placeholder="🍽️" maxlength="4" />
</div>
</div>
<div class="form-group">
<label id="lbl_author">Kirjoittaja / Author</label>
<input type="text" id="postAuthor" placeholder="esim. Anna K." />
</div>
<div class="form-group">
<label id="lbl_desc">Kuvaus / Description</label>
<textarea id="postDesc" rows="2" placeholder="Lyhyt houkutteleva kuvaus..."></textarea>
</div>
<div class="form-group">
<label>Kuvat / Images <small style="color:#7a5c3e;font-weight:normal;text-transform:none">(valinnainen / optional, 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 / Time</label>
<input type="text" id="postTime" placeholder="esim. 30 min" />
</div>
<div class="form-group">
<label id="lbl_servings">Annoksia / Servings</label>
<input type="text" id="postServings" placeholder="esim. 4 annosta" />
</div>
</div>
<div class="form-group">
<label id="lbl_ingredients">Ainekset / Ingredients</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 / Add ingredient</button>
</div>
<div class="form-group">
<label id="lbl_steps">Ohjeet / Instructions</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 / Add step</button>
</div>
</div>
<!-- POST FIELDS -->
<div class="post-fields" id="postFields">
<div class="form-group">
<label id="lbl_body">Sisältö / Content <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 / Save</button>
</section>
</div>
<!-- RIGHT: Categories + Post list -->
<div class="section-right">
<!-- CATEGORIES -->
<section class="admin-panel">
<h2 id="lbl_categories">Kategoriat / Categories</h2>
<div class="cat-list" id="catList"></div>
<div class="form-row-3">
<div class="form-group">
<label>Suomi / Finnish</label>
<input type="text" id="newCatFi" placeholder="esim. Leivonta" />
</div>
<div class="form-group">
<label>English</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 / Add category</button>
</section>
<!-- POST LIST -->
<section class="admin-panel">
<h2 id="lbl_all_posts">Kaikki julkaisut / All Posts</h2>
<div class="post-list" id="postList"></div>
</section>
</div>
</main>
<div class="toast" id="toast"></div>
<script>
// ===========================
// MINI i18n FOR ADMIN
// ===========================
const AT = {
fi: {
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 (suomi + english).',
cat_deleted: '🗑️ Kategoria poistettu.',
cat_added: '✅ Kategoria lisätty!',
},
en: {
back: '← Back to Blog',
form_add: 'Add New Post', form_edit: 'Edit Post',
save: '💾 Save', saved: '✅ Saved!', updated: '✅ Updated!',
deleted: '🗑️ Deleted.', confirm_del: 'Delete this post?',
empty: 'No posts yet.', no_title: '⚠️ Please enter a title.',
muokkaa: 'Edit', poista: 'Delete',
cat_empty: 'No categories yet. Add the first one!',
cat_no_name: '⚠️ Enter both Finnish and English names.',
cat_deleted: '🗑️ Category deleted.',
cat_added: '✅ Category added!',
}
};
const lang = 'fi';
function at(k) { return (AT[lang] && AT[lang][k]) ? AT[lang][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 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] = await Promise.all([
apiGet('posts'),
apiGet('categories'),
]);
ADMIN.posts = postsData.posts || [];
ADMIN.categories = catsData.categories || [];
document.getElementById('backLink').textContent = at('back');
document.getElementById('saveBtn').textContent = at('save');
populateCategorySelect();
renderCatList();
renderPostList();
}
// ===========================
// IMAGE UPLOAD
// ===========================
async function uploadImgAdmin(input, hiddenId, previewId, labelId) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('upload.php', { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
document.getElementById(hiddenId).value = data.url;
const prev = document.getElementById(previewId);
prev.src = data.url;
prev.style.display = 'block';
document.getElementById(labelId).classList.add('has-image');
} catch (e) {
showToast('⚠️ Kuvan lataus epäonnistui.');
}
}
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 => `
<div class="cat-item">
<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>
`).join('');
populateCategorySelect();
}
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;
}
// ===========================
// 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 emoji = document.getElementById('postEmoji').value.trim() || '🍽️';
const category = document.getElementById('postCategory').value;
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, 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('postEmoji').value = p.emoji;
document.getElementById('postCategory').value = p.category;
document.getElementById('postAuthor').value = p.author || '';
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('postEmoji').value = '';
document.getElementById('postAuthor').value = '';
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('');
}
// ===========================
// 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>

241
api.php Normal file
View File

@@ -0,0 +1,241 @@
<?php
/**
* tykkää.fi — API backend
* Tallentaa julkaisut, kommentit, tykkäykset ja kategoriat JSON-tiedostoihin.
*/
session_start();
header('Content-Type: application/json; charset=UTF-8');
// ─── VAIHDA TÄMÄ SALASANA! ──────────────────────────────────────
define('ADMIN_PASSWORD', 'vaihda_tämä_salasana');
// ────────────────────────────────────────────────────────────────
define('DATA_DIR', __DIR__ . '/data/');
if (!is_dir(DATA_DIR)) mkdir(DATA_DIR, 0755, true);
$action = $_GET['action'] ?? '';
$body = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$raw = file_get_contents('php://input');
$body = json_decode($raw, true) ?? [];
if (!$action) $action = $body['action'] ?? '';
}
// ─── Apufunktiot ───────────────────────────────────────────────
function readData(string $file, $default = []) {
$path = DATA_DIR . $file;
if (!file_exists($path)) return $default;
$d = json_decode(file_get_contents($path), true);
return $d !== null ? $d : $default;
}
function writeData(string $file, $data): void {
file_put_contents(
DATA_DIR . $file,
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
);
}
function ok($data = []): void {
echo json_encode(array_merge(['ok' => true], $data));
exit;
}
function err(string $msg, int $code = 400): void {
http_response_code($code);
echo json_encode(['error' => $msg]);
exit;
}
function isAdmin(): bool {
return !empty($_SESSION['tykkaafi_admin']);
}
// ─── Oletusdata ────────────────────────────────────────────────
function defaultCategories(): array {
return [
['id' => 'recipes', 'fi' => 'Reseptit', 'en' => 'Recipes', 'emoji' => '🍳'],
['id' => 'knitting', 'fi' => 'Neulominen', 'en' => 'Knitting', 'emoji' => '🧶'],
['id' => 'tips', 'fi' => 'Vinkit', 'en' => 'Tips', 'emoji' => '💡'],
];
}
function defaultPosts(): array {
return [
[
'id' => 'pancakes', 'emoji' => '🥞', 'title' => 'Kuohkeat letut',
'category' => 'recipes', 'author' => 'Admin',
'time' => '20 min', 'servings' => '4 annosta', 'type' => 'recipe',
'desc' => 'Kultaisia, voisia lettuja, jotka sulavat suuhun. Täydellisiä laiskaan sunnuntaiaamuun.',
'ingredients' => ['1½ dl vehnäjauhoja','3½ tl leivinjauhetta','1 tl suolaa','1 rkl sokeria','3 dl maitoa','1 muna','3 rkl sulatettua voita','Voita tai öljyä paistamiseen'],
'steps' => ['Sekoita kulhossa jauhot, leivinjauhe, suola ja sokeri.','Tee keskelle kuoppa ja kaada sekaan maito, muna ja sulatettu voi.','Sekoita tasaiseksi — pienet paakut ovat ok.','Kuumenna pannua keskilämmöllä.','Kaada noin ¼ dl taikinaa per lettu.','Paista kunnes pintaan nousee kuplia, käännä ja paista kullanruskeaksi.','Tarjoile vaahterasiirapilla ja marjoilla.'],
],
[
'id' => 'bolognese', 'emoji' => '🍝', 'title' => 'Klassinen spagetti bolognese',
'category' => 'recipes', 'author' => 'Admin',
'time' => '1 t 20 min', 'servings' => '6 annosta', 'type' => 'recipe',
'desc' => 'Runsas, hitaasti haudutettu lihamauste al dente -spagetin päällä. Ajaton italialainen klassikko.',
'ingredients' => ['500 g jauhelihaa','400 g spagettia','1 sipuli, hienonnettuna','3 valkosipulinkynttä','800 g murskattuja tomaatteja','2 dl punaviiniä','2 rkl tomaattipyrettä','Suolaa, pippuria, basilikaa, oreganoa','Parmesaanijuustoa tarjoiluun'],
'steps' => ['Kuullota sipuli ja valkosipuli oliiviöljyssä.','Ruskista jauheliha.','Lisää viini ja anna pelkistyä (5 min).','Lisää tomaatit ja mausteet.','Hauduta miedolla lämmöllä 1 tunti.','Keitä spagetti al denteksi.','Tarjoile parmesaanin kera.'],
],
[
'id' => 'cookies', 'emoji' => '🍪', 'title' => 'Suklaahippukeksit',
'category' => 'recipes', 'author' => 'Admin',
'time' => '30 min', 'servings' => '24 keksiä', 'type' => 'recipe',
'desc' => 'Sitkeä sisältä, rapea reunoilta — täydellinen kotitekoinen keksi.',
'ingredients' => ['5½ dl vehnäjauhoja','1 tl ruokasoodaa','1 tl suolaa','225 g pehmeää voita','1½ dl sokeria','1½ dl ruskeaa sokeria','2 munaa','2 tl vaniljauutetta','4 dl suklaahippuja'],
'steps' => ['Kuumenna uuni 190°C.','Sekoita jauhot, sooda ja suola.','Vatkaa voi ja sokerit kuohkeaksi.','Lisää munat ja vanilja.','Yhdistä aineet ja lisää suklaa.','Lusikoi pellille.','Paista 911 min kullanruskeaksi.'],
],
[
'id' => 'soup', 'emoji' => '🍲', 'title' => 'Täyttävä kasviskeitto',
'category' => 'recipes', 'author' => 'Admin',
'time' => '45 min', 'servings' => '4 annosta', 'type' => 'recipe',
'desc' => 'Lämmittävä kulhollinen paksuja kasviksia rikkaassa yrttiliemessä.',
'ingredients' => ['2 rkl oliiviöljyä','1 sipuli','3 valkosipulia','3 porkkanaa, siivuina','2 perunaa, kuutioina','1 kesäkurpitsa','400 g tomaattimurskaa','1½ l kasvislientä','Timjami, rosmariini, suola, pippuri'],
'steps' => ['Kuullota sipuli ja valkosipuli.','Lisää kasvikset ja sekoittele 5 min.','Kaada liemi ja tomaatit, lisää yrtit.','Hauduta 25 min.','Lisää kesäkurpitsa, keitä 10 min.','Mausta ja tarjoile.'],
],
[
'id' => 'knitting_scarf', 'emoji' => '🧶', 'title' => 'Helppo huivi aloittelijalle',
'category' => 'knitting', 'author' => 'Admin', 'type' => 'post',
'desc' => 'Neulo kaunis huivi muutamassa tunnissa — täydellinen ensimmäinen projekti!',
'body' => '<p><strong>Tarvitset:</strong></p><ul><li>Paksu lanka (noin 200 g)</li><li>Pyöröneulat nro 68</li></ul><p><strong>Ohje:</strong> Luo 20 silmukkaa. Neulo suoraan (edestakaisin oikein silmukoin) kunnes huivi on noin 150 cm pitkä. Päättele silmukat ja viimeistele päät. Valmis! 🎉</p><p>Vinkki: Paksuilla langoilla ja suurilla neuloilla saat huivista nopeasti valmiin, vaikka olisit aloittelija.</p>',
],
[
'id' => 'tip_morning', 'emoji' => '💡', 'title' => 'Rauhallinen aamurutiini',
'category' => 'tips', 'author' => 'Admin', 'type' => 'post',
'desc' => 'Pienet muutokset aamurutiiniin tekevät koko päivästä paremman.',
'body' => '<p>Kokeile näitä vinkkejä parempaan aamuun:</p><ul><li>🌅 Herää 15 minuuttia aiemmin kuin tarvitset</li><li>💧 Juo lasi vettä ennen kahvia</li><li>📵 Älä katso puhelinta heti herätessä</li><li>🚶 Pieni lenkki tai venyttely ennen päivää</li></ul><p>Pienet muutokset, iso vaikutus!</p>',
],
];
}
function getOrInitPosts(): array {
if (!file_exists(DATA_DIR . 'posts.json')) {
$posts = defaultPosts();
writeData('posts.json', $posts);
return $posts;
}
return readData('posts.json', []);
}
function getOrInitCategories(): array {
if (!file_exists(DATA_DIR . 'categories.json')) {
$cats = defaultCategories();
writeData('categories.json', $cats);
return $cats;
}
return readData('categories.json', []);
}
// ─── Routing ───────────────────────────────────────────────────
switch ($action) {
case 'posts':
ok(['posts' => getOrInitPosts()]);
case 'categories':
ok(['categories' => getOrInitCategories()]);
case 'likes':
$likes = readData('likes.json', new stdClass());
$userLikes = $_SESSION['user_likes'] ?? [];
ok(['likes' => $likes, 'userLikes' => $userLikes]);
case 'toggle_like':
$postId = $body['postId'] ?? '';
if (!$postId) err('Missing postId');
$likes = readData('likes.json', []);
$userLikes = $_SESSION['user_likes'] ?? [];
$idx = array_search($postId, $userLikes, true);
if ($idx === false) {
$likes[$postId] = ($likes[$postId] ?? 0) + 1;
$userLikes[] = $postId;
$liked = true;
} else {
$likes[$postId] = max(0, ($likes[$postId] ?? 1) - 1);
array_splice($userLikes, $idx, 1);
$liked = false;
}
$_SESSION['user_likes'] = array_values($userLikes);
writeData('likes.json', $likes);
ok(['liked' => $liked, 'count' => $likes[$postId] ?? 0]);
case 'comments':
$postId = $_GET['postId'] ?? '';
$comments = readData('comments.json', []);
ok(['comments' => $comments[$postId] ?? []]);
case 'add_comment':
$postId = $body['postId'] ?? '';
$name = trim($body['name'] ?? '');
$text = trim($body['text'] ?? '');
if (!$postId || !$name || !$text) err('Missing fields');
$now = time();
$win = 10 * 60;
$times = array_values(array_filter($_SESSION['comment_times'] ?? [], fn($t) => $now - $t < $win));
if (count($times) >= 3) err('Liian monta kommenttia. Odota hetki.');
$times[] = $now;
$_SESSION['comment_times'] = $times;
$comments = readData('comments.json', []);
if (!isset($comments[$postId])) $comments[$postId] = [];
$comment = [
'name' => htmlspecialchars($name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'text' => htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'time' => date('d.m.Y'),
];
$comments[$postId][] = $comment;
writeData('comments.json', $comments);
ok(['comment' => $comment]);
case 'add_post':
$post = $body['post'] ?? [];
if (empty($post['title'])) err('Missing title');
$post['id'] = preg_replace('/[^a-z0-9_]/', '', strtolower($post['id'] ?? '')) ?: 'post_' . time();
$posts = getOrInitPosts();
$posts[] = $post;
writeData('posts.json', $posts);
ok();
case 'update_post':
if (!isAdmin()) err('Unauthorized', 403);
$post = $body['post'] ?? [];
if (empty($post['id'])) err('Missing id');
$posts = getOrInitPosts();
foreach ($posts as &$p) {
if ($p['id'] === $post['id']) { $p = $post; break; }
}
unset($p);
writeData('posts.json', $posts);
ok();
case 'delete_post':
if (!isAdmin()) err('Unauthorized', 403);
$id = $body['id'] ?? '';
if (!$id) err('Missing id');
$posts = array_values(array_filter(getOrInitPosts(), fn($p) => ($p['id'] ?? '') !== $id));
writeData('posts.json', $posts);
ok();
case 'save_categories':
if (!isAdmin()) err('Unauthorized', 403);
writeData('categories.json', $body['categories'] ?? []);
ok();
case 'admin_login':
if (($body['password'] ?? '') === ADMIN_PASSWORD) {
$_SESSION['tykkaafi_admin'] = true;
ok();
}
err('Väärä salasana');
case 'admin_logout':
$_SESSION['tykkaafi_admin'] = false;
ok();
case 'admin_check':
ok(['loggedIn' => isAdmin()]);
default:
err('Unknown action');
}

0
data/.gitkeep Normal file
View File

1
data/.htaccess Normal file
View File

@@ -0,0 +1 @@
Deny from all

0
images/.gitkeep Normal file
View File

202
index.html Normal file
View File

@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="fi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tykkää.fi</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- HEADER -->
<header>
<div class="header-inner">
<div class="logo">
<span class="logo-icon">🍳</span>
<h1>tykkää.fi</h1>
</div>
<p class="tagline" data-i18n="tagline"></p>
<nav>
<a href="#posts" data-i18n="nav_posts"></a>
<a href="#about" data-i18n="nav_about"></a>
<a href="#contact" data-i18n="nav_contact"></a>
<a href="admin.html" data-i18n="nav_admin"></a>
</nav>
</div>
</header>
<!-- HERO -->
<section class="hero">
<div class="hero-content">
<h2 data-i18n="hero_title"></h2>
<p data-i18n="hero_desc"></p>
<a href="#posts" class="btn" data-i18n="hero_btn"></a>
</div>
</section>
<!-- SEARCH & FILTER -->
<section class="controls" id="posts">
<div class="container">
<input type="text" id="search" data-i18n-ph="search_ph" oninput="filterPosts()" />
<div class="filters" id="categoryFilters"></div>
<button class="add-post-btn" onclick="openSubmitModal()">✏️ Lisää julkaisu</button>
</div>
</section>
<!-- POST GRID -->
<section class="recipe-section">
<div class="container">
<div class="recipe-grid" id="postGrid"></div>
<p class="no-results" id="noResults" style="display:none;" data-i18n="no_results"></p>
</div>
</section>
<!-- ABOUT -->
<section class="about" id="about">
<div class="container about-inner">
<div class="about-emoji">👩‍🍳</div>
<div>
<h2 data-i18n="about_title"></h2>
<p data-i18n="about_text"></p>
</div>
</div>
</section>
<!-- CONTACT -->
<section class="contact" id="contact">
<div class="container">
<h2 data-i18n="contact_title"></h2>
<p data-i18n="contact_desc"></p>
<form class="contact-form" onsubmit="handleSubmit(event)">
<input type="text" data-i18n-ph="name_ph" required />
<input type="email" data-i18n-ph="email_ph" required />
<textarea data-i18n-ph="msg_ph" rows="4" required></textarea>
<button type="submit" class="btn" data-i18n="send_btn"></button>
</form>
</div>
</section>
<!-- FOOTER -->
<footer>
<p>🍳 tykkää.fi &mdash; <span data-i18n="footer"></span> &copy; 2026</p>
</footer>
<!-- POST MODAL -->
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()">
<div class="modal" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closeModal()"></button>
<div id="modalContent"></div>
</div>
</div>
<!-- SUBMIT MODAL -->
<div class="modal-overlay" id="submitOverlay" onclick="closeSubmitModal()">
<div class="modal" onclick="event.stopPropagation()" style="max-width:640px">
<button class="modal-close" onclick="closeSubmitModal()"></button>
<h2>✏️ Lisää julkaisu</h2>
<div class="type-toggle">
<button class="type-btn active" id="sub-typeRecipeBtn" onclick="setSubmitType('recipe')">🍳 Resepti</button>
<button class="type-btn" id="sub-typePostBtn" onclick="setSubmitType('post')">📝 Julkaisu</button>
</div>
<div class="form-group">
<label>Otsikko *</label>
<input type="text" id="sub-title" placeholder="esim. Mummon mustikkapiirakka" maxlength="100" />
</div>
<div class="form-row">
<div class="form-group">
<label>Kategoria</label>
<select id="sub-category"></select>
</div>
<div class="form-group">
<label>Emoji</label>
<input type="text" id="sub-emoji" placeholder="🍽️" maxlength="4" />
</div>
</div>
<div class="form-group">
<label>Kirjoittaja</label>
<input type="text" id="sub-author" placeholder="Nimesi tai nimimerkki" maxlength="50" />
</div>
<div class="form-group">
<label>Kuvaus</label>
<textarea id="sub-desc" rows="2" placeholder="Lyhyt houkutteleva kuvaus..."></textarea>
</div>
<div class="form-group">
<label>Kuvat <small style="font-weight:normal;text-transform:none;color:var(--text-light)">(1. kuva pakollinen, 2. ja 3. vapaaehtoisia)</small></label>
<div class="img-upload-slots">
<div class="img-upload-slot">
<label class="img-upload-btn" id="sub-lbl1">📷 Kuva 1 *
<input type="file" accept="image/*" onchange="uploadImg(this,'sub-img1','sub-prev1','sub-lbl1')" style="display:none" />
</label>
<img class="img-preview" id="sub-prev1" alt="" />
<input type="hidden" id="sub-img1" />
</div>
<div class="img-upload-slot">
<label class="img-upload-btn" id="sub-lbl2">📷 Kuva 2
<input type="file" accept="image/*" onchange="uploadImg(this,'sub-img2','sub-prev2','sub-lbl2')" style="display:none" />
</label>
<img class="img-preview" id="sub-prev2" alt="" />
<input type="hidden" id="sub-img2" />
</div>
<div class="img-upload-slot">
<label class="img-upload-btn" id="sub-lbl3">📷 Kuva 3
<input type="file" accept="image/*" onchange="uploadImg(this,'sub-img3','sub-prev3','sub-lbl3')" style="display:none" />
</label>
<img class="img-preview" id="sub-prev3" alt="" />
<input type="hidden" id="sub-img3" />
</div>
</div>
</div>
<!-- RECIPE FIELDS -->
<div id="sub-recipeFields">
<div class="form-row">
<div class="form-group">
<label>Valmistusaika</label>
<input type="text" id="sub-time" placeholder="esim. 30 min" />
</div>
<div class="form-group">
<label>Annoksia</label>
<input type="text" id="sub-servings" placeholder="esim. 4 annosta" />
</div>
</div>
<div class="form-group">
<label>Ainekset <small style="font-weight:normal;text-transform:none;color:var(--text-light)">(yksi per rivi)</small></label>
<textarea id="sub-ingredients" rows="4" placeholder="1 dl vehnäjauhoja&#10;2 munaa&#10;..."></textarea>
</div>
<div class="form-group">
<label>Ohjeet <small style="font-weight:normal;text-transform:none;color:var(--text-light)">(yksi vaihe per rivi)</small></label>
<textarea id="sub-steps" rows="5" placeholder="Sekoita aineet&#10;Paista 180°C..."></textarea>
</div>
</div>
<!-- POST FIELDS -->
<div id="sub-postFields" style="display:none">
<div class="form-group">
<label>Sisältö</label>
<textarea id="sub-body" rows="8" placeholder="Kirjoita sisältö tähän..."></textarea>
</div>
</div>
<!-- honeypot -->
<input type="text" id="sub-honeypot" tabindex="-1" autocomplete="off" style="display:none" />
<!-- captcha -->
<div class="captcha-row" style="margin-top:8px">
<label class="captcha-label" id="sub-captcha-question"></label>
<input type="number" id="sub-captcha-input" placeholder="Vastaus" min="0" max="99" style="width:100px" />
</div>
<p class="sub-error" id="sub-error"></p>
<button class="submit-btn" onclick="submitPublicPost()">📨 Lähetä julkaisu</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

538
script.js Normal file
View File

@@ -0,0 +1,538 @@
// ===========================
// TRANSLATIONS
// ===========================
const T = {
fi: {
tagline: 'Kotitekoista rakkaudella, jaettu maailmalle',
nav_posts: 'Julkaisut', nav_about: 'Tietoa', nav_contact: 'Yhteystiedot', nav_admin: 'Hallinta',
hero_title: 'Tervetuloa!',
hero_desc: 'Reseptejä, neulomisvinkkejä, elämänohjeita — jaa mitä rakastat.',
hero_btn: 'Selaa julkaisuja',
search_ph: 'Hae julkaisuja...',
filter_all: 'Kaikki',
view_btn: 'Lue lisää',
no_results: 'Julkaisuja ei löydy. Kokeile eri hakusanaa!',
about_title: 'Tietoa blogista',
about_text: 'Hei! Täällä jaan kaiken mielenkiintoisen — resepteistä neulomisohjeisiin ja kaikkeen siltä väliltä. Tervetuloa!',
contact_title: 'Ota yhteyttä',
contact_desc: 'Haluatko ehdottaa aihetta tai vain sanoa hei? Lähetä viesti!',
name_ph: 'Nimesi', email_ph: 'Sähköpostisi', msg_ph: 'Viestisi...',
send_btn: 'Lähetä viesti', msg_sent: 'Viesti lähetetty! ✓',
footer: 'Tehty rakkaudella & voilla',
modal_by: 'Kirjoittanut',
modal_ingredients: 'Ainekset', modal_steps: 'Ohjeet',
modal_comments: 'Kommentit',
no_comments: 'Ei kommentteja vielä. Ole ensimmäinen!',
comment_name_ph: 'Nimesi', comment_ph: 'Kirjoita kommentti...',
comment_btn: 'Lähetä kommentti',
like_btn: '❤️ Tykkää', liked_btn: '❤️ Tykätty',
no_ingredients: 'Aineksia ei lisätty.', no_steps: 'Ohjeita ei lisätty.',
},
en: {
tagline: 'Homemade with love, shared with the world',
nav_posts: 'Posts', nav_about: 'About', nav_contact: 'Contact', nav_admin: 'Admin',
hero_title: 'Welcome!',
hero_desc: 'Recipes, knitting tips, life advice — share what you love.',
hero_btn: 'Browse Posts',
search_ph: 'Search posts...',
filter_all: 'All',
view_btn: 'Read more',
no_results: 'No posts found. Try a different search!',
about_title: 'About This Blog',
about_text: 'Hi! I share everything interesting here — from recipes to knitting tips and everything in between. Welcome!',
contact_title: 'Get in Touch',
contact_desc: 'Want to suggest a topic or just say hi? Drop me a message!',
name_ph: 'Your name', email_ph: 'Your email', msg_ph: 'Your message...',
send_btn: 'Send Message', msg_sent: 'Message Sent! ✓',
footer: 'Made with love & butter',
modal_by: 'By',
modal_ingredients: 'Ingredients', modal_steps: 'Instructions',
modal_comments: 'Comments',
no_comments: 'No comments yet. Be the first!',
comment_name_ph: 'Your name', comment_ph: 'Write a comment...',
comment_btn: 'Post Comment',
like_btn: '❤️ Like', liked_btn: '❤️ Liked',
no_ingredients: 'No ingredients listed.', no_steps: 'No instructions listed.',
}
};
const lang = 'fi';
function t(key) { return (T[lang] && T[lang][key]) ? T[lang][key] : key; }
function applyTranslations() {
document.documentElement.lang = lang;
document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
document.querySelectorAll('[data-i18n-ph]').forEach(el => { el.placeholder = t(el.dataset.i18nPh); });
}
// ===========================
// 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();
}
// ===========================
// APP STATE
// ===========================
const APP = {
posts: [],
categories: [],
likes: {},
userLikes: [],
};
// ===========================
// CATEGORIES
// ===========================
function getCategoryLabel(catId) {
const cat = APP.categories.find(c => c.id === catId);
return cat ? cat.fi : catId;
}
function renderCategoryFilters() {
const cats = APP.categories;
const container = document.getElementById('categoryFilters');
if (!container) return;
container.innerHTML =
`<button class="filter-btn ${currentFilter === 'all' ? 'active' : ''}" onclick="setFilter('all',this)">${t('filter_all')}</button>` +
cats.map(c => `<button class="filter-btn ${currentFilter === c.id ? 'active' : ''}" onclick="setFilter('${c.id}',this)">${c.emoji ? c.emoji + ' ' : ''}${c.fi}</button>`).join('');
}
// ===========================
// LIKES
// ===========================
async function toggleLike(id) {
const data = await apiPost('toggle_like', { postId: id });
if (data.error) return;
const liked = data.liked;
const count = data.count;
// Update in-memory state
APP.likes[id] = count;
if (liked) {
if (!APP.userLikes.includes(id)) APP.userLikes.push(id);
} else {
APP.userLikes = APP.userLikes.filter(x => x !== id);
}
// Update all like buttons for this post (card + modal)
document.querySelectorAll(`[data-like-id="${id}"]`).forEach(btn => {
btn.textContent = liked ? t('liked_btn') : t('like_btn');
btn.classList.toggle('liked', liked);
});
document.querySelectorAll(`[data-like-count="${id}"]`).forEach(el => {
el.textContent = count;
});
}
// ===========================
// COMMENTS + SPAM PROTECTION
// ===========================
let captchaAnswer = 0;
function generateCaptcha() {
const a = Math.floor(Math.random() * 9) + 1;
const b = Math.floor(Math.random() * 9) + 1;
captchaAnswer = a + b;
return `Laske: ${a} + ${b} = ?`;
}
function renderCommentsList(comments) {
if (!comments || !comments.length) return `<p class="empty-note">${t('no_comments')}</p>`;
return comments.map(c => `
<div class="comment">
<div class="comment-header"><strong>${c.name}</strong><span>${c.time}</span></div>
<p>${c.text}</p>
</div>
`).join('');
}
// ===========================
// RENDER CARDS
// ===========================
function renderCards() {
const grid = document.getElementById('postGrid');
if (!grid) return;
grid.innerHTML = APP.posts.map(p => {
const liked = APP.userLikes.includes(p.id);
const likeCount = APP.likes[p.id] || 0;
const metaRow = p.time ? `<div class="card-meta"><span>⏱ ${p.time}</span><span>👤 ${p.servings}</span></div>` : '';
const imgSrc = p.images?.[0];
const cardImgHTML = imgSrc
? `<div class="card-img card-has-photo"><img src="${imgSrc}" alt="${p.title}" /></div>`
: `<div class="card-img">${p.emoji}</div>`;
return `
<article class="recipe-card" data-category="${p.category}">
${cardImgHTML}
<div class="card-body">
<span class="category-tag">${getCategoryLabel(p.category)}</span>
<h3>${p.title}</h3>
${p.author ? `<p class="card-author">✍️ ${p.author}</p>` : ''}
<p>${p.desc || ''}</p>
${metaRow}
<div class="card-actions">
<button class="btn-outline" onclick="openPost('${p.id}')">${t('view_btn')}</button>
<div class="like-row">
<button class="like-btn ${liked ? 'liked' : ''}" data-like-id="${p.id}" onclick="toggleLike('${p.id}')">${liked ? t('liked_btn') : t('like_btn')}</button>
<span class="like-count" data-like-count="${p.id}">${likeCount}</span>
</div>
</div>
</div>
</article>
`;
}).join('');
}
// ===========================
// FILTER & SEARCH
// ===========================
let currentFilter = 'all';
function setFilter(category, btn) {
currentFilter = category;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterPosts();
}
function filterPosts() {
const query = (document.getElementById('search')?.value || '').toLowerCase();
const cards = document.querySelectorAll('.recipe-card');
let visible = 0;
cards.forEach(card => {
const matchesCat = currentFilter === 'all' || card.dataset.category === currentFilter;
const title = card.querySelector('h3').textContent.toLowerCase();
const desc = card.querySelector('p:not(.card-author)').textContent.toLowerCase();
const matchesSearch = title.includes(query) || desc.includes(query);
const show = matchesCat && matchesSearch;
card.style.display = show ? '' : 'none';
if (show) visible++;
});
const noRes = document.getElementById('noResults');
if (noRes) noRes.style.display = visible === 0 ? 'block' : 'none';
}
// ===========================
// MODAL
// ===========================
async function openPost(id) {
const p = APP.posts.find(x => x.id === id);
if (!p) return;
const liked = APP.userLikes.includes(p.id);
const likeCount = APP.likes[p.id] || 0;
// Load comments from server
const commData = await apiGet('comments', { postId: id });
const comments = commData.comments || [];
let bodyHTML = '';
if (p.type === 'recipe') {
const ings = p.ingredients?.length
? `<ul>${p.ingredients.map(i => `<li>${i}</li>`).join('')}</ul>`
: `<p class="empty-note">${t('no_ingredients')}</p>`;
const stps = p.steps?.length
? `<ol>${p.steps.map(s => `<li>${s}</li>`).join('')}</ol>`
: `<p class="empty-note">${t('no_steps')}</p>`;
bodyHTML = `<h3>${t('modal_ingredients')}</h3>${ings}<h3>${t('modal_steps')}</h3>${stps}`;
} else {
bodyHTML = `<div class="post-body">${p.body || p.desc}</div>`;
}
const galleryHTML = p.images?.length
? `<div class="img-gallery">${p.images.map(src => `<img src="${src}" alt="${p.title}" />`).join('')}</div>`
: '';
document.getElementById('modalContent').innerHTML = `
${p.images?.length ? '' : `<span class="modal-emoji">${p.emoji}</span>`}
${galleryHTML}
<h2>${p.title}</h2>
<p class="modal-meta">
📂 ${getCategoryLabel(p.category)}
${p.time ? `&nbsp;|&nbsp; ⏱ ${p.time} &nbsp;|&nbsp; 👤 ${p.servings}` : ''}
${p.author ? `&nbsp;|&nbsp; ✍️ ${t('modal_by')} ${p.author}` : ''}
</p>
<div class="modal-like-row">
<button class="like-btn ${liked ? 'liked' : ''}" data-like-id="${p.id}" onclick="toggleLike('${p.id}')">${liked ? t('liked_btn') : t('like_btn')}</button>
<span class="like-count" data-like-count="${p.id}">${likeCount}</span>
</div>
${bodyHTML}
<div class="comments-section">
<h3 id="comments-heading-${p.id}">${t('modal_comments')} (${comments.length})</h3>
<div id="comments-list-${p.id}">${renderCommentsList(comments)}</div>
<form class="comment-form" onsubmit="submitComment(event,'${p.id}')">
<input type="text" placeholder="${t('comment_name_ph')}" required maxlength="50" />
<textarea placeholder="${t('comment_ph')}" rows="3" required maxlength="500"></textarea>
<!-- honeypot: bots fill this, humans don't see it -->
<input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none" />
<div class="captcha-row">
<label class="captcha-label" id="captcha-question"></label>
<input type="number" id="captcha-input" placeholder="Vastaus" required min="0" max="99" style="width:100px" />
</div>
<button type="submit" class="btn-outline">${t('comment_btn')}</button>
</form>
</div>
`;
document.getElementById('modalOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
const qEl = document.getElementById('captcha-question');
if (qEl) qEl.textContent = generateCaptcha();
}
async function submitComment(e, postId) {
e.preventDefault();
// Honeypot check
if (e.target.querySelector('[name="website"]').value) return;
const name = e.target.querySelector('input[type="text"]').value.trim();
const text = e.target.querySelector('textarea').value.trim();
if (!name || !text) return;
// CAPTCHA check
const captchaInput = parseInt(e.target.querySelector('#captcha-input').value, 10);
if (captchaInput !== captchaAnswer) {
const qEl = document.getElementById('captcha-question');
if (qEl) {
qEl.style.color = '#c04040';
qEl.textContent = 'Väärä vastaus! ' + generateCaptcha();
setTimeout(() => { qEl.style.color = ''; }, 2000);
}
e.target.querySelector('#captcha-input').value = '';
return;
}
const btn = e.target.querySelector('button[type="submit"]');
btn.disabled = true;
btn.textContent = '...';
const data = await apiPost('add_comment', { postId, name, text });
btn.disabled = false;
btn.textContent = t('comment_btn');
if (data.error) {
const qEl = document.getElementById('captcha-question');
if (qEl) { qEl.style.color = '#c04040'; qEl.textContent = data.error; }
return;
}
e.target.reset();
const qEl = document.getElementById('captcha-question');
if (qEl) qEl.textContent = generateCaptcha();
// Refresh comments
const commData = await apiGet('comments', { postId });
const comments = commData.comments || [];
const listEl = document.getElementById(`comments-list-${postId}`);
const headEl = document.getElementById(`comments-heading-${postId}`);
if (listEl) listEl.innerHTML = renderCommentsList(comments);
if (headEl) headEl.textContent = `${t('modal_comments')} (${comments.length})`;
}
function closeModal() {
document.getElementById('modalOverlay').classList.remove('open');
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
// ===========================
// PUBLIC SUBMISSION
// ===========================
let submitCaptchaAnswer = 0;
let submitType = 'recipe';
function generateSubmitCaptcha() {
const a = Math.floor(Math.random() * 9) + 1;
const b = Math.floor(Math.random() * 9) + 1;
submitCaptchaAnswer = a + b;
const qEl = document.getElementById('sub-captcha-question');
if (qEl) qEl.textContent = `Laske: ${a} + ${b} = ?`;
}
function setSubmitType(type) {
submitType = type;
document.getElementById('sub-recipeFields').style.display = type === 'recipe' ? '' : 'none';
document.getElementById('sub-postFields').style.display = type === 'post' ? '' : 'none';
document.getElementById('sub-typeRecipeBtn').classList.toggle('active', type === 'recipe');
document.getElementById('sub-typePostBtn').classList.toggle('active', type === 'post');
}
function openSubmitModal() {
const cats = APP.categories;
const sel = document.getElementById('sub-category');
sel.innerHTML = cats.map(c => `<option value="${c.id}">${c.emoji || ''} ${c.fi}</option>`).join('');
setSubmitType('recipe');
document.getElementById('submitOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
generateSubmitCaptcha();
}
function closeSubmitModal() {
document.getElementById('submitOverlay').classList.remove('open');
document.body.style.overflow = '';
}
async function uploadImg(input, hiddenId, previewId, labelId) {
const file = input.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('upload.php', { method: 'POST', body: formData });
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
document.getElementById(hiddenId).value = data.url;
const prev = document.getElementById(previewId);
prev.src = data.url;
prev.style.display = 'block';
document.getElementById(labelId).classList.add('has-image');
} catch (e) {
alert('Kuvan lataus epäonnistui: ' + e.message);
}
}
function showSubError(msg) {
const el = document.getElementById('sub-error');
el.textContent = msg;
el.style.display = 'block';
}
async function submitPublicPost() {
document.getElementById('sub-error').style.display = 'none';
// Honeypot
if (document.getElementById('sub-honeypot').value) return;
const title = document.getElementById('sub-title').value.trim();
if (!title) { showSubError('Anna julkaisulle otsikko.'); return; }
if (!document.getElementById('sub-img1').value) {
showSubError('Lisää vähintään 1 kuva julkaisuun.');
return;
}
// CAPTCHA
const captchaVal = parseInt(document.getElementById('sub-captcha-input').value, 10);
if (captchaVal !== submitCaptchaAnswer) {
showSubError('Väärä vastaus captchaan!');
document.getElementById('sub-captcha-input').value = '';
generateSubmitCaptcha();
return;
}
const emoji = document.getElementById('sub-emoji').value.trim() || '📝';
const category = document.getElementById('sub-category').value;
const author = document.getElementById('sub-author').value.trim() || 'Vieras';
const desc = document.getElementById('sub-desc').value.trim();
const images = [
document.getElementById('sub-img1').value.trim(),
document.getElementById('sub-img2').value.trim(),
document.getElementById('sub-img3').value.trim(),
].filter(Boolean);
const post = {
id: title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now(),
title, emoji, category, author, desc, images,
type: submitType,
};
if (submitType === 'recipe') {
post.time = document.getElementById('sub-time').value.trim();
post.servings = document.getElementById('sub-servings').value.trim();
post.ingredients = document.getElementById('sub-ingredients').value.split('\n').map(s => s.trim()).filter(Boolean);
post.steps = document.getElementById('sub-steps').value.split('\n').map(s => s.trim()).filter(Boolean);
} else {
post.body = document.getElementById('sub-body').value.trim();
}
const btn = document.querySelector('.submit-btn');
btn.disabled = true;
btn.textContent = 'Lähetetään...';
const data = await apiPost('add_post', { post });
btn.disabled = false;
btn.textContent = '📨 Lähetä julkaisu';
if (data.error) { showSubError(data.error); return; }
// Update local state
APP.posts.push(post);
closeSubmitModal();
renderCards();
renderCategoryFilters();
// Reset form fields
['sub-title','sub-emoji','sub-author','sub-desc','sub-img1','sub-img2','sub-img3',
'sub-time','sub-servings','sub-ingredients','sub-steps','sub-body'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
[1,2,3].forEach(i => {
const prev = document.getElementById(`sub-prev${i}`);
const lbl = document.getElementById(`sub-lbl${i}`);
if (prev) { prev.src = ''; prev.style.display = 'none'; }
if (lbl) lbl.classList.remove('has-image');
});
document.getElementById('posts')?.scrollIntoView({ behavior: 'smooth' });
}
// ===========================
// CONTACT FORM
// ===========================
function handleSubmit(e) {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
btn.textContent = t('msg_sent');
btn.style.background = '#5c8a4a';
btn.disabled = true;
e.target.reset();
setTimeout(() => {
btn.textContent = t('send_btn');
btn.style.background = '';
btn.disabled = false;
}, 4000);
}
// ===========================
// INIT
// ===========================
async function init() {
try {
const [postsData, catsData, likesData] = await Promise.all([
apiGet('posts'),
apiGet('categories'),
apiGet('likes'),
]);
APP.posts = postsData.posts || [];
APP.categories = catsData.categories || [];
APP.likes = likesData.likes || {};
APP.userLikes = likesData.userLikes || [];
} catch (e) {
console.error('API virhe:', e);
}
applyTranslations();
renderCategoryFilters();
renderCards();
}
init();

110
server.py Normal file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""
tykkää.fi dev server
- Serves static files (GET)
- Accepts image uploads (POST /upload) → saves to images/
"""
import http.server, json, mimetypes, os, re, socketserver, time
from pathlib import Path
PORT = 3000
IMAGES_DIR = Path('images')
IMAGES_DIR.mkdir(exist_ok=True)
ALLOWED_MIME = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
MAX_BYTES = 8 * 1024 * 1024 # 8 MB
def parse_multipart(data: bytes, boundary: str):
"""Return list of (headers_dict, body_bytes) for each part."""
sep = ('--' + boundary).encode()
parts = []
for chunk in data.split(sep):
if not chunk or chunk in (b'\r\n', b'--\r\n', b'--'):
continue
chunk = chunk.lstrip(b'\r\n')
if chunk.startswith(b'--'):
continue
if b'\r\n\r\n' not in chunk:
continue
hdr_raw, body = chunk.split(b'\r\n\r\n', 1)
if body.endswith(b'\r\n'):
body = body[:-2]
headers = {}
for line in hdr_raw.decode(errors='replace').split('\r\n'):
if ':' in line:
k, v = line.split(':', 1)
headers[k.strip().lower()] = v.strip()
parts.append((headers, body))
return parts
class Handler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
if self.path != '/upload':
self.send_error(404)
return
ct = self.headers.get('Content-Type', '')
m = re.search(r'boundary=([^\s;]+)', ct)
if not m:
self.send_error(400, 'Missing boundary')
return
boundary = m.group(1).strip('"')
length = int(self.headers.get('Content-Length', 0))
if length > MAX_BYTES + 4096:
self.send_error(413, 'Request too large')
return
raw = self.rfile.read(length)
parts = parse_multipart(raw, boundary)
file_part = None
for hdrs, body in parts:
cd = hdrs.get('content-disposition', '')
if 'name="file"' in cd:
file_part = (hdrs, body)
break
if not file_part:
self.send_error(400, 'No file part')
return
hdrs, data = file_part
cd = hdrs.get('content-disposition', '')
fn_match = re.search(r'filename="([^"]+)"', cd)
filename = fn_match.group(1) if fn_match else 'upload.jpg'
if len(data) > MAX_BYTES:
self.send_error(413, 'File too large (max 8 MB)')
return
mime = mimetypes.guess_type(filename)[0] or ''
if mime not in ALLOWED_MIME:
self.send_error(415, 'Only images (jpeg/png/gif/webp) allowed')
return
ext = Path(filename).suffix.lower() or '.jpg'
fname = f"{int(time.time() * 1000)}{ext}"
(IMAGES_DIR / fname).write_bytes(data)
self._json(200, {'url': f'images/{fname}'})
def _json(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, fmt, *args):
pass # suppress request logs
if __name__ == '__main__':
os.chdir(Path(__file__).parent)
print(f'tykkää.fi running at http://localhost:{PORT}')
with socketserver.TCPServer(('', PORT), Handler) as srv:
srv.serve_forever()

757
style.css Normal file
View File

@@ -0,0 +1,757 @@
/* =====================
RESET & BASE
===================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--cream: #fdf6ec;
--warm-brown: #7c4a1e;
--light-brown: #c28b5a;
--accent: #e07b39;
--accent-dark: #c4612a;
--text: #3b2a1a;
--text-light: #7a5c3e;
--card-bg: #fff9f2;
--border: #e8d5c0;
--shadow: rgba(124, 74, 30, 0.12);
}
html { scroll-behavior: smooth; }
body {
font-family: 'Georgia', serif;
background: var(--cream);
color: var(--text);
line-height: 1.7;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 0 24px;
}
/* =====================
HEADER
===================== */
header {
background: var(--warm-brown);
padding: 32px 24px 24px;
text-align: center;
}
.header-inner { max-width: 1100px; margin: 0 auto; }
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-bottom: 6px;
}
.logo-icon { font-size: 2.4rem; }
header h1 {
font-size: 2.4rem;
color: #fff;
letter-spacing: 0.5px;
}
.tagline {
color: #e8c9a8;
font-style: italic;
font-size: 1rem;
margin-bottom: 18px;
}
nav { display: flex; justify-content: center; gap: 32px; flex-wrap: wrap; }
nav a {
color: #f5dfc0;
text-decoration: none;
font-size: 0.95rem;
letter-spacing: 1px;
text-transform: uppercase;
font-family: 'Arial', sans-serif;
transition: color 0.2s;
}
nav a:hover { color: #fff; }
/* =====================
HERO
===================== */
.hero {
background: linear-gradient(135deg, #d4895a 0%, #e8b88a 50%, #c4732e 100%);
padding: 80px 24px;
text-align: center;
}
.hero-content { max-width: 600px; margin: 0 auto; }
.hero h2 {
font-size: 2.8rem;
color: #fff;
margin-bottom: 14px;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.hero p {
color: #fef5ea;
font-size: 1.15rem;
margin-bottom: 28px;
}
.btn {
display: inline-block;
background: var(--warm-brown);
color: #fff;
padding: 13px 32px;
border-radius: 30px;
text-decoration: none;
font-family: 'Arial', sans-serif;
font-size: 0.95rem;
font-weight: bold;
letter-spacing: 0.5px;
border: none;
cursor: pointer;
transition: background 0.2s, transform 0.15s;
}
.btn:hover { background: #5c3412; transform: translateY(-2px); }
/* =====================
SEARCH & FILTERS
===================== */
.controls {
padding: 36px 24px 24px;
background: #f5ece0;
border-bottom: 2px solid var(--border);
}
.controls .container { display: flex; flex-direction: column; align-items: center; gap: 16px; }
#search {
width: 100%;
max-width: 500px;
padding: 12px 20px;
border: 2px solid var(--border);
border-radius: 30px;
font-size: 1rem;
font-family: 'Georgia', serif;
background: #fff;
color: var(--text);
outline: none;
transition: border-color 0.2s;
}
#search:focus { border-color: var(--accent); }
.filters { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
.filter-btn {
padding: 8px 20px;
border: 2px solid var(--border);
border-radius: 20px;
background: #fff;
color: var(--text-light);
font-family: 'Arial', sans-serif;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.2s;
}
.filter-btn:hover, .filter-btn.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* =====================
RECIPE GRID
===================== */
.recipe-section { padding: 48px 24px; }
.recipe-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 28px;
}
.recipe-card {
background: var(--card-bg);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 16px var(--shadow);
transition: transform 0.2s, box-shadow 0.2s;
}
.recipe-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 28px var(--shadow);
}
.card-img {
background: linear-gradient(135deg, #f5dfc0, #e8c4a0);
font-size: 4rem;
text-align: center;
padding: 32px 0;
}
.card-has-photo {
padding: 0;
height: 200px;
overflow: hidden;
}
.card-has-photo img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-body { padding: 20px; }
.category-tag {
display: inline-block;
background: #fde8d0;
color: var(--accent-dark);
font-size: 0.75rem;
font-family: 'Arial', sans-serif;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 1px;
padding: 3px 10px;
border-radius: 10px;
margin-bottom: 10px;
}
.card-body h3 {
font-size: 1.25rem;
color: var(--warm-brown);
margin-bottom: 8px;
}
.card-body p {
font-size: 0.93rem;
color: var(--text-light);
margin-bottom: 14px;
}
.card-meta {
display: flex;
gap: 16px;
font-size: 0.85rem;
color: var(--text-light);
font-family: 'Arial', sans-serif;
margin-bottom: 16px;
}
.btn-outline {
padding: 9px 22px;
border: 2px solid var(--accent);
border-radius: 20px;
background: transparent;
color: var(--accent);
font-family: 'Arial', sans-serif;
font-size: 0.88rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.btn-outline:hover {
background: var(--accent);
color: #fff;
}
.no-results {
text-align: center;
color: var(--text-light);
font-style: italic;
font-size: 1.1rem;
padding: 40px 0;
}
/* =====================
ABOUT
===================== */
.about {
background: var(--warm-brown);
padding: 60px 24px;
color: #f5dfc0;
}
.about-inner {
display: flex;
align-items: center;
gap: 36px;
flex-wrap: wrap;
}
.about-emoji { font-size: 5rem; }
.about h2 {
font-size: 1.9rem;
color: #fff;
margin-bottom: 12px;
}
.about p { font-size: 1rem; line-height: 1.8; max-width: 680px; }
/* =====================
CONTACT
===================== */
.contact {
padding: 60px 24px;
background: #f5ece0;
text-align: center;
}
.contact h2 { font-size: 1.9rem; color: var(--warm-brown); margin-bottom: 8px; }
.contact > .container > p { color: var(--text-light); margin-bottom: 28px; }
.contact-form {
display: flex;
flex-direction: column;
gap: 14px;
max-width: 520px;
margin: 0 auto;
}
.contact-form input,
.contact-form textarea {
padding: 12px 18px;
border: 2px solid var(--border);
border-radius: 10px;
font-size: 1rem;
font-family: 'Georgia', serif;
background: #fff;
color: var(--text);
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.contact-form input:focus,
.contact-form textarea:focus { border-color: var(--accent); }
/* =====================
FOOTER
===================== */
footer {
background: #3b2a1a;
color: #b89070;
text-align: center;
padding: 20px;
font-family: 'Arial', sans-serif;
font-size: 0.88rem;
}
/* =====================
MODAL
===================== */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(30, 18, 8, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
padding: 20px;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--card-bg);
border-radius: 20px;
max-width: 600px;
width: 100%;
max-height: 85vh;
overflow-y: auto;
padding: 36px;
position: relative;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.modal-close {
position: absolute;
top: 16px;
right: 20px;
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: var(--text-light);
transition: color 0.2s;
}
.modal-close:hover { color: var(--accent); }
.modal h2 { color: var(--warm-brown); font-size: 1.8rem; margin-bottom: 6px; }
.modal .modal-meta { color: var(--text-light); font-size: 0.9rem; font-family: 'Arial', sans-serif; margin-bottom: 20px; }
.modal .modal-emoji { font-size: 3.5rem; margin-bottom: 16px; display: block; }
.modal h3 { color: var(--warm-brown); margin: 20px 0 10px; font-size: 1.1rem; }
.modal ul, .modal ol { padding-left: 22px; color: var(--text); }
.modal li { margin-bottom: 6px; font-size: 0.97rem; }
/* =====================
IMAGE GALLERY
===================== */
.img-gallery {
display: flex;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
border-radius: 8px;
}
.img-gallery img {
width: 180px;
height: 140px;
object-fit: cover;
border-radius: 8px;
flex-shrink: 0;
}
/* =====================
SUBMIT FORM (in modal)
===================== */
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-family: 'Arial', sans-serif;
font-size: 0.8rem;
font-weight: bold;
color: var(--text-light);
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 var(--border);
border-radius: 8px;
font-size: 0.95rem;
font-family: 'Georgia', serif;
background: #fff;
color: var(--text);
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus { border-color: var(--accent); }
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.type-toggle {
display: flex;
gap: 10px;
margin-bottom: 14px;
}
.type-btn {
flex: 1;
padding: 10px;
border: 2px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--text-light);
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: var(--accent);
border-color: var(--accent);
color: #fff;
}
.submit-btn {
width: 100%;
padding: 13px;
background: var(--accent);
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;
}
.submit-btn:hover { background: var(--accent-dark); transform: translateY(-2px); }
.sub-error {
color: #c04040;
font-size: 0.9rem;
margin-top: 8px;
display: none;
}
.add-post-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 30px;
font-family: 'Arial', sans-serif;
font-size: 0.95rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.add-post-btn:hover { background: var(--accent-dark); transform: translateY(-2px); }
/* =====================
IMAGE UPLOAD SLOTS
===================== */
.img-upload-slots {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.img-upload-slot {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.img-upload-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 2px dashed var(--border);
border-radius: 8px;
color: var(--text-light);
font-family: 'Arial', sans-serif;
font-size: 0.82rem;
cursor: pointer;
transition: all 0.2s;
background: #fff;
white-space: nowrap;
user-select: none;
}
.img-upload-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.img-upload-btn.has-image {
border-color: var(--accent);
border-style: solid;
color: var(--accent-dark);
}
.img-preview {
width: 100px;
height: 72px;
object-fit: cover;
border-radius: 6px;
border: 2px solid var(--border);
display: none;
}
/* =====================
LANGUAGE TOGGLE
===================== */
.lang-toggle {
padding: 6px 14px;
border: 2px solid rgba(255,255,255,0.4);
border-radius: 20px;
background: transparent;
color: #f5dfc0;
font-family: 'Arial', sans-serif;
font-size: 0.8rem;
font-weight: bold;
letter-spacing: 1px;
cursor: pointer;
transition: all 0.2s;
}
.lang-toggle:hover { background: rgba(255,255,255,0.15); color: #fff; border-color: #fff; }
/* =====================
CARD AUTHOR + ACTIONS
===================== */
.card-author {
font-size: 0.8rem !important;
color: var(--light-brown) !important;
font-family: 'Arial', sans-serif;
margin-bottom: 4px !important;
font-style: italic;
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.like-row {
display: flex;
align-items: center;
gap: 6px;
}
.like-btn {
padding: 7px 14px;
border: 2px solid #e8c0c8;
border-radius: 20px;
background: #fff;
color: #c06080;
font-family: 'Arial', sans-serif;
font-size: 0.82rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.like-btn:hover { background: #fde8f0; border-color: #e07090; }
.like-btn.liked { background: #fde8f0; border-color: #e07090; color: #c03060; }
.like-count {
font-family: 'Arial', sans-serif;
font-size: 0.85rem;
color: var(--text-light);
font-weight: bold;
min-width: 16px;
}
.modal-like-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 20px;
}
/* =====================
POST BODY (non-recipe)
===================== */
.post-body { color: var(--text); line-height: 1.8; }
.post-body p { margin-bottom: 12px; }
.post-body ul, .post-body ol { padding-left: 22px; margin-bottom: 12px; }
.post-body li { margin-bottom: 6px; }
.post-body strong { color: var(--warm-brown); }
.empty-note { color: var(--text-light); font-style: italic; font-size: 0.93rem; }
/* =====================
COMMENTS
===================== */
.comments-section {
margin-top: 28px;
padding-top: 24px;
border-top: 2px solid var(--border);
}
.comments-section h3 { color: var(--warm-brown); font-size: 1.1rem; margin-bottom: 14px; }
.comment {
background: #f5ece0;
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 10px;
}
.comment-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
font-family: 'Arial', sans-serif;
font-size: 0.85rem;
}
.comment-header strong { color: var(--warm-brown); }
.comment-header span { color: var(--text-light); font-size: 0.78rem; }
.comment p { font-size: 0.93rem; color: var(--text); margin: 0; }
.comment-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 14px;
}
.comment-form input,
.comment-form textarea {
padding: 10px 14px;
border: 2px solid var(--border);
border-radius: 8px;
font-size: 0.93rem;
font-family: 'Georgia', serif;
background: #fff;
color: var(--text);
outline: none;
transition: border-color 0.2s;
resize: vertical;
}
.comment-form input:focus,
.comment-form textarea:focus { border-color: var(--accent); }
.captcha-row {
display: flex;
align-items: center;
gap: 12px;
background: #f5ece0;
border: 2px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
}
.captcha-label {
font-family: 'Arial', sans-serif;
font-size: 0.9rem;
font-weight: bold;
color: var(--warm-brown);
flex: 1;
}
.captcha-row input {
padding: 7px 10px !important;
border-radius: 6px !important;
text-align: center;
font-weight: bold;
}
/* =====================
RESPONSIVE
===================== */
@media (max-width: 600px) {
header h1 { font-size: 1.7rem; }
.hero h2 { font-size: 2rem; }
.about-inner { flex-direction: column; text-align: center; }
.recipe-grid { grid-template-columns: 1fr; }
nav { gap: 16px; }
.form-row { grid-template-columns: 1fr; }
}

63
upload.php Normal file
View File

@@ -0,0 +1,63 @@
<?php
/**
* tykkää.fi — kuvaupload-käsittelijä
* Tallentaa kuvat images/-kansioon.
*/
$allowed_mime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
$max_bytes = 8 * 1024 * 1024; // 8 Mt
header('Content-Type: application/json');
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
if (empty($_FILES['file'])) {
http_response_code(400);
echo json_encode(['error' => 'No file']);
exit;
}
$f = $_FILES['file'];
if ($f['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['error' => 'Upload error: ' . $f['error']]);
exit;
}
if ($f['size'] > $max_bytes) {
http_response_code(413);
echo json_encode(['error' => 'File too large (max 8 MB)']);
exit;
}
$mime = mime_content_type($f['tmp_name']);
if (!in_array($mime, $allowed_mime, true)) {
http_response_code(415);
echo json_encode(['error' => 'Only images (jpeg/png/gif/webp) allowed']);
exit;
}
$ext_map = ['image/jpeg' => '.jpg', 'image/png' => '.png',
'image/gif' => '.gif', 'image/webp' => '.webp'];
$ext = $ext_map[$mime] ?? '.jpg';
$dir = __DIR__ . '/images/';
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$fname = round(microtime(true) * 1000) . $ext;
$dest = $dir . $fname;
if (!move_uploaded_file($f['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['error' => 'Failed to save file']);
exit;
}
echo json_encode(['url' => 'images/' . $fname]);