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:
108
api.php
108
api.php
@@ -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 2–30 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');
|
||||||
}
|
}
|
||||||
|
|||||||
19
index.html
19
index.html
@@ -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
207
script.js
@@ -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
147
style.css
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user