Hash-based URLs (#resepti/slug) allow sharing individual posts on social media. Modal shows "Kopioi linkki" button, hash auto-opens post on page load. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
946 lines
36 KiB
JavaScript
946 lines
36 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 postSlug(title) {
|
|
return title.toLowerCase()
|
|
.replace(/[äå]/g, 'a').replace(/ö/g, 'o').replace(/ü/g, 'u')
|
|
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function findPostBySlug(slug) {
|
|
return APP.posts.find(p => postSlug(p.title) === slug);
|
|
}
|
|
|
|
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.submittedBy?.validated ? `<strong>${p.author}</strong> <span class="validated-badge">✔ Kirjautunut</span>` : `${p.author}${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 ? ` | ⏱ ${p.time} | 👤 ${p.servings}` : ''}
|
|
${p.author ? ` | ✍️ ${t('modal_by')} ${p.submittedBy?.validated ? `<strong>${p.author}</strong> <span class="validated-badge">✔ Kirjautunut</span>` : `${p.author}${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>
|
|
<button class="copy-link-btn" onclick="copyPostLink('${postSlug(p.title)}')" style="margin-left:auto;padding:4px 14px;font-size:0.82rem;border:1px solid #ccc;background:#fff;border-radius:20px;cursor:pointer;">🔗 Kopioi linkki</button>
|
|
</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';
|
|
|
|
// Update URL hash for shareable link
|
|
history.replaceState(null, '', '#resepti/' + postSlug(p.title));
|
|
|
|
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 copyPostLink(slug) {
|
|
const url = window.location.origin + window.location.pathname + '#resepti/' + slug;
|
|
navigator.clipboard.writeText(url).then(() => {
|
|
const btn = document.querySelector('.copy-link-btn');
|
|
if (btn) { btn.textContent = '✓ Kopioitu!'; setTimeout(() => { btn.textContent = '🔗 Kopioi linkki'; }, 2000); }
|
|
});
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modalOverlay').classList.remove('open');
|
|
document.body.style.overflow = '';
|
|
// Clear URL hash
|
|
history.replaceState(null, '', window.location.pathname + window.location.search);
|
|
}
|
|
|
|
// ===========================
|
|
// 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();
|
|
|
|
// Open post from URL hash (e.g. #resepti/pinaattiletut)
|
|
const hash = window.location.hash;
|
|
if (hash.startsWith('#resepti/')) {
|
|
const slug = hash.slice('#resepti/'.length);
|
|
const post = findPostBySlug(slug);
|
|
if (post) openPost(post.id);
|
|
}
|
|
}
|
|
|
|
// Handle browser back/forward with hash
|
|
window.addEventListener('hashchange', () => {
|
|
const hash = window.location.hash;
|
|
if (hash.startsWith('#resepti/')) {
|
|
const slug = hash.slice('#resepti/'.length);
|
|
const post = findPostBySlug(slug);
|
|
if (post) openPost(post.id);
|
|
} else {
|
|
closeModal();
|
|
}
|
|
});
|
|
|
|
init();
|