Files
tykkaa.fi/script.js

579 lines
21 KiB
JavaScript

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