// ===========================
// 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! ✓',
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.',
},
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! ✓',
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.',
}
};
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);
}
// 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;
});
}
// ===========================
// 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 => `
`).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.emoji}
`;
return `
${cardImgHTML}
${getCategoryLabel(p.category)}
${p.title}
${p.author ? `
✍️ ${p.author}
` : ''}
${p.desc || ''}
${metaRow}
${likeCount}
`;
}).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 = cat?.subcategories || [];
if (!subs.length) { container.innerHTML = ''; return; }
container.innerHTML =
`` +
subs.map(s => ``).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 show = matchesCat && matchesSub && 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
? `${p.ingredients.map(i => `- ${i}
`).join('')}
`
: `${t('no_ingredients')}
`;
const stps = p.steps?.length
? `${p.steps.map(s => `- ${s}
`).join('')}
`
: `${t('no_steps')}
`;
const copyBtn = p.ingredients?.length
? ``
: '';
bodyHTML = `${t('modal_ingredients')}
${copyBtn}${ings}${t('modal_steps')}
${stps}`;
} else {
bodyHTML = `${p.body || p.desc}
`;
}
const galleryHTML = p.images?.length
? `${p.images.map(src => `

`).join('')}
`
: '';
document.getElementById('modalContent').innerHTML = `
${p.images?.length ? '' : `${p.emoji}`}
${galleryHTML}
${p.title}
📂 ${getCategoryLabel(p.category)}
${p.time ? ` | ⏱ ${p.time} | 👤 ${p.servings}` : ''}
${p.author ? ` | ✍️ ${t('modal_by')} ${p.author}` : ''}
${likeCount}
${bodyHTML}
`;
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 = 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 = `` +
subs.map(s => ``).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 => ``).join('');
updateSubcategoryPicker(); // asettaa myös oikean tyypin kategorian mukaan
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 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 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,
};
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
// ===========================
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
// ===========================
// 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 { return JSON.parse(localStorage.getItem('tykkaafi_likes') || '[]'); } catch { return []; }
}
function saveLocalLikes(arr) {
try { localStorage.setItem('tykkaafi_likes', JSON.stringify(arr)); } catch {}
}
// ===========================
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 || {};
// Merge server session likes with localStorage — remember likes across sessions
const serverLikes = likesData.userLikes || [];
const localLikes = getLocalLikes();
APP.userLikes = [...new Set([...serverLikes, ...localLikes])];
} catch (e) {
console.error('API virhe:', e);
}
applyTranslations();
renderCategoryFilters();
renderSubFilters();
renderCards();
}
init();
${c.text}