commit 4248e69ab7d1aab48965f3806ac0cb4c4a6cdd56 Author: Jukka Lampikoski Date: Sun Mar 8 00:20:17 2026 +0200 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71e05f0 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/admin.html b/admin.html new file mode 100644 index 0000000..33a6bbe --- /dev/null +++ b/admin.html @@ -0,0 +1,756 @@ + + + + + + Hallinta / Admin — tykkää.fi + + + + + + + + +
+ +
+ ← Takaisin blogiin +
+
+ +
+ + +
+
+

Lisää uusi julkaisu

+ + +
+ + +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+

Kategoriat / Categories

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+

Kaikki julkaisut / All Posts

+
+
+ +
+
+ +
+ + + + + diff --git a/api.php b/api.php new file mode 100644 index 0000000..b0d3c42 --- /dev/null +++ b/api.php @@ -0,0 +1,241 @@ + 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 9–11 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' => '

Tarvitset:

Ohje: Luo 20 silmukkaa. Neulo suoraan (edestakaisin oikein silmukoin) kunnes huivi on noin 150 cm pitkä. Päättele silmukat ja viimeistele päät. Valmis! 🎉

Vinkki: Paksuilla langoilla ja suurilla neuloilla saat huivista nopeasti valmiin, vaikka olisit aloittelija.

', + ], + [ + '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' => '

Kokeile näitä vinkkejä parempaan aamuun:

Pienet muutokset, iso vaikutus!

', + ], + ]; +} + +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'); +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..3a42882 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1 @@ +Deny from all diff --git a/images/.gitkeep b/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/index.html b/index.html new file mode 100644 index 0000000..68ecee5 --- /dev/null +++ b/index.html @@ -0,0 +1,202 @@ + + + + + + tykkää.fi + + + + + +
+
+ +

+ +
+
+ + +
+
+

+

+ +
+
+ + +
+
+ +
+ +
+
+ + +
+
+
+ +
+
+ + +
+
+
👩‍🍳
+
+

+

+
+
+
+ + +
+
+

+

+
+ + + + +
+
+
+ + +
+

🍳 tykkää.fi — © 2026

+
+ + + + + + + + + + diff --git a/script.js b/script.js new file mode 100644 index 0000000..e090bc3 --- /dev/null +++ b/script.js @@ -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 = + `` + + cats.map(c => ``).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 `

${t('no_comments')}

`; + return comments.map(c => ` +
+
${c.name}${c.time}
+

${c.text}

+
+ `).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 ? `
⏱ ${p.time}👤 ${p.servings}
` : ''; + const imgSrc = p.images?.[0]; + const cardImgHTML = imgSrc + ? `
${p.title}
` + : `
${p.emoji}
`; + return ` +
+ ${cardImgHTML} +
+ ${getCategoryLabel(p.category)} +

${p.title}

+ ${p.author ? `

✍️ ${p.author}

` : ''} +

${p.desc || ''}

+ ${metaRow} +
+ + +
+
+
+ `; + }).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 + ? `` + : `

${t('no_ingredients')}

`; + const stps = p.steps?.length + ? `
    ${p.steps.map(s => `
  1. ${s}
  2. `).join('')}
` + : `

${t('no_steps')}

`; + bodyHTML = `

${t('modal_ingredients')}

${ings}

${t('modal_steps')}

${stps}`; + } else { + bodyHTML = `
${p.body || p.desc}
`; + } + + const galleryHTML = p.images?.length + ? `` + : ''; + + document.getElementById('modalContent').innerHTML = ` + ${p.images?.length ? '' : `${p.emoji}`} + ${galleryHTML} +

${p.title}

+ + + ${bodyHTML} +
+

${t('modal_comments')} (${comments.length})

+
${renderCommentsList(comments)}
+
+ + + + +
+ + +
+ +
+
+ `; + + 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 => ``).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(); diff --git a/server.py b/server.py new file mode 100644 index 0000000..0d304cd --- /dev/null +++ b/server.py @@ -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() diff --git a/style.css b/style.css new file mode 100644 index 0000000..ce0730f --- /dev/null +++ b/style.css @@ -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; } +} diff --git a/upload.php b/upload.php new file mode 100644 index 0000000..678c387 --- /dev/null +++ b/upload.php @@ -0,0 +1,63 @@ + '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]);