Add user registration/login, persistent likes, category hiding, and contact email

- User auth: register (nickname + password + email), login, logout with PHP sessions
- Persistent likes: logged-in users' likes saved to users.json, anonymous via session
- "Tykkäämäni" filter button next to search — filter liked posts, combinable with search
- Hide empty/sparse categories from filter buttons until posts exist
- Replace broken contact form with simple mailto link (info@tykkaa.fi)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 11:08:22 +02:00
parent f14913cb4b
commit 5dfbbacf39
4 changed files with 443 additions and 70 deletions

211
script.js
View File

@@ -15,9 +15,7 @@ const T = {
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! ✓',
contact_desc: 'Kysyttävää tai ehdotuksia? Lähetä meille sähköpostia!',
footer: 'Avoin yhteisö kaikille',
modal_by: 'Kirjoittanut',
modal_ingredients: 'Ainekset', modal_steps: 'Ohjeet',
@@ -27,6 +25,14 @@ const T = {
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',
@@ -41,9 +47,7 @@ const T = {
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! ✓',
contact_desc: 'Questions or suggestions? Send us an email!',
footer: 'Open community for everyone',
modal_by: 'By',
modal_ingredients: 'Ingredients', modal_steps: 'Instructions',
@@ -53,6 +57,14 @@ const T = {
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.',
}
};
@@ -91,6 +103,8 @@ const APP = {
categories: [],
likes: {},
userLikes: [],
user: null,
showOnlyLiked: false,
};
// ===========================
@@ -101,8 +115,20 @@ function getCategoryLabel(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;
const cats = APP.categories.filter(c => countPostsInCategory(c.id) > 0);
const container = document.getElementById('categoryFilters');
if (!container) return;
container.innerHTML =
@@ -138,6 +164,7 @@ async function toggleLike(id) {
document.querySelectorAll(`[data-like-count="${id}"]`).forEach(el => {
el.textContent = count;
});
if (APP.showOnlyLiked) filterPosts();
}
// ===========================
@@ -225,7 +252,7 @@ function renderSubFilters() {
if (!container) return;
if (currentFilter === 'all') { container.innerHTML = ''; return; }
const cat = APP.categories.find(c => c.id === currentFilter);
const subs = cat?.subcategories || [];
const subs = (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>` +
@@ -244,7 +271,9 @@ function filterPosts() {
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;
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++;
});
@@ -443,6 +472,10 @@ function openSubmitModal() {
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() {
@@ -605,20 +638,6 @@ async function submitPublicPost() {
// ===========================
// 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
// ===========================
@@ -638,31 +657,165 @@ function copyIngredients(postId) {
// LOCAL LIKES PERSISTENCE
// ===========================
function getLocalLikes() {
try { return JSON.parse(localStorage.getItem('tykkaafi_likes') || '[]'); } catch { return []; }
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';
function openAuthModal(mode = 'login') {
authMode = mode;
renderAuthForm();
document.getElementById('authOverlay').classList.add('open');
document.body.style.overflow = 'hidden';
}
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>
<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;
}
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] = await Promise.all([
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 || {};
// Merge server session likes with localStorage — remember likes across sessions
const serverLikes = likesData.userLikes || [];
const localLikes = getLocalLikes();
APP.userLikes = [...new Set([...serverLikes, ...localLikes])];
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();