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

108
api.php
View File

@@ -52,6 +52,19 @@ function isAdmin(): bool {
return !empty($_SESSION['tykkaafi_admin']); return !empty($_SESSION['tykkaafi_admin']);
} }
function isLoggedIn(): bool {
return !empty($_SESSION['tykkaafi_user_id']);
}
function getLoggedInUser(): ?array {
if (!isLoggedIn()) return null;
$users = readData('users.json', []);
foreach ($users as $u) {
if ($u['id'] === $_SESSION['tykkaafi_user_id']) return $u;
}
return null;
}
// ─── Oletusdata ──────────────────────────────────────────────── // ─── Oletusdata ────────────────────────────────────────────────
// Kategoriat jotka on poistettu — poistetaan automaattisesti live-serveriltä // Kategoriat jotka on poistettu — poistetaan automaattisesti live-serveriltä
const REMOVED_CATEGORIES = ['knitting', 'kasvit', 'matkustus', 'tips']; const REMOVED_CATEGORIES = ['knitting', 'kasvit', 'matkustus', 'tips'];
@@ -692,13 +705,40 @@ switch ($action) {
case 'likes': case 'likes':
$likes = readData('likes.json', new stdClass()); $likes = readData('likes.json', new stdClass());
$user = getLoggedInUser();
if ($user) {
$userLikes = $user['likes'] ?? [];
} else {
$userLikes = $_SESSION['user_likes'] ?? []; $userLikes = $_SESSION['user_likes'] ?? [];
}
ok(['likes' => $likes, 'userLikes' => $userLikes]); ok(['likes' => $likes, 'userLikes' => $userLikes]);
case 'toggle_like': case 'toggle_like':
$postId = $body['postId'] ?? ''; $postId = $body['postId'] ?? '';
if (!$postId) err('Missing postId'); if (!$postId) err('Missing postId');
$likes = readData('likes.json', []); $likes = readData('likes.json', []);
$user = getLoggedInUser();
if ($user) {
$users = readData('users.json', []);
$userLikes = $user['likes'] ?? [];
$idx = array_search($postId, $userLikes, true);
if ($idx === false) {
$likes[$postId] = ($likes[$postId] ?? 0) + 1;
$userLikes[] = $postId;
$liked = true;
} else {
$likes[$postId] = max(0, ($likes[$postId] ?? 1) - 1);
array_splice($userLikes, $idx, 1);
$liked = false;
}
foreach ($users as &$u) {
if ($u['id'] === $user['id']) { $u['likes'] = array_values($userLikes); break; }
}
unset($u);
writeData('users.json', $users);
$_SESSION['user_likes'] = array_values($userLikes);
} else {
$userLikes = $_SESSION['user_likes'] ?? []; $userLikes = $_SESSION['user_likes'] ?? [];
$idx = array_search($postId, $userLikes, true); $idx = array_search($postId, $userLikes, true);
if ($idx === false) { if ($idx === false) {
@@ -711,6 +751,8 @@ switch ($action) {
$liked = false; $liked = false;
} }
$_SESSION['user_likes'] = array_values($userLikes); $_SESSION['user_likes'] = array_values($userLikes);
}
writeData('likes.json', $likes); writeData('likes.json', $likes);
ok(['liked' => $liked, 'count' => $likes[$postId] ?? 0]); ok(['liked' => $liked, 'count' => $likes[$postId] ?? 0]);
@@ -801,6 +843,72 @@ switch ($action) {
case 'admin_check': case 'admin_check':
ok(['loggedIn' => isAdmin()]); ok(['loggedIn' => isAdmin()]);
// ─── Käyttäjätunnukset ─────────────────────────────────────
case 'user_register':
$nickname = trim($body['nickname'] ?? '');
$email = trim($body['email'] ?? '');
$password = $body['password'] ?? '';
if (!$nickname || !$password) err('Nimimerkki ja salasana vaaditaan.');
if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 30) err('Nimimerkin tulee olla 230 merkkiä.');
if (mb_strlen($password) < 6) err('Salasanan tulee olla vähintään 6 merkkiä.');
if ($email && !filter_var($email, FILTER_VALIDATE_EMAIL)) err('Sähköpostiosoite ei kelpaa.');
$users = readData('users.json', []);
foreach ($users as $u) {
if (mb_strtolower($u['nickname']) === mb_strtolower($nickname)) {
err('Nimimerkki on jo käytössä.');
}
}
$user = [
'id' => 'user_' . time() . '_' . random_int(1000, 9999),
'nickname' => htmlspecialchars($nickname, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'email' => htmlspecialchars($email, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'password' => password_hash($password, PASSWORD_DEFAULT),
'likes' => $_SESSION['user_likes'] ?? [],
'created' => date('Y-m-d'),
];
$users[] = $user;
writeData('users.json', $users);
$_SESSION['tykkaafi_user_id'] = $user['id'];
ok(['user' => ['id' => $user['id'], 'nickname' => $user['nickname'], 'likes' => $user['likes']]]);
case 'user_login':
$nickname = trim($body['nickname'] ?? '');
$password = $body['password'] ?? '';
if (!$nickname || !$password) err('Nimimerkki ja salasana vaaditaan.');
$users = readData('users.json', []);
foreach ($users as $u) {
if (mb_strtolower($u['nickname']) === mb_strtolower($nickname)) {
if (password_verify($password, $u['password'])) {
$_SESSION['tykkaafi_user_id'] = $u['id'];
$_SESSION['user_likes'] = $u['likes'] ?? [];
ok(['user' => ['id' => $u['id'], 'nickname' => $u['nickname'], 'likes' => $u['likes'] ?? []]]);
}
err('Väärä salasana.');
}
}
err('Nimimerkkiä ei löydy.');
case 'user_logout':
unset($_SESSION['tykkaafi_user_id']);
ok();
case 'user_check':
$user = getLoggedInUser();
if ($user) {
ok(['loggedIn' => true, 'user' => [
'id' => $user['id'],
'nickname' => $user['nickname'],
'likes' => $user['likes'] ?? [],
]]);
}
ok(['loggedIn' => false]);
default: default:
err('Unknown action'); err('Unknown action');
} }

View File

@@ -17,6 +17,7 @@
<h1 class="pixel-logo"><span class="logo-heart"></span>tykkää<span class="logo-fi">.fi</span></h1> <h1 class="pixel-logo"><span class="logo-heart"></span>tykkää<span class="logo-fi">.fi</span></h1>
</div> </div>
<p class="tagline" data-i18n="tagline"></p> <p class="tagline" data-i18n="tagline"></p>
<div class="user-area" id="userArea"></div>
<nav> <nav>
<a href="#posts" data-i18n="nav_posts"></a> <a href="#posts" data-i18n="nav_posts"></a>
<a href="#about" data-i18n="nav_about"></a> <a href="#about" data-i18n="nav_about"></a>
@@ -29,7 +30,10 @@
<!-- SEARCH & FILTER --> <!-- SEARCH & FILTER -->
<section class="controls" id="posts"> <section class="controls" id="posts">
<div class="container"> <div class="container">
<div class="search-row">
<input type="text" id="search" data-i18n-ph="search_ph" oninput="filterPosts()" /> <input type="text" id="search" data-i18n-ph="search_ph" oninput="filterPosts()" />
<button class="likes-filter-btn" id="likesFilterBtn" onclick="toggleLikesFilter()">❤️ Tykkäämäni</button>
</div>
<div class="filters" id="categoryFilters"></div> <div class="filters" id="categoryFilters"></div>
<div class="filters sub-filters" id="subCategoryFilters"></div> <div class="filters sub-filters" id="subCategoryFilters"></div>
<button class="add-post-btn" onclick="openSubmitModal()">✏️ Lisää julkaisu</button> <button class="add-post-btn" onclick="openSubmitModal()">✏️ Lisää julkaisu</button>
@@ -60,12 +64,7 @@
<div class="container"> <div class="container">
<h2 data-i18n="contact_title"></h2> <h2 data-i18n="contact_title"></h2>
<p data-i18n="contact_desc"></p> <p data-i18n="contact_desc"></p>
<form class="contact-form" onsubmit="handleSubmit(event)"> <a href="mailto:info@tykkaa.fi" class="contact-email">info@tykkaa.fi</a>
<input type="text" data-i18n-ph="name_ph" required />
<input type="email" data-i18n-ph="email_ph" required />
<textarea data-i18n-ph="msg_ph" rows="4" required></textarea>
<button type="submit" class="btn" data-i18n="send_btn"></button>
</form>
</div> </div>
</section> </section>
@@ -190,6 +189,14 @@
<img id="lightboxImg" src="" alt="" onclick="event.stopPropagation()" /> <img id="lightboxImg" src="" alt="" onclick="event.stopPropagation()" />
</div> </div>
<!-- AUTH MODAL -->
<div class="modal-overlay" id="authOverlay" onclick="closeAuthModal()">
<div class="modal" onclick="event.stopPropagation()" style="max-width:420px">
<button class="modal-close" onclick="closeAuthModal()"></button>
<div id="authContent"></div>
</div>
</div>
<script src="script.js"></script> <script src="script.js"></script>
</body> </body>
</html> </html>

207
script.js
View File

@@ -15,9 +15,7 @@ const T = {
about_title: 'Mikä tykkää.fi?', 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!', 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_title: 'Ota yhteyttä',
contact_desc: 'Kysyttävää tai ehdotuksia? Lähetä meille viestiä!', contact_desc: 'Kysyttävää tai ehdotuksia? Lähetä meille sähköpostia!',
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', footer: 'Avoin yhteisö kaikille',
modal_by: 'Kirjoittanut', modal_by: 'Kirjoittanut',
modal_ingredients: 'Ainekset', modal_steps: 'Ohjeet', modal_ingredients: 'Ainekset', modal_steps: 'Ohjeet',
@@ -27,6 +25,14 @@ const T = {
comment_btn: 'Lähetä kommentti', comment_btn: 'Lähetä kommentti',
like_btn: '❤️ Tykkää', liked_btn: '❤️ Tykätty', like_btn: '❤️ Tykkää', liked_btn: '❤️ Tykätty',
no_ingredients: 'Aineksia ei lisätty.', no_steps: 'Ohjeita ei lisä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: { en: {
tagline: 'Share things you love — recipes, tips, ideas', tagline: 'Share things you love — recipes, tips, ideas',
@@ -41,9 +47,7 @@ const T = {
about_title: 'What is tykkää.fi?', 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!', 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_title: 'Get in Touch',
contact_desc: 'Questions or suggestions? Send us a message!', contact_desc: 'Questions or suggestions? Send us an email!',
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', footer: 'Open community for everyone',
modal_by: 'By', modal_by: 'By',
modal_ingredients: 'Ingredients', modal_steps: 'Instructions', modal_ingredients: 'Ingredients', modal_steps: 'Instructions',
@@ -53,6 +57,14 @@ const T = {
comment_btn: 'Post Comment', comment_btn: 'Post Comment',
like_btn: '❤️ Like', liked_btn: '❤️ Liked', like_btn: '❤️ Like', liked_btn: '❤️ Liked',
no_ingredients: 'No ingredients listed.', no_steps: 'No instructions listed.', 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: [], categories: [],
likes: {}, likes: {},
userLikes: [], userLikes: [],
user: null,
showOnlyLiked: false,
}; };
// =========================== // ===========================
@@ -101,8 +115,20 @@ function getCategoryLabel(catId) {
return cat ? cat.fi : 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() { function renderCategoryFilters() {
const cats = APP.categories; const cats = APP.categories.filter(c => countPostsInCategory(c.id) > 0);
const container = document.getElementById('categoryFilters'); const container = document.getElementById('categoryFilters');
if (!container) return; if (!container) return;
container.innerHTML = container.innerHTML =
@@ -138,6 +164,7 @@ async function toggleLike(id) {
document.querySelectorAll(`[data-like-count="${id}"]`).forEach(el => { document.querySelectorAll(`[data-like-count="${id}"]`).forEach(el => {
el.textContent = count; el.textContent = count;
}); });
if (APP.showOnlyLiked) filterPosts();
} }
// =========================== // ===========================
@@ -225,7 +252,7 @@ function renderSubFilters() {
if (!container) return; if (!container) return;
if (currentFilter === 'all') { container.innerHTML = ''; return; } if (currentFilter === 'all') { container.innerHTML = ''; return; }
const cat = APP.categories.find(c => c.id === currentFilter); 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; } if (!subs.length) { container.innerHTML = ''; return; }
container.innerHTML = container.innerHTML =
`<button class="sub-filter-btn ${currentSubFilter === 'all' ? 'active' : ''}" onclick="setSubFilter('all',this)">Kaikki</button>` + `<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 desc = (card.querySelector('p:not(.card-author)')?.textContent || '').toLowerCase();
const subLbl = (card.dataset.subcategory || '').toLowerCase(); const subLbl = (card.dataset.subcategory || '').toLowerCase();
const matchesSearch = !query || title.includes(query) || desc.includes(query) || subLbl.includes(query); 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'; card.style.display = show ? '' : 'none';
if (show) visible++; if (show) visible++;
}); });
@@ -443,6 +472,10 @@ function openSubmitModal() {
document.getElementById('submitOverlay').classList.add('open'); document.getElementById('submitOverlay').classList.add('open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
generateSubmitCaptcha(); generateSubmitCaptcha();
if (APP.user) {
const authorField = document.getElementById('sub-author');
if (authorField && !authorField.value) authorField.value = APP.user.nickname;
}
} }
function closeSubmitModal() { function closeSubmitModal() {
@@ -605,20 +638,6 @@ async function submitPublicPost() {
// =========================== // ===========================
// CONTACT FORM // 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 // INIT
// =========================== // ===========================
@@ -638,31 +657,165 @@ function copyIngredients(postId) {
// LOCAL LIKES PERSISTENCE // LOCAL LIKES PERSISTENCE
// =========================== // ===========================
function getLocalLikes() { 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) { function saveLocalLikes(arr) {
try { localStorage.setItem('tykkaafi_likes', JSON.stringify(arr)); } catch {} 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() { async function init() {
try { try {
const [postsData, catsData, likesData] = await Promise.all([ const [postsData, catsData, likesData, userData] = await Promise.all([
apiGet('posts'), apiGet('posts'),
apiGet('categories'), apiGet('categories'),
apiGet('likes'), apiGet('likes'),
apiGet('user_check'),
]); ]);
APP.posts = postsData.posts || []; APP.posts = postsData.posts || [];
APP.categories = catsData.categories || []; APP.categories = catsData.categories || [];
APP.likes = likesData.likes || {}; APP.likes = likesData.likes || {};
// Merge server session likes with localStorage — remember likes across sessions
const serverLikes = likesData.userLikes || []; if (userData.loggedIn && userData.user) {
APP.user = userData.user;
const serverLikes = Array.isArray(userData.user.likes) ? userData.user.likes : [];
const localLikes = getLocalLikes(); const localLikes = getLocalLikes();
APP.userLikes = [...new Set([...serverLikes, ...localLikes])]; 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) { } catch (e) {
console.error('API virhe:', e); console.error('API virhe:', e);
} }
applyTranslations(); applyTranslations();
updateUserUI();
renderCategoryFilters(); renderCategoryFilters();
renderSubFilters(); renderSubFilters();
renderCards(); renderCards();

147
style.css
View File

@@ -324,30 +324,18 @@ nav a:hover { color: #fff; }
.contact h2 { font-size: 1.9rem; color: var(--warm-brown); margin-bottom: 8px; } .contact h2 { font-size: 1.9rem; color: var(--warm-brown); margin-bottom: 8px; }
.contact > .container > p { color: var(--text-light); margin-bottom: 28px; } .contact > .container > p { color: var(--text-light); margin-bottom: 28px; }
.contact-form { .contact-email {
display: flex; font-size: 1.3rem;
flex-direction: column;
gap: 14px;
max-width: 520px;
margin: 0 auto;
}
.contact-form input,
.contact-form textarea {
padding: 12px 18px;
border: 2px solid var(--border);
border-radius: 10px;
font-size: 1rem;
font-family: 'Georgia', serif; font-family: 'Georgia', serif;
background: #fff; color: var(--accent);
color: var(--text); text-decoration: none;
outline: none; transition: color 0.2s;
transition: border-color 0.2s;
resize: vertical;
} }
.contact-form input:focus, .contact-email:hover {
.contact-form textarea:focus { border-color: var(--accent); } color: #a0522d;
text-decoration: underline;
}
/* ===================== /* =====================
FOOTER FOOTER
@@ -770,6 +758,120 @@ footer {
font-weight: bold; font-weight: bold;
} }
/* =====================
USER AREA (header)
===================== */
.user-area {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 8px 0;
flex-wrap: wrap;
}
.user-greeting {
color: #e8c9a8;
font-family: 'Arial', sans-serif;
font-size: 0.85rem;
}
.user-greeting strong { color: #fff; }
.user-btn {
padding: 6px 16px;
border: 2px solid rgba(255,255,255,0.4);
border-radius: 20px;
background: transparent;
color: #f5dfc0;
font-family: 'Arial', sans-serif;
font-size: 0.82rem;
font-weight: bold;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.2s;
}
.user-btn:hover {
background: rgba(255,255,255,0.15);
color: #fff;
border-color: #fff;
}
.register-btn {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.register-btn:hover {
background: var(--accent-dark);
border-color: var(--accent-dark);
}
/* =====================
SEARCH ROW
===================== */
.search-row {
display: flex;
gap: 10px;
width: 100%;
max-width: 600px;
align-items: center;
}
.search-row #search { flex: 1; }
.likes-filter-btn {
padding: 8px 18px;
border: 2px solid var(--border);
border-radius: 20px;
background: #fff;
color: #c06080;
font-family: 'Arial', sans-serif;
font-size: 0.85rem;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.likes-filter-btn:hover {
background: #fde8f0;
border-color: #e07090;
}
.likes-filter-btn.active {
background: #fde8f0;
border-color: #e07090;
color: #c03060;
}
/* =====================
AUTH MODAL
===================== */
.auth-error {
color: #c04040;
font-size: 0.9rem;
margin-top: 8px;
display: none;
}
.auth-switch {
text-align: center;
margin-top: 14px;
font-family: 'Arial', sans-serif;
font-size: 0.85rem;
color: var(--accent);
cursor: pointer;
transition: color 0.2s;
}
.auth-switch:hover {
color: var(--accent-dark);
text-decoration: underline;
}
/* ===================== /* =====================
RESPONSIVE RESPONSIVE
===================== */ ===================== */
@@ -779,4 +881,7 @@ footer {
.recipe-grid { grid-template-columns: 1fr; } .recipe-grid { grid-template-columns: 1fr; }
nav { gap: 16px; } nav { gap: 16px; }
.form-row { grid-template-columns: 1fr; } .form-row { grid-template-columns: 1fr; }
.search-row { flex-direction: column; }
.likes-filter-btn { width: 100%; }
.user-area { font-size: 0.78rem; }
} }