Files
tykkaa.fi/script.js
Jukka Lampikoski d9ecfe4c97 Sort subcategories alphabetically with Muut always last
Applied sortSubcategories() to submission form picker, admin category
list, and admin subcategory checkboxes. Added the helper function to
admin.html since it's a standalone file.

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

902 lines
34 KiB
JavaScript

// ===========================
// TRANSLATIONS
// ===========================
const T = {
fi: {
tagline: 'Jaa asioita joista tykkäät — reseptejä, vinkkejä, ideoita',
nav_posts: 'Julkaisut', nav_about: 'Tietoa', nav_contact: 'Yhteystiedot', nav_admin: 'Hallinta',
hero_title: 'Tervetuloa tykkää.fi:hin!',
hero_desc: 'Jaa ja löydä reseptejä, neulomisvinkkejä, elämänohjeita — ja kaikkea muuta 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: 'Mikä tykkää.fi?',
about_text: 'tykkää.fi on avoin yhteisö, jonne kuka tahansa voi tulla jakamaan asioita joista tykkää. Reseptejä, neulomisohjeita, käsityöideoita tai ihan mitä muuta kivaa — tärkeintä on jakamisen ilo. Lisää oma julkaisusi yläkulman napista!',
contact_title: 'Ota yhteyttä',
contact_desc: 'Kysyttävää tai ehdotuksia? Lähetä meille viestiä!',
name_ph: 'Nimesi', email_ph: 'Sähköpostisi', msg_ph: 'Viestisi...',
send_btn: 'Lähetä viesti', msg_sent: 'Viesti lähetetty! ✓', msg_error: 'Virhe lähetyksessä. Yritä uudelleen.',
footer: 'Avoin yhteisö kaikille',
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.',
login_btn: 'Kirjaudu', logout_btn: 'Kirjaudu ulos', register_btn: 'Rekisteröidy',
login_title: 'Kirjaudu sisään', register_title: 'Luo tili',
nickname_ph: 'Nimimerkki', password_ph: 'Salasana', email_ph_reg: 'Sähköposti (vapaaehtoinen)',
login_submit: 'Kirjaudu', register_submit: 'Luo tili',
switch_to_login: 'Onko sinulla jo tili? Kirjaudu sisään',
switch_to_register: 'Eikö sinulla ole tiliä? Rekisteröidy',
logged_in_as: 'Kirjautunut:', my_likes_btn: '❤️ Tykkäämäni',
auth_fields_required: 'Täytä nimimerkki ja salasana.',
},
en: {
tagline: 'Share things you love — recipes, tips, ideas',
nav_posts: 'Posts', nav_about: 'About', nav_contact: 'Contact', nav_admin: 'Admin',
hero_title: 'Welcome to tykkää.fi!',
hero_desc: 'Share and discover recipes, knitting tips, life advice — and everything else 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: 'What is tykkää.fi?',
about_text: 'tykkää.fi is an open community where anyone can share things they love. Recipes, knitting patterns, craft ideas or anything fun — the joy of sharing is what matters. Add your own post using the button in the top corner!',
contact_title: 'Get in Touch',
contact_desc: 'Questions or suggestions? Send us a message!',
name_ph: 'Your name', email_ph: 'Your email', msg_ph: 'Your message...',
send_btn: 'Send Message', msg_sent: 'Message Sent! ✓', msg_error: 'Error sending. Please try again.',
footer: 'Open community for everyone',
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.',
login_btn: 'Log in', logout_btn: 'Log out', register_btn: 'Register',
login_title: 'Log In', register_title: 'Create Account',
nickname_ph: 'Nickname', password_ph: 'Password', email_ph_reg: 'Email (optional)',
login_submit: 'Log In', register_submit: 'Create Account',
switch_to_login: 'Already have an account? Log in',
switch_to_register: "Don't have an account? Register",
logged_in_as: 'Logged in:', my_likes_btn: '❤️ My Likes',
auth_fields_required: 'Nickname and password are required.',
}
};
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); });
}
// ===========================
// HELPERS
// ===========================
function sortSubcategories(subs) {
return [...subs].sort((a, b) => {
const aM = a.fi.toLowerCase() === 'muut' ? 1 : 0;
const bM = b.fi.toLowerCase() === 'muut' ? 1 : 0;
if (aM !== bM) return aM - bM;
return a.fi.localeCompare(b.fi, 'fi');
});
}
// ===========================
// 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: [],
user: null,
showOnlyLiked: false,
};
// ===========================
// CATEGORIES
// ===========================
function getCategoryLabel(catId) {
const cat = APP.categories.find(c => c.id === catId);
return cat ? cat.fi : catId;
}
function countPostsInCategory(catId) {
return APP.posts.filter(p => p.category === catId).length;
}
function countPostsInSubcategory(catId, subId) {
return APP.posts.filter(p => {
if (p.category !== catId) return false;
const subs = Array.isArray(p.subcategory) ? p.subcategory : [p.subcategory].filter(Boolean);
return subs.includes(subId);
}).length;
}
function renderCategoryFilters() {
const cats = APP.categories.filter(c => countPostsInCategory(c.id) > 0);
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);
}
// Persist to localStorage so likes survive session expiry
saveLocalLikes(APP.userLikes);
// 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;
});
if (APP.showOnlyLiked) filterPosts();
}
// ===========================
// 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}" data-subcategory="${Array.isArray(p.subcategory) ? p.subcategory.join(',') : (p.subcategory || '')}" onclick="openPost('${p.id}')">
${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.submittedBy?.validated ? ' <span class="validated-badge">✔ Vahvistettu</span>' : (p.submittedBy?.guestCode ? ` <span class="guest-badge">#${p.submittedBy.guestCode}</span>` : '')}</p>` : ''}
<p>${p.desc || ''}</p>
${metaRow}
<div class="card-actions">
<button class="btn-outline" onclick="event.stopPropagation(); openPost('${p.id}')">${t('view_btn')}</button>
<div class="like-row" onclick="event.stopPropagation()">
<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';
let currentSubFilter = 'all';
function setFilter(category, btn) {
currentFilter = category;
currentSubFilter = 'all';
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderSubFilters();
filterPosts();
}
function setSubFilter(sub, btn) {
currentSubFilter = sub;
document.querySelectorAll('.sub-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
filterPosts();
}
function renderSubFilters() {
const container = document.getElementById('subCategoryFilters');
if (!container) return;
if (currentFilter === 'all') { container.innerHTML = ''; return; }
const cat = APP.categories.find(c => c.id === currentFilter);
const subs = sortSubcategories((cat?.subcategories || []).filter(s => countPostsInSubcategory(currentFilter, s.id) > 0));
if (!subs.length) { container.innerHTML = ''; return; }
container.innerHTML =
`<button class="sub-filter-btn ${currentSubFilter === 'all' ? 'active' : ''}" onclick="setSubFilter('all',this)">Kaikki</button>` +
subs.map(s => `<button class="sub-filter-btn ${currentSubFilter === s.id ? 'active' : ''}" onclick="setSubFilter('${s.id}',this)">${s.fi}</button>`).join('');
}
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 postSubs = (card.dataset.subcategory || '').split(',').filter(Boolean);
const matchesSub = currentSubFilter === 'all' || postSubs.includes(currentSubFilter);
const title = (card.querySelector('h3')?.textContent || '').toLowerCase();
const desc = (card.querySelector('p:not(.card-author)')?.textContent || '').toLowerCase();
const subLbl = (card.dataset.subcategory || '').toLowerCase();
const matchesSearch = !query || title.includes(query) || desc.includes(query) || subLbl.includes(query);
const postId = card.querySelector('[data-like-id]')?.dataset.likeId || '';
const matchesLikes = !APP.showOnlyLiked || APP.userLikes.includes(postId);
const show = matchesCat && matchesSub && matchesSearch && matchesLikes;
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>`;
const copyBtn = p.ingredients?.length
? `<button class="copy-list-btn" onclick="copyIngredients('${p.id}')" style="margin-left:10px;padding:4px 12px;font-size:0.82rem;border:1px solid #ccc;background:#fff;border-radius:20px;cursor:pointer;">📋 Kopioi ostoslista</button>`
: '';
bodyHTML = `<div style="display:flex;align-items:center;flex-wrap:wrap;gap:6px;margin-bottom:2px"><h3 style="margin:0">${t('modal_ingredients')}</h3>${copyBtn}</div>${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}" onclick="openLightbox(this.src)" />`).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.submittedBy?.validated ? ' <span class="validated-badge">✔ Vahvistettu</span>' : (p.submittedBy?.guestCode ? ` <span class="guest-badge">#${p.submittedBy.guestCode}</span>` : '')}` : ''}
</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 = '';
}
// ===========================
// LIGHTBOX
// ===========================
function openLightbox(src) {
document.getElementById('lightboxImg').src = src;
document.getElementById('lightboxOverlay').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightboxOverlay').classList.remove('open');
document.getElementById('lightboxImg').src = '';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeLightbox();
});
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';
}
function updateSubcategoryPicker() {
const catId = document.getElementById('sub-category').value;
const cat = APP.categories.find(c => c.id === catId);
const subs = sortSubcategories(cat?.subcategories || []);
const group = document.getElementById('sub-subcategory-group');
const sel = document.getElementById('sub-subcategory');
if (!subs.length) {
group.style.display = 'none';
sel.innerHTML = '';
} else {
sel.innerHTML = `<option value="">— Valitse alikategoria —</option>` +
subs.map(s => `<option value="${s.id}">${s.fi}</option>`).join('');
group.style.display = '';
}
// Näytä reseptikentät jos kategoria on Reseptit, muuten julkaisukentät
setSubmitType(catId === 'recipes' ? 'recipe' : '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('');
updateSubcategoryPicker(); // asettaa myös oikean tyypin kategorian mukaan
document.getElementById('submitOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
generateSubmitCaptcha();
if (APP.user) {
const authorField = document.getElementById('sub-author');
if (authorField && !authorField.value) authorField.value = APP.user.nickname;
}
}
function closeSubmitModal() {
document.getElementById('submitOverlay').classList.remove('open');
document.body.style.overflow = '';
}
async function resizeImage(file, maxPx = 1920, quality = 0.85) {
// Älä muuta GIF:ejä (animaatio menisi rikki)
if (file.type === 'image/gif') return file;
return new Promise(resolve => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const ratio = Math.min(maxPx / img.naturalWidth, maxPx / img.naturalHeight, 1);
// Jos kuva on jo tarpeeksi pieni, palauta sellaisenaan
if (ratio === 1 && file.size < 1.5 * 1024 * 1024) { resolve(file); return; }
const canvas = document.createElement('canvas');
canvas.width = Math.round(img.naturalWidth * ratio);
canvas.height = Math.round(img.naturalHeight * ratio);
canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
blob => resolve(new File([blob], 'image.jpg', { type: 'image/jpeg' })),
'image/jpeg', quality
);
};
img.src = url;
});
}
async function uploadImg(input, hiddenId, previewId, labelId) {
const file = input.files[0];
if (!file) return;
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowed.includes(file.type)) {
alert('Tuetut kuvamuodot: JPG, PNG, GIF, WebP');
input.value = '';
return;
}
const lbl = document.getElementById(labelId);
lbl.style.opacity = '0.5';
let resized;
try { resized = await resizeImage(file); } catch (e) { lbl.style.opacity = ''; alert('Kuvan käsittely epäonnistui: ' + e.message); return; }
const formData = new FormData();
formData.append('file', resized, resized.name || 'image.jpg');
try {
const res = await fetch('upload.php', { method: 'POST', body: formData });
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch(e) { throw new Error('Palvelin vastasi (HTTP ' + res.status + '): ' + (text.slice(0, 200) || '(tyhjä)')); }
if (data.error) throw new Error(data.error);
document.getElementById(hiddenId).value = data.url;
const prev = document.getElementById(previewId);
prev.src = data.url;
prev.style.display = 'block';
lbl.classList.add('has-image');
} catch (e) {
alert('Kuvan lataus epäonnistui: ' + e.message);
} finally {
lbl.style.opacity = '';
}
}
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 category = document.getElementById('sub-category').value;
const subVal = document.getElementById('sub-subcategory')?.value || '';
const subcategory = subVal ? [subVal] : [];
const cat = APP.categories.find(c => c.id === category);
const emoji = cat?.emoji || '📝';
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 submittedBy = APP.user
? { userId: APP.user.id, nickname: APP.user.nickname, validated: true }
: { guestCode: Math.random().toString(36).slice(2, 8).toUpperCase(), validated: false };
const post = {
id: title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now(),
title, emoji, category, subcategory, author, desc, images,
type: submitType,
submittedBy,
};
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-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
// ===========================
// ===========================
// CONTACT FORM
// ===========================
async function handleSubmit(e) {
e.preventDefault();
const btn = e.target.querySelector('button[type="submit"]');
const name = document.getElementById('contact-name').value.trim();
const email = document.getElementById('contact-email').value.trim();
const message = document.getElementById('contact-msg').value.trim();
btn.disabled = true;
try {
await apiPost('contact', { name, email, message });
btn.textContent = t('msg_sent');
btn.style.background = '#5c8a4a';
e.target.reset();
setTimeout(() => { btn.textContent = t('send_btn'); btn.style.background = ''; btn.disabled = false; }, 4000);
} catch {
btn.textContent = t('msg_error');
btn.style.background = '#c04040';
setTimeout(() => { btn.textContent = t('send_btn'); btn.style.background = ''; btn.disabled = false; }, 4000);
}
}
// ===========================
// INIT
// ===========================
// COPY INGREDIENTS
// ===========================
function copyIngredients(postId) {
const p = APP.posts.find(x => x.id === postId);
if (!p?.ingredients?.length) return;
const text = p.title + '\n\nOstoslista:\n' + p.ingredients.map(i => '• ' + i).join('\n');
navigator.clipboard.writeText(text).then(() => {
const btn = document.querySelector('.copy-list-btn');
if (btn) { btn.textContent = '✅ Kopioitu!'; setTimeout(() => { btn.textContent = '📋 Kopioi ostoslista'; }, 2000); }
}).catch(() => {});
}
// ===========================
// LOCAL LIKES PERSISTENCE
// ===========================
function getLocalLikes() {
try {
const v = JSON.parse(localStorage.getItem('tykkaafi_likes') || '[]');
return Array.isArray(v) ? v : [];
} catch { return []; }
}
function saveLocalLikes(arr) {
try { localStorage.setItem('tykkaafi_likes', JSON.stringify(arr)); } catch {}
}
// ===========================
// AUTH
// ===========================
let authMode = 'login';
let authCaptchaAnswer = 0;
function generateAuthCaptcha() {
const a = Math.floor(Math.random() * 9) + 1;
const b = Math.floor(Math.random() * 9) + 1;
authCaptchaAnswer = a + b;
const qEl = document.getElementById('auth-captcha-question');
if (qEl) qEl.textContent = `Laske: ${a} + ${b} = ?`;
}
function openAuthModal(mode = 'login') {
authMode = mode;
renderAuthForm();
document.getElementById('authOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
if (mode === 'register') generateAuthCaptcha();
}
function closeAuthModal() {
document.getElementById('authOverlay').classList.remove('open');
document.body.style.overflow = '';
}
function renderAuthForm() {
const isLogin = authMode === 'login';
document.getElementById('authContent').innerHTML = `
<h2>${isLogin ? t('login_title') : t('register_title')}</h2>
<div class="form-group">
<label>${t('nickname_ph')}</label>
<input type="text" id="auth-nickname" placeholder="${t('nickname_ph')}" maxlength="30" />
</div>
${!isLogin ? `<div class="form-group">
<label>${t('email_ph_reg')}</label>
<input type="email" id="auth-email" placeholder="${t('email_ph_reg')}" maxlength="100" />
</div>` : ''}
<div class="form-group">
<label>${t('password_ph')}</label>
<input type="password" id="auth-password" placeholder="${t('password_ph')}" maxlength="100" />
</div>
${!isLogin ? `
<input type="text" name="website" id="auth-honeypot" tabindex="-1" autocomplete="off" style="display:none" />
<div class="captcha-row">
<label class="captcha-label" id="auth-captcha-question"></label>
<input type="number" id="auth-captcha-input" placeholder="Vastaus" required min="0" max="99" style="width:100px" />
</div>
` : ''}
<p class="auth-error" id="auth-error"></p>
<button class="submit-btn" onclick="submitAuth()">${isLogin ? t('login_submit') : t('register_submit')}</button>
<p class="auth-switch" onclick="openAuthModal('${isLogin ? 'register' : 'login'}')">
${isLogin ? t('switch_to_register') : t('switch_to_login')}
</p>
`;
}
async function submitAuth() {
const errEl = document.getElementById('auth-error');
errEl.textContent = '';
errEl.style.display = 'none';
const nickname = document.getElementById('auth-nickname').value.trim();
const password = document.getElementById('auth-password').value;
const emailEl = document.getElementById('auth-email');
const email = emailEl ? emailEl.value.trim() : '';
if (!nickname || !password) {
errEl.textContent = t('auth_fields_required');
errEl.style.display = 'block';
return;
}
if (authMode === 'register') {
// Honeypot
const hp = document.getElementById('auth-honeypot');
if (hp && hp.value) return;
// CAPTCHA
const captchaVal = parseInt(document.getElementById('auth-captcha-input').value, 10);
if (captchaVal !== authCaptchaAnswer) {
errEl.textContent = 'Väärä vastaus captchaan!';
errEl.style.display = 'block';
document.getElementById('auth-captcha-input').value = '';
generateAuthCaptcha();
return;
}
}
const action = authMode === 'login' ? 'user_login' : 'user_register';
const body = { nickname, password };
if (authMode === 'register') body.email = email;
const data = await apiPost(action, body);
if (data.error) {
errEl.textContent = data.error;
errEl.style.display = 'block';
return;
}
APP.user = data.user;
if (data.user.likes?.length) {
APP.userLikes = [...new Set([...APP.userLikes, ...data.user.likes])];
saveLocalLikes(APP.userLikes);
}
closeAuthModal();
updateUserUI();
renderCards();
}
async function logoutUser() {
await apiPost('user_logout');
APP.user = null;
APP.showOnlyLiked = false;
updateUserUI();
renderCards();
filterPosts();
}
function updateUserUI() {
const container = document.getElementById('userArea');
if (!container) return;
if (APP.user) {
container.innerHTML = `
<span class="user-greeting">${t('logged_in_as')} <strong>${APP.user.nickname}</strong></span>
<button class="user-btn logout-btn" onclick="logoutUser()">${t('logout_btn')}</button>
`;
} else {
container.innerHTML = `
<button class="user-btn login-btn" onclick="openAuthModal('login')">${t('login_btn')}</button>
<button class="user-btn register-btn" onclick="openAuthModal('register')">${t('register_btn')}</button>
`;
}
updateLikesFilterBtn();
}
// ===========================
// LIKES FILTER
// ===========================
function updateLikesFilterBtn() {
const btn = document.getElementById('likesFilterBtn');
if (!btn) return;
btn.textContent = APP.showOnlyLiked ? ('❤️ ' + t('my_likes_btn').replace('❤️ ', '') + ' ✓') : t('my_likes_btn');
btn.classList.toggle('active', APP.showOnlyLiked);
}
function toggleLikesFilter() {
APP.showOnlyLiked = !APP.showOnlyLiked;
updateLikesFilterBtn();
filterPosts();
}
// ===========================
async function init() {
try {
const [postsData, catsData, likesData, userData] = await Promise.all([
apiGet('posts'),
apiGet('categories'),
apiGet('likes'),
apiGet('user_check'),
]);
APP.posts = postsData.posts || [];
APP.categories = catsData.categories || [];
APP.likes = likesData.likes || {};
if (userData.loggedIn && userData.user) {
APP.user = userData.user;
const serverLikes = Array.isArray(userData.user.likes) ? userData.user.likes : [];
const localLikes = getLocalLikes();
APP.userLikes = [...new Set([...serverLikes, ...localLikes])];
} else {
const serverLikes = Array.isArray(likesData.userLikes) ? likesData.userLikes : [];
const localLikes = getLocalLikes();
APP.userLikes = [...new Set([...serverLikes, ...localLikes])];
}
} catch (e) {
console.error('API virhe:', e);
}
applyTranslations();
updateUserUI();
renderCategoryFilters();
renderSubFilters();
renderCards();
}
init();