Initial commit: tykkää.fi sivusto
- Julkaisualusta resepteille, neuloville, vinkeille - PHP-backend (api.php) palvelinpuolen datalle - Admin-paneeli salasanasuojauksella - Kuvaupload (upload.php) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Data-kansio (palvelimen omat tiedot, ei versiohallintaan)
|
||||||
|
data/*.json
|
||||||
|
|
||||||
|
# Kuvat (isot tiedostot, ei versiohallintaan)
|
||||||
|
images/*
|
||||||
|
!images/.gitkeep
|
||||||
|
|
||||||
|
# Claude Code -asetukset
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editorit
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
756
admin.html
Normal file
756
admin.html
Normal file
@@ -0,0 +1,756 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Hallinta / Admin — tykkää.fi</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
<style>
|
||||||
|
body { background: #f0e8dc; }
|
||||||
|
|
||||||
|
.admin-header {
|
||||||
|
background: #3b2a1a;
|
||||||
|
padding: 20px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.admin-header .logo { gap: 10px; }
|
||||||
|
.admin-header h1 { font-size: 1.4rem; color: #fff; }
|
||||||
|
.admin-header a { color: #e8b88a; font-family: Arial, sans-serif; font-size: 0.9rem; text-decoration: none; }
|
||||||
|
.admin-header a:hover { color: #fff; }
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr;
|
||||||
|
gap: 28px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 36px auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
@media (max-width: 860px) { .admin-main { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
.admin-panel {
|
||||||
|
background: #fff9f2;
|
||||||
|
border: 1px solid #e8d5c0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: 0 4px 16px rgba(124,74,30,0.1);
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-panel h2 {
|
||||||
|
color: #7c4a1e;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #e8d5c0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 14px; }
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #7a5c3e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.form-group input, .form-group select, .form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 2px solid #e8d5c0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
background: #fff;
|
||||||
|
color: #3b2a1a;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.form-group input:focus, .form-group select:focus, .form-group textarea:focus { border-color: #e07b39; }
|
||||||
|
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
|
||||||
|
|
||||||
|
.type-toggle { display: flex; gap: 10px; margin-bottom: 14px; }
|
||||||
|
.type-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid #e8d5c0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #7a5c3e;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.type-btn.active { background: #e07b39; border-color: #e07b39; color: #fff; }
|
||||||
|
|
||||||
|
.recipe-fields, .post-fields { display: none; }
|
||||||
|
.recipe-fields.visible, .post-fields.visible { display: block; }
|
||||||
|
|
||||||
|
.dynamic-list { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.dynamic-item { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.dynamic-item input { flex: 1; margin: 0; }
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
background: none; border: none; color: #c0856a;
|
||||||
|
font-size: 1.1rem; cursor: pointer; padding: 4px 8px;
|
||||||
|
border-radius: 6px; transition: all 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.remove-btn:hover { background: #fde8d0; color: #c4612a; }
|
||||||
|
|
||||||
|
.add-item-btn {
|
||||||
|
background: none; border: 2px dashed #c28b5a; border-radius: 8px;
|
||||||
|
color: #c28b5a; font-family: Arial, sans-serif; font-size: 0.85rem;
|
||||||
|
padding: 7px; cursor: pointer; width: 100%; transition: all 0.2s; margin-top: 5px;
|
||||||
|
}
|
||||||
|
.add-item-btn:hover { background: #fde8d0; border-color: #e07b39; color: #e07b39; }
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
width: 100%; padding: 13px; background: #e07b39; color: #fff;
|
||||||
|
border: none; border-radius: 10px; font-family: Arial, sans-serif;
|
||||||
|
font-size: 1rem; font-weight: bold; cursor: pointer; transition: all 0.2s; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.save-btn:hover { background: #c4612a; transform: translateY(-2px); }
|
||||||
|
|
||||||
|
/* Post list */
|
||||||
|
.post-list { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.post-item {
|
||||||
|
background: #fff; border: 1px solid #e8d5c0; border-radius: 10px;
|
||||||
|
padding: 13px 16px; display: flex; align-items: center; gap: 12px;
|
||||||
|
}
|
||||||
|
.post-item-emoji { font-size: 1.8rem; }
|
||||||
|
.post-item-info { flex: 1; }
|
||||||
|
.post-item-info h3 { font-size: 0.95rem; color: #7c4a1e; margin-bottom: 2px; }
|
||||||
|
.post-item-info p { font-size: 0.78rem; color: #7a5c3e; font-family: Arial, sans-serif; }
|
||||||
|
.post-item-actions { display: flex; gap: 7px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
padding: 6px 12px; background: none; border: 2px solid #c28b5a;
|
||||||
|
border-radius: 7px; color: #7c4a1e; font-family: Arial, sans-serif;
|
||||||
|
font-size: 0.8rem; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.edit-btn:hover { background: #fde8d0; }
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
padding: 6px 12px; background: none; border: 2px solid #e8c0c0;
|
||||||
|
border-radius: 7px; color: #c04040; font-family: Arial, sans-serif;
|
||||||
|
font-size: 0.8rem; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.delete-btn:hover { background: #fde8e8; border-color: #c04040; }
|
||||||
|
|
||||||
|
/* Category management */
|
||||||
|
.cat-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||||
|
.cat-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
background: #fff; border: 1px solid #e8d5c0; border-radius: 8px; padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.cat-emoji { font-size: 1.4rem; }
|
||||||
|
.cat-info { flex: 1; font-family: Arial, sans-serif; font-size: 0.88rem; color: #3b2a1a; }
|
||||||
|
.cat-info span { color: #7a5c3e; font-size: 0.78rem; }
|
||||||
|
|
||||||
|
.empty-state { text-align: center; color: #7a5c3e; font-style: italic; padding: 20px 0; }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 28px; left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(80px);
|
||||||
|
background: #7c4a1e; color: #fff; padding: 13px 26px; border-radius: 30px;
|
||||||
|
font-family: Arial, sans-serif; font-size: 0.92rem;
|
||||||
|
box-shadow: 0 6px 24px rgba(0,0,0,0.3); transition: transform 0.3s; z-index: 2000;
|
||||||
|
}
|
||||||
|
.toast.show { transform: translateX(-50%) translateY(0); }
|
||||||
|
|
||||||
|
.section-right { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* Login overlay */
|
||||||
|
.login-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(59,42,26,0.85);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
background: #fff9f2; border-radius: 20px; padding: 40px 36px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0,0,0,0.4); text-align: center;
|
||||||
|
min-width: 320px;
|
||||||
|
}
|
||||||
|
.login-box h2 { color: #7c4a1e; margin-bottom: 8px; font-size: 1.4rem; }
|
||||||
|
.login-box p { color: #7a5c3e; font-family: Arial, sans-serif; font-size: 0.9rem; margin-bottom: 20px; }
|
||||||
|
.login-box input {
|
||||||
|
width: 100%; padding: 11px 14px; border: 2px solid #e8d5c0; border-radius: 10px;
|
||||||
|
font-size: 1rem; font-family: Georgia, serif; color: #3b2a1a; outline: none;
|
||||||
|
margin-bottom: 12px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.login-box input:focus { border-color: #e07b39; }
|
||||||
|
.login-box button {
|
||||||
|
width: 100%; padding: 12px; background: #e07b39; color: #fff;
|
||||||
|
border: none; border-radius: 10px; font-family: Arial, sans-serif;
|
||||||
|
font-size: 1rem; font-weight: bold; cursor: pointer; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.login-box button:hover { background: #c4612a; }
|
||||||
|
.login-error { color: #c04040; font-family: Arial, sans-serif; font-size: 0.88rem; margin-top: 8px; min-height: 18px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- LOGIN OVERLAY -->
|
||||||
|
<div class="login-overlay" id="loginOverlay">
|
||||||
|
<div class="login-box">
|
||||||
|
<div style="font-size:2.4rem;margin-bottom:10px">🍳</div>
|
||||||
|
<h2>tykkää.fi — Hallinta</h2>
|
||||||
|
<p>Kirjaudu sisään jatkaaksesi</p>
|
||||||
|
<input type="password" id="adminPwInput" placeholder="Salasana" onkeydown="if(event.key==='Enter')doLogin()" />
|
||||||
|
<button onclick="doLogin()">🔐 Kirjaudu</button>
|
||||||
|
<div class="login-error" id="loginError"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="admin-header">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">🍳</span>
|
||||||
|
<h1>tykkää.fi — Hallinta / Admin</h1>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:16px;align-items:center;">
|
||||||
|
<a href="index.html" id="backLink">← Takaisin blogiin</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
|
||||||
|
<!-- LEFT: ADD / EDIT FORM -->
|
||||||
|
<div>
|
||||||
|
<section class="admin-panel">
|
||||||
|
<h2 id="formTitle">Lisää uusi julkaisu</h2>
|
||||||
|
|
||||||
|
<!-- Type toggle -->
|
||||||
|
<div class="type-toggle">
|
||||||
|
<button class="type-btn active" id="typeRecipeBtn" onclick="setType('recipe')">🍳 Resepti / Recipe</button>
|
||||||
|
<button class="type-btn" id="typePostBtn" onclick="setType('post')">📝 Julkaisu / Post</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group" style="grid-column:1/3">
|
||||||
|
<label id="lbl_title">Otsikko / Title</label>
|
||||||
|
<input type="text" id="postTitle" placeholder="esim. Mustikkamuffinit" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_category">Kategoria / Category</label>
|
||||||
|
<select id="postCategory"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_emoji">Emoji</label>
|
||||||
|
<input type="text" id="postEmoji" placeholder="🍽️" maxlength="4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_author">Kirjoittaja / Author</label>
|
||||||
|
<input type="text" id="postAuthor" placeholder="esim. Anna K." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_desc">Kuvaus / Description</label>
|
||||||
|
<textarea id="postDesc" rows="2" placeholder="Lyhyt houkutteleva kuvaus..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kuvat / Images <small style="color:#7a5c3e;font-weight:normal;text-transform:none">(valinnainen / optional, max 3)</small></label>
|
||||||
|
<div class="img-upload-slots">
|
||||||
|
<div class="img-upload-slot">
|
||||||
|
<label class="img-upload-btn" id="adm-lbl1">📷 Kuva 1
|
||||||
|
<input type="file" accept="image/*" onchange="uploadImgAdmin(this,'postImg1','adm-prev1','adm-lbl1')" style="display:none" />
|
||||||
|
</label>
|
||||||
|
<img class="img-preview" id="adm-prev1" alt="" />
|
||||||
|
<input type="hidden" id="postImg1" />
|
||||||
|
</div>
|
||||||
|
<div class="img-upload-slot">
|
||||||
|
<label class="img-upload-btn" id="adm-lbl2">📷 Kuva 2
|
||||||
|
<input type="file" accept="image/*" onchange="uploadImgAdmin(this,'postImg2','adm-prev2','adm-lbl2')" style="display:none" />
|
||||||
|
</label>
|
||||||
|
<img class="img-preview" id="adm-prev2" alt="" />
|
||||||
|
<input type="hidden" id="postImg2" />
|
||||||
|
</div>
|
||||||
|
<div class="img-upload-slot">
|
||||||
|
<label class="img-upload-btn" id="adm-lbl3">📷 Kuva 3
|
||||||
|
<input type="file" accept="image/*" onchange="uploadImgAdmin(this,'postImg3','adm-prev3','adm-lbl3')" style="display:none" />
|
||||||
|
</label>
|
||||||
|
<img class="img-preview" id="adm-prev3" alt="" />
|
||||||
|
<input type="hidden" id="postImg3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RECIPE FIELDS -->
|
||||||
|
<div class="recipe-fields visible" id="recipeFields">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_time">Valmistusaika / Time</label>
|
||||||
|
<input type="text" id="postTime" placeholder="esim. 30 min" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_servings">Annoksia / Servings</label>
|
||||||
|
<input type="text" id="postServings" placeholder="esim. 4 annosta" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_ingredients">Ainekset / Ingredients</label>
|
||||||
|
<div class="dynamic-list" id="ingredientsList">
|
||||||
|
<div class="dynamic-item">
|
||||||
|
<input type="text" placeholder="esim. 2 dl vehnäjauhoja" />
|
||||||
|
<button class="remove-btn" onclick="removeItem(this)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-item-btn" onclick="addIngredient()">+ Lisää aines / Add ingredient</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_steps">Ohjeet / Instructions</label>
|
||||||
|
<div class="dynamic-list" id="stepsList">
|
||||||
|
<div class="dynamic-item">
|
||||||
|
<input type="text" placeholder="Vaihe 1..." />
|
||||||
|
<button class="remove-btn" onclick="removeItem(this)">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="add-item-btn" onclick="addStep()">+ Lisää vaihe / Add step</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- POST FIELDS -->
|
||||||
|
<div class="post-fields" id="postFields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="lbl_body">Sisältö / Content <small style="color:#7a5c3e;font-weight:normal;text-transform:none">(HTML sallittu)</small></label>
|
||||||
|
<textarea id="postBody" rows="8" placeholder="<p>Kirjoita sisältö tähän...</p>"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="save-btn" id="saveBtn" onclick="savePost()">💾 Tallenna / Save</button>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Categories + Post list -->
|
||||||
|
<div class="section-right">
|
||||||
|
|
||||||
|
<!-- CATEGORIES -->
|
||||||
|
<section class="admin-panel">
|
||||||
|
<h2 id="lbl_categories">Kategoriat / Categories</h2>
|
||||||
|
<div class="cat-list" id="catList"></div>
|
||||||
|
<div class="form-row-3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Suomi / Finnish</label>
|
||||||
|
<input type="text" id="newCatFi" placeholder="esim. Leivonta" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>English</label>
|
||||||
|
<input type="text" id="newCatEn" placeholder="e.g. Baking" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Emoji</label>
|
||||||
|
<input type="text" id="newCatEmoji" placeholder="🥐" maxlength="4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="save-btn" onclick="addCategory()" style="margin-top:0">+ Lisää kategoria / Add category</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- POST LIST -->
|
||||||
|
<section class="admin-panel">
|
||||||
|
<h2 id="lbl_all_posts">Kaikki julkaisut / All Posts</h2>
|
||||||
|
<div class="post-list" id="postList"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ===========================
|
||||||
|
// MINI i18n FOR ADMIN
|
||||||
|
// ===========================
|
||||||
|
const AT = {
|
||||||
|
fi: {
|
||||||
|
back: '← Takaisin blogiin',
|
||||||
|
form_add: 'Lisää uusi julkaisu', form_edit: 'Muokkaa julkaisua',
|
||||||
|
save: '💾 Tallenna', saved: '✅ Tallennettu!', updated: '✅ Päivitetty!',
|
||||||
|
deleted: '🗑️ Poistettu.', confirm_del: 'Haluatko varmasti poistaa tämän?',
|
||||||
|
empty: 'Ei julkaisuja vielä.', no_title: '⚠️ Anna julkaisulle otsikko.',
|
||||||
|
muokkaa: 'Muokkaa', poista: 'Poista',
|
||||||
|
cat_empty: 'Ei kategorioita. Lisää ensimmäinen!',
|
||||||
|
cat_no_name: '⚠️ Anna kategorialle nimi (suomi + english).',
|
||||||
|
cat_deleted: '🗑️ Kategoria poistettu.',
|
||||||
|
cat_added: '✅ Kategoria lisätty!',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
back: '← Back to Blog',
|
||||||
|
form_add: 'Add New Post', form_edit: 'Edit Post',
|
||||||
|
save: '💾 Save', saved: '✅ Saved!', updated: '✅ Updated!',
|
||||||
|
deleted: '🗑️ Deleted.', confirm_del: 'Delete this post?',
|
||||||
|
empty: 'No posts yet.', no_title: '⚠️ Please enter a title.',
|
||||||
|
muokkaa: 'Edit', poista: 'Delete',
|
||||||
|
cat_empty: 'No categories yet. Add the first one!',
|
||||||
|
cat_no_name: '⚠️ Enter both Finnish and English names.',
|
||||||
|
cat_deleted: '🗑️ Category deleted.',
|
||||||
|
cat_added: '✅ Category added!',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lang = 'fi';
|
||||||
|
function at(k) { return (AT[lang] && AT[lang][k]) ? AT[lang][k] : k; }
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// ADMIN STATE
|
||||||
|
// ===========================
|
||||||
|
const ADMIN = { posts: [], categories: [] };
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// LOGIN
|
||||||
|
// ===========================
|
||||||
|
async function doLogin() {
|
||||||
|
const pw = document.getElementById('adminPwInput').value;
|
||||||
|
const err = document.getElementById('loginError');
|
||||||
|
err.textContent = '';
|
||||||
|
const data = await apiPost('admin_login', { password: pw });
|
||||||
|
if (data.ok) {
|
||||||
|
document.getElementById('loginOverlay').style.display = 'none';
|
||||||
|
await loadAdminData();
|
||||||
|
} else {
|
||||||
|
err.textContent = data.error || 'Kirjautuminen epäonnistui.';
|
||||||
|
document.getElementById('adminPwInput').value = '';
|
||||||
|
document.getElementById('adminPwInput').focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminData() {
|
||||||
|
const [postsData, catsData] = await Promise.all([
|
||||||
|
apiGet('posts'),
|
||||||
|
apiGet('categories'),
|
||||||
|
]);
|
||||||
|
ADMIN.posts = postsData.posts || [];
|
||||||
|
ADMIN.categories = catsData.categories || [];
|
||||||
|
document.getElementById('backLink').textContent = at('back');
|
||||||
|
document.getElementById('saveBtn').textContent = at('save');
|
||||||
|
populateCategorySelect();
|
||||||
|
renderCatList();
|
||||||
|
renderPostList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// IMAGE UPLOAD
|
||||||
|
// ===========================
|
||||||
|
async function uploadImgAdmin(input, hiddenId, previewId, labelId) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('upload.php', { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById(hiddenId).value = data.url;
|
||||||
|
const prev = document.getElementById(previewId);
|
||||||
|
prev.src = data.url;
|
||||||
|
prev.style.display = 'block';
|
||||||
|
document.getElementById(labelId).classList.add('has-image');
|
||||||
|
} catch (e) {
|
||||||
|
showToast('⚠️ Kuvan lataus epäonnistui.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAdmImgPreview(idx, url) {
|
||||||
|
const prev = document.getElementById(`adm-prev${idx}`);
|
||||||
|
const lbl = document.getElementById(`adm-lbl${idx}`);
|
||||||
|
if (url) {
|
||||||
|
document.getElementById(`postImg${idx}`).value = url;
|
||||||
|
prev.src = url; prev.style.display = 'block';
|
||||||
|
lbl.classList.add('has-image');
|
||||||
|
} else {
|
||||||
|
document.getElementById(`postImg${idx}`).value = '';
|
||||||
|
prev.src = ''; prev.style.display = 'none';
|
||||||
|
lbl.classList.remove('has-image');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// CATEGORY HELPERS
|
||||||
|
// ===========================
|
||||||
|
function getCatLabel(id) {
|
||||||
|
const cat = ADMIN.categories.find(c => c.id === id);
|
||||||
|
return cat ? cat.fi : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// CATEGORY MANAGEMENT
|
||||||
|
// ===========================
|
||||||
|
function renderCatList() {
|
||||||
|
const cats = ADMIN.categories;
|
||||||
|
const el = document.getElementById('catList');
|
||||||
|
if (!cats.length) { el.innerHTML = `<p class="empty-state">${at('cat_empty')}</p>`; return; }
|
||||||
|
el.innerHTML = cats.map(c => `
|
||||||
|
<div class="cat-item">
|
||||||
|
<span class="cat-emoji">${c.emoji || '📁'}</span>
|
||||||
|
<div class="cat-info">
|
||||||
|
<strong>${c.fi}</strong> / ${c.en}
|
||||||
|
<span> · id: ${c.id}</span>
|
||||||
|
</div>
|
||||||
|
<button class="delete-btn" onclick="deleteCategory('${c.id}')">${at('poista')}</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
populateCategorySelect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addCategory() {
|
||||||
|
const fi = document.getElementById('newCatFi').value.trim();
|
||||||
|
const en = document.getElementById('newCatEn').value.trim();
|
||||||
|
const emoji = document.getElementById('newCatEmoji').value.trim() || '📁';
|
||||||
|
if (!fi || !en) { showToast(at('cat_no_name')); return; }
|
||||||
|
const id = fi.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now();
|
||||||
|
ADMIN.categories.push({ id, fi, en, emoji });
|
||||||
|
await apiPost('save_categories', { categories: ADMIN.categories });
|
||||||
|
document.getElementById('newCatFi').value = '';
|
||||||
|
document.getElementById('newCatEn').value = '';
|
||||||
|
document.getElementById('newCatEmoji').value = '';
|
||||||
|
renderCatList();
|
||||||
|
showToast(at('cat_added'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCategory(id) {
|
||||||
|
if (!confirm(at('confirm_del'))) return;
|
||||||
|
ADMIN.categories = ADMIN.categories.filter(c => c.id !== id);
|
||||||
|
await apiPost('save_categories', { categories: ADMIN.categories });
|
||||||
|
renderCatList();
|
||||||
|
showToast(at('cat_deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateCategorySelect() {
|
||||||
|
const sel = document.getElementById('postCategory');
|
||||||
|
const current = sel.value;
|
||||||
|
sel.innerHTML = ADMIN.categories.map(c => `<option value="${c.id}">${c.emoji || ''} ${c.fi} / ${c.en}</option>`).join('');
|
||||||
|
if (current) sel.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// POST TYPE TOGGLE
|
||||||
|
// ===========================
|
||||||
|
let currentType = 'recipe';
|
||||||
|
|
||||||
|
function setType(type) {
|
||||||
|
currentType = type;
|
||||||
|
document.getElementById('recipeFields').classList.toggle('visible', type === 'recipe');
|
||||||
|
document.getElementById('postFields').classList.toggle('visible', type === 'post');
|
||||||
|
document.getElementById('typeRecipeBtn').classList.toggle('active', type === 'recipe');
|
||||||
|
document.getElementById('typePostBtn').classList.toggle('active', type === 'post');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// DYNAMIC INGREDIENT/STEP LISTS
|
||||||
|
// ===========================
|
||||||
|
function addIngredient() {
|
||||||
|
const list = document.getElementById('ingredientsList');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dynamic-item';
|
||||||
|
div.innerHTML = `<input type="text" placeholder="esim. 1 dl sokeria" /><button class="remove-btn" onclick="removeItem(this)">✕</button>`;
|
||||||
|
list.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addStep() {
|
||||||
|
const list = document.getElementById('stepsList');
|
||||||
|
const n = list.children.length + 1;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'dynamic-item';
|
||||||
|
div.innerHTML = `<input type="text" placeholder="Vaihe ${n}..." /><button class="remove-btn" onclick="removeItem(this)">✕</button>`;
|
||||||
|
list.appendChild(div);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(btn) {
|
||||||
|
if (btn.parentElement.parentElement.children.length > 1) btn.parentElement.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// SAVE / EDIT POST
|
||||||
|
// ===========================
|
||||||
|
let editingId = null;
|
||||||
|
|
||||||
|
async function savePost() {
|
||||||
|
const title = document.getElementById('postTitle').value.trim();
|
||||||
|
const emoji = document.getElementById('postEmoji').value.trim() || '🍽️';
|
||||||
|
const category = document.getElementById('postCategory').value;
|
||||||
|
const author = document.getElementById('postAuthor').value.trim();
|
||||||
|
const desc = document.getElementById('postDesc').value.trim();
|
||||||
|
|
||||||
|
if (!title) { showToast(at('no_title')); return; }
|
||||||
|
|
||||||
|
const images = [
|
||||||
|
document.getElementById('postImg1').value.trim(),
|
||||||
|
document.getElementById('postImg2').value.trim(),
|
||||||
|
document.getElementById('postImg3').value.trim(),
|
||||||
|
].filter(Boolean);
|
||||||
|
const post = { title, emoji, category, author, desc, images, type: currentType };
|
||||||
|
|
||||||
|
if (currentType === 'recipe') {
|
||||||
|
post.time = document.getElementById('postTime').value.trim();
|
||||||
|
post.servings = document.getElementById('postServings').value.trim();
|
||||||
|
post.ingredients = [...document.getElementById('ingredientsList').querySelectorAll('input')].map(i => i.value.trim()).filter(Boolean);
|
||||||
|
post.steps = [...document.getElementById('stepsList').querySelectorAll('input')].map(i => i.value.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
post.body = document.getElementById('postBody').value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('saveBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
if (editingId) {
|
||||||
|
post.id = editingId;
|
||||||
|
const idx = ADMIN.posts.findIndex(p => p.id === editingId);
|
||||||
|
if (idx !== -1) ADMIN.posts[idx] = { ...ADMIN.posts[idx], ...post };
|
||||||
|
await apiPost('update_post', { post: ADMIN.posts[idx] || post });
|
||||||
|
showToast(at('updated'));
|
||||||
|
editingId = null;
|
||||||
|
document.getElementById('formTitle').textContent = at('form_add');
|
||||||
|
} else {
|
||||||
|
post.id = title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now();
|
||||||
|
ADMIN.posts.push(post);
|
||||||
|
await apiPost('add_post', { post });
|
||||||
|
showToast(at('saved'));
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
resetForm();
|
||||||
|
renderPostList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPost(id) {
|
||||||
|
const p = ADMIN.posts.find(p => p.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
editingId = id;
|
||||||
|
document.getElementById('formTitle').textContent = at('form_edit');
|
||||||
|
document.getElementById('postTitle').value = p.title;
|
||||||
|
document.getElementById('postEmoji').value = p.emoji;
|
||||||
|
document.getElementById('postCategory').value = p.category;
|
||||||
|
document.getElementById('postAuthor').value = p.author || '';
|
||||||
|
document.getElementById('postDesc').value = p.desc || '';
|
||||||
|
setAdmImgPreview(1, p.images?.[0] || '');
|
||||||
|
setAdmImgPreview(2, p.images?.[1] || '');
|
||||||
|
setAdmImgPreview(3, p.images?.[2] || '');
|
||||||
|
setType(p.type || 'recipe');
|
||||||
|
|
||||||
|
if (p.type === 'recipe') {
|
||||||
|
document.getElementById('postTime').value = p.time || '';
|
||||||
|
document.getElementById('postServings').value = p.servings || '';
|
||||||
|
const ingList = document.getElementById('ingredientsList');
|
||||||
|
ingList.innerHTML = (p.ingredients?.length ? p.ingredients : ['']).map(ing =>
|
||||||
|
`<div class="dynamic-item"><input type="text" value="${ing}" placeholder="Aines" /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`
|
||||||
|
).join('');
|
||||||
|
const stpList = document.getElementById('stepsList');
|
||||||
|
stpList.innerHTML = (p.steps?.length ? p.steps : ['']).map((s, i) =>
|
||||||
|
`<div class="dynamic-item"><input type="text" value="${s}" placeholder="Vaihe ${i+1}..." /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
document.getElementById('postBody').value = p.body || '';
|
||||||
|
}
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePost(id) {
|
||||||
|
if (!confirm(at('confirm_del'))) return;
|
||||||
|
ADMIN.posts = ADMIN.posts.filter(p => p.id !== id);
|
||||||
|
await apiPost('delete_post', { id });
|
||||||
|
renderPostList();
|
||||||
|
showToast(at('deleted'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId = null;
|
||||||
|
document.getElementById('postTitle').value = '';
|
||||||
|
document.getElementById('postEmoji').value = '';
|
||||||
|
document.getElementById('postAuthor').value = '';
|
||||||
|
document.getElementById('postDesc').value = '';
|
||||||
|
document.getElementById('postTime').value = '';
|
||||||
|
document.getElementById('postServings').value = '';
|
||||||
|
document.getElementById('postBody').value = '';
|
||||||
|
setAdmImgPreview(1, '');
|
||||||
|
setAdmImgPreview(2, '');
|
||||||
|
setAdmImgPreview(3, '');
|
||||||
|
document.getElementById('ingredientsList').innerHTML = `<div class="dynamic-item"><input type="text" placeholder="esim. 2 dl vehnäjauhoja" /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`;
|
||||||
|
document.getElementById('stepsList').innerHTML = `<div class="dynamic-item"><input type="text" placeholder="Vaihe 1..." /><button class="remove-btn" onclick="removeItem(this)">✕</button></div>`;
|
||||||
|
setType('recipe');
|
||||||
|
document.getElementById('formTitle').textContent = at('form_add');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// RENDER POST LIST
|
||||||
|
// ===========================
|
||||||
|
function renderPostList() {
|
||||||
|
const posts = ADMIN.posts;
|
||||||
|
const el = document.getElementById('postList');
|
||||||
|
if (!posts.length) { el.innerHTML = `<p class="empty-state">${at('empty')}</p>`; return; }
|
||||||
|
el.innerHTML = posts.map(p => `
|
||||||
|
<div class="post-item">
|
||||||
|
<span class="post-item-emoji">${p.emoji}</span>
|
||||||
|
<div class="post-item-info">
|
||||||
|
<h3>${p.title}</h3>
|
||||||
|
<p>${getCatLabel(p.category)} · ${p.type === 'recipe' ? (p.time || '') : '📝'} ${p.author ? '· ✍️ ' + p.author : ''}</p>
|
||||||
|
</div>
|
||||||
|
<div class="post-item-actions">
|
||||||
|
<button class="edit-btn" onclick="editPost('${p.id}')">${at('muokkaa')}</button>
|
||||||
|
<button class="delete-btn" onclick="deletePost('${p.id}')">${at('poista')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// TOAST
|
||||||
|
// ===========================
|
||||||
|
function showToast(msg) {
|
||||||
|
const t = document.getElementById('toast');
|
||||||
|
t.textContent = msg;
|
||||||
|
t.classList.add('show');
|
||||||
|
setTimeout(() => t.classList.remove('show'), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// INIT — check if already logged in
|
||||||
|
// ===========================
|
||||||
|
(async () => {
|
||||||
|
const data = await apiGet('admin_check');
|
||||||
|
if (data.loggedIn) {
|
||||||
|
document.getElementById('loginOverlay').style.display = 'none';
|
||||||
|
await loadAdminData();
|
||||||
|
} else {
|
||||||
|
document.getElementById('adminPwInput').focus();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
241
api.php
Normal file
241
api.php
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* tykkää.fi — API backend
|
||||||
|
* Tallentaa julkaisut, kommentit, tykkäykset ja kategoriat JSON-tiedostoihin.
|
||||||
|
*/
|
||||||
|
session_start();
|
||||||
|
header('Content-Type: application/json; charset=UTF-8');
|
||||||
|
|
||||||
|
// ─── VAIHDA TÄMÄ SALASANA! ──────────────────────────────────────
|
||||||
|
define('ADMIN_PASSWORD', 'vaihda_tämä_salasana');
|
||||||
|
// ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
define('DATA_DIR', __DIR__ . '/data/');
|
||||||
|
|
||||||
|
if (!is_dir(DATA_DIR)) mkdir(DATA_DIR, 0755, true);
|
||||||
|
|
||||||
|
$action = $_GET['action'] ?? '';
|
||||||
|
$body = [];
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$body = json_decode($raw, true) ?? [];
|
||||||
|
if (!$action) $action = $body['action'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Apufunktiot ───────────────────────────────────────────────
|
||||||
|
function readData(string $file, $default = []) {
|
||||||
|
$path = DATA_DIR . $file;
|
||||||
|
if (!file_exists($path)) return $default;
|
||||||
|
$d = json_decode(file_get_contents($path), true);
|
||||||
|
return $d !== null ? $d : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeData(string $file, $data): void {
|
||||||
|
file_put_contents(
|
||||||
|
DATA_DIR . $file,
|
||||||
|
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ok($data = []): void {
|
||||||
|
echo json_encode(array_merge(['ok' => true], $data));
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function err(string $msg, int $code = 400): void {
|
||||||
|
http_response_code($code);
|
||||||
|
echo json_encode(['error' => $msg]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(): bool {
|
||||||
|
return !empty($_SESSION['tykkaafi_admin']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Oletusdata ────────────────────────────────────────────────
|
||||||
|
function defaultCategories(): array {
|
||||||
|
return [
|
||||||
|
['id' => 'recipes', 'fi' => 'Reseptit', 'en' => 'Recipes', 'emoji' => '🍳'],
|
||||||
|
['id' => 'knitting', 'fi' => 'Neulominen', 'en' => 'Knitting', 'emoji' => '🧶'],
|
||||||
|
['id' => 'tips', 'fi' => 'Vinkit', 'en' => 'Tips', 'emoji' => '💡'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultPosts(): array {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => 'pancakes', 'emoji' => '🥞', 'title' => 'Kuohkeat letut',
|
||||||
|
'category' => 'recipes', 'author' => 'Admin',
|
||||||
|
'time' => '20 min', 'servings' => '4 annosta', 'type' => 'recipe',
|
||||||
|
'desc' => 'Kultaisia, voisia lettuja, jotka sulavat suuhun. Täydellisiä laiskaan sunnuntaiaamuun.',
|
||||||
|
'ingredients' => ['1½ dl vehnäjauhoja','3½ tl leivinjauhetta','1 tl suolaa','1 rkl sokeria','3 dl maitoa','1 muna','3 rkl sulatettua voita','Voita tai öljyä paistamiseen'],
|
||||||
|
'steps' => ['Sekoita kulhossa jauhot, leivinjauhe, suola ja sokeri.','Tee keskelle kuoppa ja kaada sekaan maito, muna ja sulatettu voi.','Sekoita tasaiseksi — pienet paakut ovat ok.','Kuumenna pannua keskilämmöllä.','Kaada noin ¼ dl taikinaa per lettu.','Paista kunnes pintaan nousee kuplia, käännä ja paista kullanruskeaksi.','Tarjoile vaahterasiirapilla ja marjoilla.'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'bolognese', 'emoji' => '🍝', 'title' => 'Klassinen spagetti bolognese',
|
||||||
|
'category' => 'recipes', 'author' => 'Admin',
|
||||||
|
'time' => '1 t 20 min', 'servings' => '6 annosta', 'type' => 'recipe',
|
||||||
|
'desc' => 'Runsas, hitaasti haudutettu lihamauste al dente -spagetin päällä. Ajaton italialainen klassikko.',
|
||||||
|
'ingredients' => ['500 g jauhelihaa','400 g spagettia','1 sipuli, hienonnettuna','3 valkosipulinkynttä','800 g murskattuja tomaatteja','2 dl punaviiniä','2 rkl tomaattipyrettä','Suolaa, pippuria, basilikaa, oreganoa','Parmesaanijuustoa tarjoiluun'],
|
||||||
|
'steps' => ['Kuullota sipuli ja valkosipuli oliiviöljyssä.','Ruskista jauheliha.','Lisää viini ja anna pelkistyä (5 min).','Lisää tomaatit ja mausteet.','Hauduta miedolla lämmöllä 1 tunti.','Keitä spagetti al denteksi.','Tarjoile parmesaanin kera.'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'cookies', 'emoji' => '🍪', 'title' => 'Suklaahippukeksit',
|
||||||
|
'category' => 'recipes', 'author' => 'Admin',
|
||||||
|
'time' => '30 min', 'servings' => '24 keksiä', 'type' => 'recipe',
|
||||||
|
'desc' => 'Sitkeä sisältä, rapea reunoilta — täydellinen kotitekoinen keksi.',
|
||||||
|
'ingredients' => ['5½ dl vehnäjauhoja','1 tl ruokasoodaa','1 tl suolaa','225 g pehmeää voita','1½ dl sokeria','1½ dl ruskeaa sokeria','2 munaa','2 tl vaniljauutetta','4 dl suklaahippuja'],
|
||||||
|
'steps' => ['Kuumenna uuni 190°C.','Sekoita jauhot, sooda ja suola.','Vatkaa voi ja sokerit kuohkeaksi.','Lisää munat ja vanilja.','Yhdistä aineet ja lisää suklaa.','Lusikoi pellille.','Paista 9–11 min kullanruskeaksi.'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'soup', 'emoji' => '🍲', 'title' => 'Täyttävä kasviskeitto',
|
||||||
|
'category' => 'recipes', 'author' => 'Admin',
|
||||||
|
'time' => '45 min', 'servings' => '4 annosta', 'type' => 'recipe',
|
||||||
|
'desc' => 'Lämmittävä kulhollinen paksuja kasviksia rikkaassa yrttiliemessä.',
|
||||||
|
'ingredients' => ['2 rkl oliiviöljyä','1 sipuli','3 valkosipulia','3 porkkanaa, siivuina','2 perunaa, kuutioina','1 kesäkurpitsa','400 g tomaattimurskaa','1½ l kasvislientä','Timjami, rosmariini, suola, pippuri'],
|
||||||
|
'steps' => ['Kuullota sipuli ja valkosipuli.','Lisää kasvikset ja sekoittele 5 min.','Kaada liemi ja tomaatit, lisää yrtit.','Hauduta 25 min.','Lisää kesäkurpitsa, keitä 10 min.','Mausta ja tarjoile.'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'knitting_scarf', 'emoji' => '🧶', 'title' => 'Helppo huivi aloittelijalle',
|
||||||
|
'category' => 'knitting', 'author' => 'Admin', 'type' => 'post',
|
||||||
|
'desc' => 'Neulo kaunis huivi muutamassa tunnissa — täydellinen ensimmäinen projekti!',
|
||||||
|
'body' => '<p><strong>Tarvitset:</strong></p><ul><li>Paksu lanka (noin 200 g)</li><li>Pyöröneulat nro 6–8</li></ul><p><strong>Ohje:</strong> Luo 20 silmukkaa. Neulo suoraan (edestakaisin oikein silmukoin) kunnes huivi on noin 150 cm pitkä. Päättele silmukat ja viimeistele päät. Valmis! 🎉</p><p>Vinkki: Paksuilla langoilla ja suurilla neuloilla saat huivista nopeasti valmiin, vaikka olisit aloittelija.</p>',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'tip_morning', 'emoji' => '💡', 'title' => 'Rauhallinen aamurutiini',
|
||||||
|
'category' => 'tips', 'author' => 'Admin', 'type' => 'post',
|
||||||
|
'desc' => 'Pienet muutokset aamurutiiniin tekevät koko päivästä paremman.',
|
||||||
|
'body' => '<p>Kokeile näitä vinkkejä parempaan aamuun:</p><ul><li>🌅 Herää 15 minuuttia aiemmin kuin tarvitset</li><li>💧 Juo lasi vettä ennen kahvia</li><li>📵 Älä katso puhelinta heti herätessä</li><li>🚶 Pieni lenkki tai venyttely ennen päivää</li></ul><p>Pienet muutokset, iso vaikutus!</p>',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrInitPosts(): array {
|
||||||
|
if (!file_exists(DATA_DIR . 'posts.json')) {
|
||||||
|
$posts = defaultPosts();
|
||||||
|
writeData('posts.json', $posts);
|
||||||
|
return $posts;
|
||||||
|
}
|
||||||
|
return readData('posts.json', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrInitCategories(): array {
|
||||||
|
if (!file_exists(DATA_DIR . 'categories.json')) {
|
||||||
|
$cats = defaultCategories();
|
||||||
|
writeData('categories.json', $cats);
|
||||||
|
return $cats;
|
||||||
|
}
|
||||||
|
return readData('categories.json', []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Routing ───────────────────────────────────────────────────
|
||||||
|
switch ($action) {
|
||||||
|
|
||||||
|
case 'posts':
|
||||||
|
ok(['posts' => getOrInitPosts()]);
|
||||||
|
|
||||||
|
case 'categories':
|
||||||
|
ok(['categories' => getOrInitCategories()]);
|
||||||
|
|
||||||
|
case 'likes':
|
||||||
|
$likes = readData('likes.json', new stdClass());
|
||||||
|
$userLikes = $_SESSION['user_likes'] ?? [];
|
||||||
|
ok(['likes' => $likes, 'userLikes' => $userLikes]);
|
||||||
|
|
||||||
|
case 'toggle_like':
|
||||||
|
$postId = $body['postId'] ?? '';
|
||||||
|
if (!$postId) err('Missing postId');
|
||||||
|
$likes = readData('likes.json', []);
|
||||||
|
$userLikes = $_SESSION['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;
|
||||||
|
}
|
||||||
|
$_SESSION['user_likes'] = array_values($userLikes);
|
||||||
|
writeData('likes.json', $likes);
|
||||||
|
ok(['liked' => $liked, 'count' => $likes[$postId] ?? 0]);
|
||||||
|
|
||||||
|
case 'comments':
|
||||||
|
$postId = $_GET['postId'] ?? '';
|
||||||
|
$comments = readData('comments.json', []);
|
||||||
|
ok(['comments' => $comments[$postId] ?? []]);
|
||||||
|
|
||||||
|
case 'add_comment':
|
||||||
|
$postId = $body['postId'] ?? '';
|
||||||
|
$name = trim($body['name'] ?? '');
|
||||||
|
$text = trim($body['text'] ?? '');
|
||||||
|
if (!$postId || !$name || !$text) err('Missing fields');
|
||||||
|
$now = time();
|
||||||
|
$win = 10 * 60;
|
||||||
|
$times = array_values(array_filter($_SESSION['comment_times'] ?? [], fn($t) => $now - $t < $win));
|
||||||
|
if (count($times) >= 3) err('Liian monta kommenttia. Odota hetki.');
|
||||||
|
$times[] = $now;
|
||||||
|
$_SESSION['comment_times'] = $times;
|
||||||
|
$comments = readData('comments.json', []);
|
||||||
|
if (!isset($comments[$postId])) $comments[$postId] = [];
|
||||||
|
$comment = [
|
||||||
|
'name' => htmlspecialchars($name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
|
'text' => htmlspecialchars($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
|
'time' => date('d.m.Y'),
|
||||||
|
];
|
||||||
|
$comments[$postId][] = $comment;
|
||||||
|
writeData('comments.json', $comments);
|
||||||
|
ok(['comment' => $comment]);
|
||||||
|
|
||||||
|
case 'add_post':
|
||||||
|
$post = $body['post'] ?? [];
|
||||||
|
if (empty($post['title'])) err('Missing title');
|
||||||
|
$post['id'] = preg_replace('/[^a-z0-9_]/', '', strtolower($post['id'] ?? '')) ?: 'post_' . time();
|
||||||
|
$posts = getOrInitPosts();
|
||||||
|
$posts[] = $post;
|
||||||
|
writeData('posts.json', $posts);
|
||||||
|
ok();
|
||||||
|
|
||||||
|
case 'update_post':
|
||||||
|
if (!isAdmin()) err('Unauthorized', 403);
|
||||||
|
$post = $body['post'] ?? [];
|
||||||
|
if (empty($post['id'])) err('Missing id');
|
||||||
|
$posts = getOrInitPosts();
|
||||||
|
foreach ($posts as &$p) {
|
||||||
|
if ($p['id'] === $post['id']) { $p = $post; break; }
|
||||||
|
}
|
||||||
|
unset($p);
|
||||||
|
writeData('posts.json', $posts);
|
||||||
|
ok();
|
||||||
|
|
||||||
|
case 'delete_post':
|
||||||
|
if (!isAdmin()) err('Unauthorized', 403);
|
||||||
|
$id = $body['id'] ?? '';
|
||||||
|
if (!$id) err('Missing id');
|
||||||
|
$posts = array_values(array_filter(getOrInitPosts(), fn($p) => ($p['id'] ?? '') !== $id));
|
||||||
|
writeData('posts.json', $posts);
|
||||||
|
ok();
|
||||||
|
|
||||||
|
case 'save_categories':
|
||||||
|
if (!isAdmin()) err('Unauthorized', 403);
|
||||||
|
writeData('categories.json', $body['categories'] ?? []);
|
||||||
|
ok();
|
||||||
|
|
||||||
|
case 'admin_login':
|
||||||
|
if (($body['password'] ?? '') === ADMIN_PASSWORD) {
|
||||||
|
$_SESSION['tykkaafi_admin'] = true;
|
||||||
|
ok();
|
||||||
|
}
|
||||||
|
err('Väärä salasana');
|
||||||
|
|
||||||
|
case 'admin_logout':
|
||||||
|
$_SESSION['tykkaafi_admin'] = false;
|
||||||
|
ok();
|
||||||
|
|
||||||
|
case 'admin_check':
|
||||||
|
ok(['loggedIn' => isAdmin()]);
|
||||||
|
|
||||||
|
default:
|
||||||
|
err('Unknown action');
|
||||||
|
}
|
||||||
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
1
data/.htaccess
Normal file
1
data/.htaccess
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Deny from all
|
||||||
0
images/.gitkeep
Normal file
0
images/.gitkeep
Normal file
202
index.html
Normal file
202
index.html
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fi">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>tykkää.fi</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- HEADER -->
|
||||||
|
<header>
|
||||||
|
<div class="header-inner">
|
||||||
|
<div class="logo">
|
||||||
|
<span class="logo-icon">🍳</span>
|
||||||
|
<h1>tykkää.fi</h1>
|
||||||
|
</div>
|
||||||
|
<p class="tagline" data-i18n="tagline"></p>
|
||||||
|
<nav>
|
||||||
|
<a href="#posts" data-i18n="nav_posts"></a>
|
||||||
|
<a href="#about" data-i18n="nav_about"></a>
|
||||||
|
<a href="#contact" data-i18n="nav_contact"></a>
|
||||||
|
<a href="admin.html" data-i18n="nav_admin"></a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- HERO -->
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-content">
|
||||||
|
<h2 data-i18n="hero_title"></h2>
|
||||||
|
<p data-i18n="hero_desc"></p>
|
||||||
|
<a href="#posts" class="btn" data-i18n="hero_btn"></a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- SEARCH & FILTER -->
|
||||||
|
<section class="controls" id="posts">
|
||||||
|
<div class="container">
|
||||||
|
<input type="text" id="search" data-i18n-ph="search_ph" oninput="filterPosts()" />
|
||||||
|
<div class="filters" id="categoryFilters"></div>
|
||||||
|
<button class="add-post-btn" onclick="openSubmitModal()">✏️ Lisää julkaisu</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- POST GRID -->
|
||||||
|
<section class="recipe-section">
|
||||||
|
<div class="container">
|
||||||
|
<div class="recipe-grid" id="postGrid"></div>
|
||||||
|
<p class="no-results" id="noResults" style="display:none;" data-i18n="no_results"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ABOUT -->
|
||||||
|
<section class="about" id="about">
|
||||||
|
<div class="container about-inner">
|
||||||
|
<div class="about-emoji">👩🍳</div>
|
||||||
|
<div>
|
||||||
|
<h2 data-i18n="about_title"></h2>
|
||||||
|
<p data-i18n="about_text"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CONTACT -->
|
||||||
|
<section class="contact" id="contact">
|
||||||
|
<div class="container">
|
||||||
|
<h2 data-i18n="contact_title"></h2>
|
||||||
|
<p data-i18n="contact_desc"></p>
|
||||||
|
<form class="contact-form" onsubmit="handleSubmit(event)">
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FOOTER -->
|
||||||
|
<footer>
|
||||||
|
<p>🍳 tykkää.fi — <span data-i18n="footer"></span> © 2026</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- POST MODAL -->
|
||||||
|
<div class="modal-overlay" id="modalOverlay" onclick="closeModal()">
|
||||||
|
<div class="modal" onclick="event.stopPropagation()">
|
||||||
|
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||||
|
<div id="modalContent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SUBMIT MODAL -->
|
||||||
|
<div class="modal-overlay" id="submitOverlay" onclick="closeSubmitModal()">
|
||||||
|
<div class="modal" onclick="event.stopPropagation()" style="max-width:640px">
|
||||||
|
<button class="modal-close" onclick="closeSubmitModal()">✕</button>
|
||||||
|
<h2>✏️ Lisää julkaisu</h2>
|
||||||
|
|
||||||
|
<div class="type-toggle">
|
||||||
|
<button class="type-btn active" id="sub-typeRecipeBtn" onclick="setSubmitType('recipe')">🍳 Resepti</button>
|
||||||
|
<button class="type-btn" id="sub-typePostBtn" onclick="setSubmitType('post')">📝 Julkaisu</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Otsikko *</label>
|
||||||
|
<input type="text" id="sub-title" placeholder="esim. Mummon mustikkapiirakka" maxlength="100" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kategoria</label>
|
||||||
|
<select id="sub-category"></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Emoji</label>
|
||||||
|
<input type="text" id="sub-emoji" placeholder="🍽️" maxlength="4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kirjoittaja</label>
|
||||||
|
<input type="text" id="sub-author" placeholder="Nimesi tai nimimerkki" maxlength="50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kuvaus</label>
|
||||||
|
<textarea id="sub-desc" rows="2" placeholder="Lyhyt houkutteleva kuvaus..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Kuvat <small style="font-weight:normal;text-transform:none;color:var(--text-light)">(1. kuva pakollinen, 2. ja 3. vapaaehtoisia)</small></label>
|
||||||
|
<div class="img-upload-slots">
|
||||||
|
<div class="img-upload-slot">
|
||||||
|
<label class="img-upload-btn" id="sub-lbl1">📷 Kuva 1 *
|
||||||
|
<input type="file" accept="image/*" onchange="uploadImg(this,'sub-img1','sub-prev1','sub-lbl1')" style="display:none" />
|
||||||
|
</label>
|
||||||
|
<img class="img-preview" id="sub-prev1" alt="" />
|
||||||
|
<input type="hidden" id="sub-img1" />
|
||||||
|
</div>
|
||||||
|
<div class="img-upload-slot">
|
||||||
|
<label class="img-upload-btn" id="sub-lbl2">📷 Kuva 2
|
||||||
|
<input type="file" accept="image/*" onchange="uploadImg(this,'sub-img2','sub-prev2','sub-lbl2')" style="display:none" />
|
||||||
|
</label>
|
||||||
|
<img class="img-preview" id="sub-prev2" alt="" />
|
||||||
|
<input type="hidden" id="sub-img2" />
|
||||||
|
</div>
|
||||||
|
<div class="img-upload-slot">
|
||||||
|
<label class="img-upload-btn" id="sub-lbl3">📷 Kuva 3
|
||||||
|
<input type="file" accept="image/*" onchange="uploadImg(this,'sub-img3','sub-prev3','sub-lbl3')" style="display:none" />
|
||||||
|
</label>
|
||||||
|
<img class="img-preview" id="sub-prev3" alt="" />
|
||||||
|
<input type="hidden" id="sub-img3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RECIPE FIELDS -->
|
||||||
|
<div id="sub-recipeFields">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Valmistusaika</label>
|
||||||
|
<input type="text" id="sub-time" placeholder="esim. 30 min" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Annoksia</label>
|
||||||
|
<input type="text" id="sub-servings" placeholder="esim. 4 annosta" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ainekset <small style="font-weight:normal;text-transform:none;color:var(--text-light)">(yksi per rivi)</small></label>
|
||||||
|
<textarea id="sub-ingredients" rows="4" placeholder="1 dl vehnäjauhoja 2 munaa ..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Ohjeet <small style="font-weight:normal;text-transform:none;color:var(--text-light)">(yksi vaihe per rivi)</small></label>
|
||||||
|
<textarea id="sub-steps" rows="5" placeholder="Sekoita aineet Paista 180°C..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- POST FIELDS -->
|
||||||
|
<div id="sub-postFields" style="display:none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Sisältö</label>
|
||||||
|
<textarea id="sub-body" rows="8" placeholder="Kirjoita sisältö tähän..."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- honeypot -->
|
||||||
|
<input type="text" id="sub-honeypot" tabindex="-1" autocomplete="off" style="display:none" />
|
||||||
|
|
||||||
|
<!-- captcha -->
|
||||||
|
<div class="captcha-row" style="margin-top:8px">
|
||||||
|
<label class="captcha-label" id="sub-captcha-question"></label>
|
||||||
|
<input type="number" id="sub-captcha-input" placeholder="Vastaus" min="0" max="99" style="width:100px" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="sub-error" id="sub-error"></p>
|
||||||
|
<button class="submit-btn" onclick="submitPublicPost()">📨 Lähetä julkaisu</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
538
script.js
Normal file
538
script.js
Normal file
@@ -0,0 +1,538 @@
|
|||||||
|
// ===========================
|
||||||
|
// TRANSLATIONS
|
||||||
|
// ===========================
|
||||||
|
const T = {
|
||||||
|
fi: {
|
||||||
|
tagline: 'Kotitekoista rakkaudella, jaettu maailmalle',
|
||||||
|
nav_posts: 'Julkaisut', nav_about: 'Tietoa', nav_contact: 'Yhteystiedot', nav_admin: 'Hallinta',
|
||||||
|
hero_title: 'Tervetuloa!',
|
||||||
|
hero_desc: 'Reseptejä, neulomisvinkkejä, elämänohjeita — jaa mitä rakastat.',
|
||||||
|
hero_btn: 'Selaa julkaisuja',
|
||||||
|
search_ph: 'Hae julkaisuja...',
|
||||||
|
filter_all: 'Kaikki',
|
||||||
|
view_btn: 'Lue lisää',
|
||||||
|
no_results: 'Julkaisuja ei löydy. Kokeile eri hakusanaa!',
|
||||||
|
about_title: 'Tietoa blogista',
|
||||||
|
about_text: 'Hei! Täällä jaan kaiken mielenkiintoisen — resepteistä neulomisohjeisiin ja kaikkeen siltä väliltä. Tervetuloa!',
|
||||||
|
contact_title: 'Ota yhteyttä',
|
||||||
|
contact_desc: 'Haluatko ehdottaa aihetta tai vain sanoa hei? Lähetä viesti!',
|
||||||
|
name_ph: 'Nimesi', email_ph: 'Sähköpostisi', msg_ph: 'Viestisi...',
|
||||||
|
send_btn: 'Lähetä viesti', msg_sent: 'Viesti lähetetty! ✓',
|
||||||
|
footer: 'Tehty rakkaudella & voilla',
|
||||||
|
modal_by: 'Kirjoittanut',
|
||||||
|
modal_ingredients: 'Ainekset', modal_steps: 'Ohjeet',
|
||||||
|
modal_comments: 'Kommentit',
|
||||||
|
no_comments: 'Ei kommentteja vielä. Ole ensimmäinen!',
|
||||||
|
comment_name_ph: 'Nimesi', comment_ph: 'Kirjoita kommentti...',
|
||||||
|
comment_btn: 'Lähetä kommentti',
|
||||||
|
like_btn: '❤️ Tykkää', liked_btn: '❤️ Tykätty',
|
||||||
|
no_ingredients: 'Aineksia ei lisätty.', no_steps: 'Ohjeita ei lisätty.',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
tagline: 'Homemade with love, shared with the world',
|
||||||
|
nav_posts: 'Posts', nav_about: 'About', nav_contact: 'Contact', nav_admin: 'Admin',
|
||||||
|
hero_title: 'Welcome!',
|
||||||
|
hero_desc: 'Recipes, knitting tips, life advice — share what you love.',
|
||||||
|
hero_btn: 'Browse Posts',
|
||||||
|
search_ph: 'Search posts...',
|
||||||
|
filter_all: 'All',
|
||||||
|
view_btn: 'Read more',
|
||||||
|
no_results: 'No posts found. Try a different search!',
|
||||||
|
about_title: 'About This Blog',
|
||||||
|
about_text: 'Hi! I share everything interesting here — from recipes to knitting tips and everything in between. Welcome!',
|
||||||
|
contact_title: 'Get in Touch',
|
||||||
|
contact_desc: 'Want to suggest a topic or just say hi? Drop me a message!',
|
||||||
|
name_ph: 'Your name', email_ph: 'Your email', msg_ph: 'Your message...',
|
||||||
|
send_btn: 'Send Message', msg_sent: 'Message Sent! ✓',
|
||||||
|
footer: 'Made with love & butter',
|
||||||
|
modal_by: 'By',
|
||||||
|
modal_ingredients: 'Ingredients', modal_steps: 'Instructions',
|
||||||
|
modal_comments: 'Comments',
|
||||||
|
no_comments: 'No comments yet. Be the first!',
|
||||||
|
comment_name_ph: 'Your name', comment_ph: 'Write a comment...',
|
||||||
|
comment_btn: 'Post Comment',
|
||||||
|
like_btn: '❤️ Like', liked_btn: '❤️ Liked',
|
||||||
|
no_ingredients: 'No ingredients listed.', no_steps: 'No instructions listed.',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lang = 'fi';
|
||||||
|
function t(key) { return (T[lang] && T[lang][key]) ? T[lang][key] : key; }
|
||||||
|
|
||||||
|
function applyTranslations() {
|
||||||
|
document.documentElement.lang = lang;
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(el => { el.textContent = t(el.dataset.i18n); });
|
||||||
|
document.querySelectorAll('[data-i18n-ph]').forEach(el => { el.placeholder = t(el.dataset.i18nPh); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// API
|
||||||
|
// ===========================
|
||||||
|
async function apiGet(action, params = {}) {
|
||||||
|
const qs = new URLSearchParams({ action, ...params }).toString();
|
||||||
|
const res = await fetch(`api.php?${qs}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiPost(action, body = {}) {
|
||||||
|
const res = await fetch(`api.php?action=${action}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// APP STATE
|
||||||
|
// ===========================
|
||||||
|
const APP = {
|
||||||
|
posts: [],
|
||||||
|
categories: [],
|
||||||
|
likes: {},
|
||||||
|
userLikes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// CATEGORIES
|
||||||
|
// ===========================
|
||||||
|
function getCategoryLabel(catId) {
|
||||||
|
const cat = APP.categories.find(c => c.id === catId);
|
||||||
|
return cat ? cat.fi : catId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCategoryFilters() {
|
||||||
|
const cats = APP.categories;
|
||||||
|
const container = document.getElementById('categoryFilters');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML =
|
||||||
|
`<button class="filter-btn ${currentFilter === 'all' ? 'active' : ''}" onclick="setFilter('all',this)">${t('filter_all')}</button>` +
|
||||||
|
cats.map(c => `<button class="filter-btn ${currentFilter === c.id ? 'active' : ''}" onclick="setFilter('${c.id}',this)">${c.emoji ? c.emoji + ' ' : ''}${c.fi}</button>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// LIKES
|
||||||
|
// ===========================
|
||||||
|
async function toggleLike(id) {
|
||||||
|
const data = await apiPost('toggle_like', { postId: id });
|
||||||
|
if (data.error) return;
|
||||||
|
|
||||||
|
const liked = data.liked;
|
||||||
|
const count = data.count;
|
||||||
|
|
||||||
|
// Update in-memory state
|
||||||
|
APP.likes[id] = count;
|
||||||
|
if (liked) {
|
||||||
|
if (!APP.userLikes.includes(id)) APP.userLikes.push(id);
|
||||||
|
} else {
|
||||||
|
APP.userLikes = APP.userLikes.filter(x => x !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all like buttons for this post (card + modal)
|
||||||
|
document.querySelectorAll(`[data-like-id="${id}"]`).forEach(btn => {
|
||||||
|
btn.textContent = liked ? t('liked_btn') : t('like_btn');
|
||||||
|
btn.classList.toggle('liked', liked);
|
||||||
|
});
|
||||||
|
document.querySelectorAll(`[data-like-count="${id}"]`).forEach(el => {
|
||||||
|
el.textContent = count;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// COMMENTS + SPAM PROTECTION
|
||||||
|
// ===========================
|
||||||
|
let captchaAnswer = 0;
|
||||||
|
function generateCaptcha() {
|
||||||
|
const a = Math.floor(Math.random() * 9) + 1;
|
||||||
|
const b = Math.floor(Math.random() * 9) + 1;
|
||||||
|
captchaAnswer = a + b;
|
||||||
|
return `Laske: ${a} + ${b} = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommentsList(comments) {
|
||||||
|
if (!comments || !comments.length) return `<p class="empty-note">${t('no_comments')}</p>`;
|
||||||
|
return comments.map(c => `
|
||||||
|
<div class="comment">
|
||||||
|
<div class="comment-header"><strong>${c.name}</strong><span>${c.time}</span></div>
|
||||||
|
<p>${c.text}</p>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// RENDER CARDS
|
||||||
|
// ===========================
|
||||||
|
function renderCards() {
|
||||||
|
const grid = document.getElementById('postGrid');
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
grid.innerHTML = APP.posts.map(p => {
|
||||||
|
const liked = APP.userLikes.includes(p.id);
|
||||||
|
const likeCount = APP.likes[p.id] || 0;
|
||||||
|
const metaRow = p.time ? `<div class="card-meta"><span>⏱ ${p.time}</span><span>👤 ${p.servings}</span></div>` : '';
|
||||||
|
const imgSrc = p.images?.[0];
|
||||||
|
const cardImgHTML = imgSrc
|
||||||
|
? `<div class="card-img card-has-photo"><img src="${imgSrc}" alt="${p.title}" /></div>`
|
||||||
|
: `<div class="card-img">${p.emoji}</div>`;
|
||||||
|
return `
|
||||||
|
<article class="recipe-card" data-category="${p.category}">
|
||||||
|
${cardImgHTML}
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="category-tag">${getCategoryLabel(p.category)}</span>
|
||||||
|
<h3>${p.title}</h3>
|
||||||
|
${p.author ? `<p class="card-author">✍️ ${p.author}</p>` : ''}
|
||||||
|
<p>${p.desc || ''}</p>
|
||||||
|
${metaRow}
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="btn-outline" onclick="openPost('${p.id}')">${t('view_btn')}</button>
|
||||||
|
<div class="like-row">
|
||||||
|
<button class="like-btn ${liked ? 'liked' : ''}" data-like-id="${p.id}" onclick="toggleLike('${p.id}')">${liked ? t('liked_btn') : t('like_btn')}</button>
|
||||||
|
<span class="like-count" data-like-count="${p.id}">${likeCount}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// FILTER & SEARCH
|
||||||
|
// ===========================
|
||||||
|
let currentFilter = 'all';
|
||||||
|
|
||||||
|
function setFilter(category, btn) {
|
||||||
|
currentFilter = category;
|
||||||
|
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
filterPosts();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterPosts() {
|
||||||
|
const query = (document.getElementById('search')?.value || '').toLowerCase();
|
||||||
|
const cards = document.querySelectorAll('.recipe-card');
|
||||||
|
let visible = 0;
|
||||||
|
cards.forEach(card => {
|
||||||
|
const matchesCat = currentFilter === 'all' || card.dataset.category === currentFilter;
|
||||||
|
const title = card.querySelector('h3').textContent.toLowerCase();
|
||||||
|
const desc = card.querySelector('p:not(.card-author)').textContent.toLowerCase();
|
||||||
|
const matchesSearch = title.includes(query) || desc.includes(query);
|
||||||
|
const show = matchesCat && matchesSearch;
|
||||||
|
card.style.display = show ? '' : 'none';
|
||||||
|
if (show) visible++;
|
||||||
|
});
|
||||||
|
const noRes = document.getElementById('noResults');
|
||||||
|
if (noRes) noRes.style.display = visible === 0 ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// MODAL
|
||||||
|
// ===========================
|
||||||
|
async function openPost(id) {
|
||||||
|
const p = APP.posts.find(x => x.id === id);
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
const liked = APP.userLikes.includes(p.id);
|
||||||
|
const likeCount = APP.likes[p.id] || 0;
|
||||||
|
|
||||||
|
// Load comments from server
|
||||||
|
const commData = await apiGet('comments', { postId: id });
|
||||||
|
const comments = commData.comments || [];
|
||||||
|
|
||||||
|
let bodyHTML = '';
|
||||||
|
if (p.type === 'recipe') {
|
||||||
|
const ings = p.ingredients?.length
|
||||||
|
? `<ul>${p.ingredients.map(i => `<li>${i}</li>`).join('')}</ul>`
|
||||||
|
: `<p class="empty-note">${t('no_ingredients')}</p>`;
|
||||||
|
const stps = p.steps?.length
|
||||||
|
? `<ol>${p.steps.map(s => `<li>${s}</li>`).join('')}</ol>`
|
||||||
|
: `<p class="empty-note">${t('no_steps')}</p>`;
|
||||||
|
bodyHTML = `<h3>${t('modal_ingredients')}</h3>${ings}<h3>${t('modal_steps')}</h3>${stps}`;
|
||||||
|
} else {
|
||||||
|
bodyHTML = `<div class="post-body">${p.body || p.desc}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryHTML = p.images?.length
|
||||||
|
? `<div class="img-gallery">${p.images.map(src => `<img src="${src}" alt="${p.title}" />`).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
document.getElementById('modalContent').innerHTML = `
|
||||||
|
${p.images?.length ? '' : `<span class="modal-emoji">${p.emoji}</span>`}
|
||||||
|
${galleryHTML}
|
||||||
|
<h2>${p.title}</h2>
|
||||||
|
<p class="modal-meta">
|
||||||
|
📂 ${getCategoryLabel(p.category)}
|
||||||
|
${p.time ? ` | ⏱ ${p.time} | 👤 ${p.servings}` : ''}
|
||||||
|
${p.author ? ` | ✍️ ${t('modal_by')} ${p.author}` : ''}
|
||||||
|
</p>
|
||||||
|
<div class="modal-like-row">
|
||||||
|
<button class="like-btn ${liked ? 'liked' : ''}" data-like-id="${p.id}" onclick="toggleLike('${p.id}')">${liked ? t('liked_btn') : t('like_btn')}</button>
|
||||||
|
<span class="like-count" data-like-count="${p.id}">${likeCount}</span>
|
||||||
|
</div>
|
||||||
|
${bodyHTML}
|
||||||
|
<div class="comments-section">
|
||||||
|
<h3 id="comments-heading-${p.id}">${t('modal_comments')} (${comments.length})</h3>
|
||||||
|
<div id="comments-list-${p.id}">${renderCommentsList(comments)}</div>
|
||||||
|
<form class="comment-form" onsubmit="submitComment(event,'${p.id}')">
|
||||||
|
<input type="text" placeholder="${t('comment_name_ph')}" required maxlength="50" />
|
||||||
|
<textarea placeholder="${t('comment_ph')}" rows="3" required maxlength="500"></textarea>
|
||||||
|
<!-- honeypot: bots fill this, humans don't see it -->
|
||||||
|
<input type="text" name="website" tabindex="-1" autocomplete="off" style="display:none" />
|
||||||
|
<div class="captcha-row">
|
||||||
|
<label class="captcha-label" id="captcha-question"></label>
|
||||||
|
<input type="number" id="captcha-input" placeholder="Vastaus" required min="0" max="99" style="width:100px" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-outline">${t('comment_btn')}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('modalOverlay').classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
|
const qEl = document.getElementById('captcha-question');
|
||||||
|
if (qEl) qEl.textContent = generateCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitComment(e, postId) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Honeypot check
|
||||||
|
if (e.target.querySelector('[name="website"]').value) return;
|
||||||
|
|
||||||
|
const name = e.target.querySelector('input[type="text"]').value.trim();
|
||||||
|
const text = e.target.querySelector('textarea').value.trim();
|
||||||
|
if (!name || !text) return;
|
||||||
|
|
||||||
|
// CAPTCHA check
|
||||||
|
const captchaInput = parseInt(e.target.querySelector('#captcha-input').value, 10);
|
||||||
|
if (captchaInput !== captchaAnswer) {
|
||||||
|
const qEl = document.getElementById('captcha-question');
|
||||||
|
if (qEl) {
|
||||||
|
qEl.style.color = '#c04040';
|
||||||
|
qEl.textContent = 'Väärä vastaus! ' + generateCaptcha();
|
||||||
|
setTimeout(() => { qEl.style.color = ''; }, 2000);
|
||||||
|
}
|
||||||
|
e.target.querySelector('#captcha-input').value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '...';
|
||||||
|
|
||||||
|
const data = await apiPost('add_comment', { postId, name, text });
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = t('comment_btn');
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
const qEl = document.getElementById('captcha-question');
|
||||||
|
if (qEl) { qEl.style.color = '#c04040'; qEl.textContent = data.error; }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.target.reset();
|
||||||
|
const qEl = document.getElementById('captcha-question');
|
||||||
|
if (qEl) qEl.textContent = generateCaptcha();
|
||||||
|
|
||||||
|
// Refresh comments
|
||||||
|
const commData = await apiGet('comments', { postId });
|
||||||
|
const comments = commData.comments || [];
|
||||||
|
const listEl = document.getElementById(`comments-list-${postId}`);
|
||||||
|
const headEl = document.getElementById(`comments-heading-${postId}`);
|
||||||
|
if (listEl) listEl.innerHTML = renderCommentsList(comments);
|
||||||
|
if (headEl) headEl.textContent = `${t('modal_comments')} (${comments.length})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modalOverlay').classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// PUBLIC SUBMISSION
|
||||||
|
// ===========================
|
||||||
|
let submitCaptchaAnswer = 0;
|
||||||
|
let submitType = 'recipe';
|
||||||
|
|
||||||
|
function generateSubmitCaptcha() {
|
||||||
|
const a = Math.floor(Math.random() * 9) + 1;
|
||||||
|
const b = Math.floor(Math.random() * 9) + 1;
|
||||||
|
submitCaptchaAnswer = a + b;
|
||||||
|
const qEl = document.getElementById('sub-captcha-question');
|
||||||
|
if (qEl) qEl.textContent = `Laske: ${a} + ${b} = ?`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSubmitType(type) {
|
||||||
|
submitType = type;
|
||||||
|
document.getElementById('sub-recipeFields').style.display = type === 'recipe' ? '' : 'none';
|
||||||
|
document.getElementById('sub-postFields').style.display = type === 'post' ? '' : 'none';
|
||||||
|
document.getElementById('sub-typeRecipeBtn').classList.toggle('active', type === 'recipe');
|
||||||
|
document.getElementById('sub-typePostBtn').classList.toggle('active', type === 'post');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSubmitModal() {
|
||||||
|
const cats = APP.categories;
|
||||||
|
const sel = document.getElementById('sub-category');
|
||||||
|
sel.innerHTML = cats.map(c => `<option value="${c.id}">${c.emoji || ''} ${c.fi}</option>`).join('');
|
||||||
|
setSubmitType('recipe');
|
||||||
|
document.getElementById('submitOverlay').classList.add('open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
generateSubmitCaptcha();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSubmitModal() {
|
||||||
|
document.getElementById('submitOverlay').classList.remove('open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImg(input, hiddenId, previewId, labelId) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
try {
|
||||||
|
const res = await fetch('upload.php', { method: 'POST', body: formData });
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById(hiddenId).value = data.url;
|
||||||
|
const prev = document.getElementById(previewId);
|
||||||
|
prev.src = data.url;
|
||||||
|
prev.style.display = 'block';
|
||||||
|
document.getElementById(labelId).classList.add('has-image');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Kuvan lataus epäonnistui: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSubError(msg) {
|
||||||
|
const el = document.getElementById('sub-error');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPublicPost() {
|
||||||
|
document.getElementById('sub-error').style.display = 'none';
|
||||||
|
|
||||||
|
// Honeypot
|
||||||
|
if (document.getElementById('sub-honeypot').value) return;
|
||||||
|
|
||||||
|
const title = document.getElementById('sub-title').value.trim();
|
||||||
|
if (!title) { showSubError('Anna julkaisulle otsikko.'); return; }
|
||||||
|
|
||||||
|
if (!document.getElementById('sub-img1').value) {
|
||||||
|
showSubError('Lisää vähintään 1 kuva julkaisuun.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAPTCHA
|
||||||
|
const captchaVal = parseInt(document.getElementById('sub-captcha-input').value, 10);
|
||||||
|
if (captchaVal !== submitCaptchaAnswer) {
|
||||||
|
showSubError('Väärä vastaus captchaan!');
|
||||||
|
document.getElementById('sub-captcha-input').value = '';
|
||||||
|
generateSubmitCaptcha();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emoji = document.getElementById('sub-emoji').value.trim() || '📝';
|
||||||
|
const category = document.getElementById('sub-category').value;
|
||||||
|
const author = document.getElementById('sub-author').value.trim() || 'Vieras';
|
||||||
|
const desc = document.getElementById('sub-desc').value.trim();
|
||||||
|
const images = [
|
||||||
|
document.getElementById('sub-img1').value.trim(),
|
||||||
|
document.getElementById('sub-img2').value.trim(),
|
||||||
|
document.getElementById('sub-img3').value.trim(),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const post = {
|
||||||
|
id: title.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g,'').replace(/\s+/g,'_').replace(/[^a-z0-9_]/g,'') + '_' + Date.now(),
|
||||||
|
title, emoji, category, author, desc, images,
|
||||||
|
type: submitType,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitType === 'recipe') {
|
||||||
|
post.time = document.getElementById('sub-time').value.trim();
|
||||||
|
post.servings = document.getElementById('sub-servings').value.trim();
|
||||||
|
post.ingredients = document.getElementById('sub-ingredients').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
post.steps = document.getElementById('sub-steps').value.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
|
} else {
|
||||||
|
post.body = document.getElementById('sub-body').value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.querySelector('.submit-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Lähetetään...';
|
||||||
|
|
||||||
|
const data = await apiPost('add_post', { post });
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '📨 Lähetä julkaisu';
|
||||||
|
|
||||||
|
if (data.error) { showSubError(data.error); return; }
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
APP.posts.push(post);
|
||||||
|
|
||||||
|
closeSubmitModal();
|
||||||
|
renderCards();
|
||||||
|
renderCategoryFilters();
|
||||||
|
|
||||||
|
// Reset form fields
|
||||||
|
['sub-title','sub-emoji','sub-author','sub-desc','sub-img1','sub-img2','sub-img3',
|
||||||
|
'sub-time','sub-servings','sub-ingredients','sub-steps','sub-body'].forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
|
[1,2,3].forEach(i => {
|
||||||
|
const prev = document.getElementById(`sub-prev${i}`);
|
||||||
|
const lbl = document.getElementById(`sub-lbl${i}`);
|
||||||
|
if (prev) { prev.src = ''; prev.style.display = 'none'; }
|
||||||
|
if (lbl) lbl.classList.remove('has-image');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('posts')?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// CONTACT FORM
|
||||||
|
// ===========================
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = e.target.querySelector('button[type="submit"]');
|
||||||
|
btn.textContent = t('msg_sent');
|
||||||
|
btn.style.background = '#5c8a4a';
|
||||||
|
btn.disabled = true;
|
||||||
|
e.target.reset();
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = t('send_btn');
|
||||||
|
btn.style.background = '';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================
|
||||||
|
// INIT
|
||||||
|
// ===========================
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const [postsData, catsData, likesData] = await Promise.all([
|
||||||
|
apiGet('posts'),
|
||||||
|
apiGet('categories'),
|
||||||
|
apiGet('likes'),
|
||||||
|
]);
|
||||||
|
APP.posts = postsData.posts || [];
|
||||||
|
APP.categories = catsData.categories || [];
|
||||||
|
APP.likes = likesData.likes || {};
|
||||||
|
APP.userLikes = likesData.userLikes || [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error('API virhe:', e);
|
||||||
|
}
|
||||||
|
applyTranslations();
|
||||||
|
renderCategoryFilters();
|
||||||
|
renderCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
110
server.py
Normal file
110
server.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
tykkää.fi dev server
|
||||||
|
- Serves static files (GET)
|
||||||
|
- Accepts image uploads (POST /upload) → saves to images/
|
||||||
|
"""
|
||||||
|
import http.server, json, mimetypes, os, re, socketserver, time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PORT = 3000
|
||||||
|
IMAGES_DIR = Path('images')
|
||||||
|
IMAGES_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
ALLOWED_MIME = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
|
||||||
|
MAX_BYTES = 8 * 1024 * 1024 # 8 MB
|
||||||
|
|
||||||
|
|
||||||
|
def parse_multipart(data: bytes, boundary: str):
|
||||||
|
"""Return list of (headers_dict, body_bytes) for each part."""
|
||||||
|
sep = ('--' + boundary).encode()
|
||||||
|
parts = []
|
||||||
|
for chunk in data.split(sep):
|
||||||
|
if not chunk or chunk in (b'\r\n', b'--\r\n', b'--'):
|
||||||
|
continue
|
||||||
|
chunk = chunk.lstrip(b'\r\n')
|
||||||
|
if chunk.startswith(b'--'):
|
||||||
|
continue
|
||||||
|
if b'\r\n\r\n' not in chunk:
|
||||||
|
continue
|
||||||
|
hdr_raw, body = chunk.split(b'\r\n\r\n', 1)
|
||||||
|
if body.endswith(b'\r\n'):
|
||||||
|
body = body[:-2]
|
||||||
|
headers = {}
|
||||||
|
for line in hdr_raw.decode(errors='replace').split('\r\n'):
|
||||||
|
if ':' in line:
|
||||||
|
k, v = line.split(':', 1)
|
||||||
|
headers[k.strip().lower()] = v.strip()
|
||||||
|
parts.append((headers, body))
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
if self.path != '/upload':
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
ct = self.headers.get('Content-Type', '')
|
||||||
|
m = re.search(r'boundary=([^\s;]+)', ct)
|
||||||
|
if not m:
|
||||||
|
self.send_error(400, 'Missing boundary')
|
||||||
|
return
|
||||||
|
boundary = m.group(1).strip('"')
|
||||||
|
|
||||||
|
length = int(self.headers.get('Content-Length', 0))
|
||||||
|
if length > MAX_BYTES + 4096:
|
||||||
|
self.send_error(413, 'Request too large')
|
||||||
|
return
|
||||||
|
raw = self.rfile.read(length)
|
||||||
|
|
||||||
|
parts = parse_multipart(raw, boundary)
|
||||||
|
file_part = None
|
||||||
|
for hdrs, body in parts:
|
||||||
|
cd = hdrs.get('content-disposition', '')
|
||||||
|
if 'name="file"' in cd:
|
||||||
|
file_part = (hdrs, body)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not file_part:
|
||||||
|
self.send_error(400, 'No file part')
|
||||||
|
return
|
||||||
|
|
||||||
|
hdrs, data = file_part
|
||||||
|
cd = hdrs.get('content-disposition', '')
|
||||||
|
fn_match = re.search(r'filename="([^"]+)"', cd)
|
||||||
|
filename = fn_match.group(1) if fn_match else 'upload.jpg'
|
||||||
|
|
||||||
|
if len(data) > MAX_BYTES:
|
||||||
|
self.send_error(413, 'File too large (max 8 MB)')
|
||||||
|
return
|
||||||
|
|
||||||
|
mime = mimetypes.guess_type(filename)[0] or ''
|
||||||
|
if mime not in ALLOWED_MIME:
|
||||||
|
self.send_error(415, 'Only images (jpeg/png/gif/webp) allowed')
|
||||||
|
return
|
||||||
|
|
||||||
|
ext = Path(filename).suffix.lower() or '.jpg'
|
||||||
|
fname = f"{int(time.time() * 1000)}{ext}"
|
||||||
|
(IMAGES_DIR / fname).write_bytes(data)
|
||||||
|
|
||||||
|
self._json(200, {'url': f'images/{fname}'})
|
||||||
|
|
||||||
|
def _json(self, code, obj):
|
||||||
|
body = json.dumps(obj).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
self.send_header('Content-Length', str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
pass # suppress request logs
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
os.chdir(Path(__file__).parent)
|
||||||
|
print(f'tykkää.fi running at http://localhost:{PORT}')
|
||||||
|
with socketserver.TCPServer(('', PORT), Handler) as srv:
|
||||||
|
srv.serve_forever()
|
||||||
757
style.css
Normal file
757
style.css
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
/* =====================
|
||||||
|
RESET & BASE
|
||||||
|
===================== */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--cream: #fdf6ec;
|
||||||
|
--warm-brown: #7c4a1e;
|
||||||
|
--light-brown: #c28b5a;
|
||||||
|
--accent: #e07b39;
|
||||||
|
--accent-dark: #c4612a;
|
||||||
|
--text: #3b2a1a;
|
||||||
|
--text-light: #7a5c3e;
|
||||||
|
--card-bg: #fff9f2;
|
||||||
|
--border: #e8d5c0;
|
||||||
|
--shadow: rgba(124, 74, 30, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
background: var(--cream);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
HEADER
|
||||||
|
===================== */
|
||||||
|
header {
|
||||||
|
background: var(--warm-brown);
|
||||||
|
padding: 32px 24px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner { max-width: 1100px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon { font-size: 2.4rem; }
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.4rem;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagline {
|
||||||
|
color: #e8c9a8;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav { display: flex; justify-content: center; gap: 32px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
color: #f5dfc0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a:hover { color: #fff; }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
HERO
|
||||||
|
===================== */
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #d4895a 0%, #e8b88a 50%, #c4732e 100%);
|
||||||
|
padding: 80px 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content { max-width: 600px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.hero h2 {
|
||||||
|
font-size: 2.8rem;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
color: #fef5ea;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--warm-brown);
|
||||||
|
color: #fff;
|
||||||
|
padding: 13px 32px;
|
||||||
|
border-radius: 30px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover { background: #5c3412; transform: translateY(-2px); }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
SEARCH & FILTERS
|
||||||
|
===================== */
|
||||||
|
.controls {
|
||||||
|
padding: 36px 24px 24px;
|
||||||
|
background: #f5ece0;
|
||||||
|
border-bottom: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls .container { display: flex; flex-direction: column; align-items: center; gap: 16px; }
|
||||||
|
|
||||||
|
#search {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 30px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.filters { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
||||||
|
|
||||||
|
.filter-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn:hover, .filter-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
RECIPE GRID
|
||||||
|
===================== */
|
||||||
|
.recipe-section { padding: 48px 24px; }
|
||||||
|
|
||||||
|
.recipe-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 16px var(--shadow);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 28px var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-img {
|
||||||
|
background: linear-gradient(135deg, #f5dfc0, #e8c4a0);
|
||||||
|
font-size: 4rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-has-photo {
|
||||||
|
padding: 0;
|
||||||
|
height: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-has-photo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body { padding: 20px; }
|
||||||
|
|
||||||
|
.category-tag {
|
||||||
|
display: inline-block;
|
||||||
|
background: #fde8d0;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--warm-brown);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body p {
|
||||||
|
font-size: 0.93rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
padding: 9px 22px;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-results {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
ABOUT
|
||||||
|
===================== */
|
||||||
|
.about {
|
||||||
|
background: var(--warm-brown);
|
||||||
|
padding: 60px 24px;
|
||||||
|
color: #f5dfc0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 36px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-emoji { font-size: 5rem; }
|
||||||
|
|
||||||
|
.about h2 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about p { font-size: 1rem; line-height: 1.8; max-width: 680px; }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
CONTACT
|
||||||
|
===================== */
|
||||||
|
.contact {
|
||||||
|
padding: 60px 24px;
|
||||||
|
background: #f5ece0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact h2 { font-size: 1.9rem; color: var(--warm-brown); margin-bottom: 8px; }
|
||||||
|
.contact > .container > p { color: var(--text-light); margin-bottom: 28px; }
|
||||||
|
|
||||||
|
.contact-form {
|
||||||
|
display: flex;
|
||||||
|
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;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contact-form input:focus,
|
||||||
|
.contact-form textarea:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
FOOTER
|
||||||
|
===================== */
|
||||||
|
footer {
|
||||||
|
background: #3b2a1a;
|
||||||
|
color: #b89070;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
MODAL
|
||||||
|
===================== */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(30, 18, 8, 0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: 20px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 36px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 20px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-light);
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
.modal h2 { color: var(--warm-brown); font-size: 1.8rem; margin-bottom: 6px; }
|
||||||
|
.modal .modal-meta { color: var(--text-light); font-size: 0.9rem; font-family: 'Arial', sans-serif; margin-bottom: 20px; }
|
||||||
|
.modal .modal-emoji { font-size: 3.5rem; margin-bottom: 16px; display: block; }
|
||||||
|
.modal h3 { color: var(--warm-brown); margin: 20px 0 10px; font-size: 1.1rem; }
|
||||||
|
.modal ul, .modal ol { padding-left: 22px; color: var(--text); }
|
||||||
|
.modal li { margin-bottom: 6px; font-size: 0.97rem; }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
IMAGE GALLERY
|
||||||
|
===================== */
|
||||||
|
.img-gallery {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-gallery img {
|
||||||
|
width: 180px;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
SUBMIT FORM (in modal)
|
||||||
|
===================== */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--text-light);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus,
|
||||||
|
.form-group textarea:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 13px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover { background: var(--accent-dark); transform: translateY(-2px); }
|
||||||
|
|
||||||
|
.sub-error {
|
||||||
|
color: #c04040;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-post-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-post-btn:hover { background: var(--accent-dark); transform: translateY(-2px); }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
IMAGE UPLOAD SLOTS
|
||||||
|
===================== */
|
||||||
|
.img-upload-slots {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-upload-slot {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-upload-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-upload-btn:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-upload-btn.has-image {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-style: solid;
|
||||||
|
color: var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-preview {
|
||||||
|
width: 100px;
|
||||||
|
height: 72px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
LANGUAGE TOGGLE
|
||||||
|
===================== */
|
||||||
|
.lang-toggle {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.4);
|
||||||
|
border-radius: 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: #f5dfc0;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.lang-toggle:hover { background: rgba(255,255,255,0.15); color: #fff; border-color: #fff; }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
CARD AUTHOR + ACTIONS
|
||||||
|
===================== */
|
||||||
|
.card-author {
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
color: var(--light-brown) !important;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
margin-bottom: 4px !important;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-btn {
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: 2px solid #e8c0c8;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
color: #c06080;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.like-btn:hover { background: #fde8f0; border-color: #e07090; }
|
||||||
|
.like-btn.liked { background: #fde8f0; border-color: #e07090; color: #c03060; }
|
||||||
|
|
||||||
|
.like-count {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-light);
|
||||||
|
font-weight: bold;
|
||||||
|
min-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-like-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
POST BODY (non-recipe)
|
||||||
|
===================== */
|
||||||
|
.post-body { color: var(--text); line-height: 1.8; }
|
||||||
|
.post-body p { margin-bottom: 12px; }
|
||||||
|
.post-body ul, .post-body ol { padding-left: 22px; margin-bottom: 12px; }
|
||||||
|
.post-body li { margin-bottom: 6px; }
|
||||||
|
.post-body strong { color: var(--warm-brown); }
|
||||||
|
|
||||||
|
.empty-note { color: var(--text-light); font-style: italic; font-size: 0.93rem; }
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
COMMENTS
|
||||||
|
===================== */
|
||||||
|
.comments-section {
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.comments-section h3 { color: var(--warm-brown); font-size: 1.1rem; margin-bottom: 14px; }
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
background: #f5ece0;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.comment-header strong { color: var(--warm-brown); }
|
||||||
|
.comment-header span { color: var(--text-light); font-size: 0.78rem; }
|
||||||
|
.comment p { font-size: 0.93rem; color: var(--text); margin: 0; }
|
||||||
|
|
||||||
|
.comment-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.comment-form input,
|
||||||
|
.comment-form textarea {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
font-family: 'Georgia', serif;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.comment-form input:focus,
|
||||||
|
.comment-form textarea:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
.captcha-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: #f5ece0;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
}
|
||||||
|
.captcha-label {
|
||||||
|
font-family: 'Arial', sans-serif;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--warm-brown);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.captcha-row input {
|
||||||
|
padding: 7px 10px !important;
|
||||||
|
border-radius: 6px !important;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================
|
||||||
|
RESPONSIVE
|
||||||
|
===================== */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
header h1 { font-size: 1.7rem; }
|
||||||
|
.hero h2 { font-size: 2rem; }
|
||||||
|
.about-inner { flex-direction: column; text-align: center; }
|
||||||
|
.recipe-grid { grid-template-columns: 1fr; }
|
||||||
|
nav { gap: 16px; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
63
upload.php
Normal file
63
upload.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* tykkää.fi — kuvaupload-käsittelijä
|
||||||
|
* Tallentaa kuvat images/-kansioon.
|
||||||
|
*/
|
||||||
|
|
||||||
|
$allowed_mime = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
$max_bytes = 8 * 1024 * 1024; // 8 Mt
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['error' => 'Method not allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($_FILES['file'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'No file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$f = $_FILES['file'];
|
||||||
|
|
||||||
|
if ($f['error'] !== UPLOAD_ERR_OK) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Upload error: ' . $f['error']]);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($f['size'] > $max_bytes) {
|
||||||
|
http_response_code(413);
|
||||||
|
echo json_encode(['error' => 'File too large (max 8 MB)']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mime = mime_content_type($f['tmp_name']);
|
||||||
|
if (!in_array($mime, $allowed_mime, true)) {
|
||||||
|
http_response_code(415);
|
||||||
|
echo json_encode(['error' => 'Only images (jpeg/png/gif/webp) allowed']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ext_map = ['image/jpeg' => '.jpg', 'image/png' => '.png',
|
||||||
|
'image/gif' => '.gif', 'image/webp' => '.webp'];
|
||||||
|
$ext = $ext_map[$mime] ?? '.jpg';
|
||||||
|
|
||||||
|
$dir = __DIR__ . '/images/';
|
||||||
|
if (!is_dir($dir)) {
|
||||||
|
mkdir($dir, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fname = round(microtime(true) * 1000) . $ext;
|
||||||
|
$dest = $dir . $fname;
|
||||||
|
|
||||||
|
if (!move_uploaded_file($f['tmp_name'], $dest)) {
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'Failed to save file']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode(['url' => 'images/' . $fname]);
|
||||||
Reference in New Issue
Block a user