Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-65 into main AGit
6 changed files with 185 additions and 2 deletions
Showing only changes of commit 6980d72041 - Show all commits

View file

@ -1 +1 @@
0.3.17-beta 0.3.18-beta

View file

@ -86,6 +86,8 @@
.tbtn:hover{filter:brightness(.94);} .tbtn:hover{filter:brightness(.94);}
.tbtn.ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line);} .tbtn.ghost{background:var(--surface); color:var(--ink); border:1px solid var(--line);}
.tbtn.ghost:hover{background:var(--surface-3); filter:none;} .tbtn.ghost:hover{background:var(--surface-3); filter:none;}
/* bewapende bevestigingsstap (reservekopie herstellen e.d.) */
.tbtn.danger{background:var(--red, #d33); color:#fff; border-color:transparent;}
/* in de menubalk zelf zijn ghost-knoppen transparant (rustiger beeld) */ /* in de menubalk zelf zijn ghost-knoppen transparant (rustiger beeld) */
#toolbar .tbtn.ghost{background:transparent; border:none;} #toolbar .tbtn.ghost{background:transparent; border:none;}
#toolbar .tbtn.ghost:hover{background:var(--surface-2);} #toolbar .tbtn.ghost:hover{background:var(--surface-2);}

View file

@ -156,6 +156,12 @@
</div> </div>
</div> </div>
<div class="am-h3" id="backupsHead" data-i18n="stBackups"></div>
<div class="am-reset" id="backupsBlock">
<div id="backupsList"></div>
<div class="guestnote" data-i18n="backupsNote"></div>
</div>
<div class="am-h3" data-i18n="stSession"></div> <div class="am-h3" data-i18n="stSession"></div>
<div class="am-reset"> <div class="am-reset">
<div class="lrow"> <div class="lrow">

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.3.17-beta"; const VERSION = "0.3.18-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -139,6 +139,9 @@ const I18N = {
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…", settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie", stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie",
stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.",
backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?",
backupRestored:"Versie hersteld ✓",
stView:"Weergave", vwTheme:"Thema", vwLight:"Licht", vwDark:"Donker", vwAuto:"Auto", stView:"Weergave", vwTheme:"Thema", vwLight:"Licht", vwDark:"Donker", vwAuto:"Auto",
vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal", vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal",
vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts", vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts",
@ -302,6 +305,9 @@ const I18N = {
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…", settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…",
stAccount:"Account", stPassword:"Password", stSession:"Session", stAccount:"Account", stPassword:"Password", stSession:"Session",
stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.",
backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?",
backupRestored:"Version restored ✓",
stView:"Display", vwTheme:"Theme", vwLight:"Light", vwDark:"Dark", vwAuto:"Auto", stView:"Display", vwTheme:"Theme", vwLight:"Light", vwDark:"Dark", vwAuto:"Auto",
vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language", vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language",
vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right", vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right",
@ -579,8 +585,61 @@ function openSettings(){
(zo blijven taal/contrast/thema ook als gast bereikbaar) */ (zo blijven taal/contrast/thema ook als gast bereikbaar) */
document.querySelector('#settingsTabs button[data-tab="account"]').style.display = currentUser ? "" : "none"; document.querySelector('#settingsTabs button[data-tab="account"]').style.display = currentUser ? "" : "none";
switchSettingsTab(currentUser ? "account" : "view"); switchSettingsTab(currentUser ? "account" : "view");
loadBackups();
settingsWrap.classList.add("open"); settingsWrap.classList.add("open");
} }
/* reservekopieën (Instellingen Account): automatisch bewaarde eerdere
versies van de borddata, met een arm-then-confirm herstelknop zoals de
verwijderknop in de boardStrip */
async function loadBackups(){
const head = document.getElementById("backupsHead");
const block = document.getElementById("backupsBlock");
const list = document.getElementById("backupsList");
const show = canPersist();
head.style.display = block.style.display = show ? "" : "none";
if(!show) return;
list.textContent = "";
let versions = [];
try{ versions = (await api("/me/data/versions")).versions || []; }
catch(e){ list.textContent = e.message; return; }
if(!versions.length){
const d = document.createElement("div");
d.className = "guestnote"; d.textContent = T("backupsEmpty");
list.appendChild(d);
return;
}
versions.forEach(v=>{
const row = document.createElement("div");
row.className = "lrow";
const when = document.createElement("span");
when.className = "am-code";
when.textContent = new Date(v.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB");
const btn = document.createElement("button");
btn.className = "tbtn ghost";
btn.type = "button";
btn.textContent = T("backupRestore");
let armed = false;
btn.addEventListener("click", async ()=>{
if(!armed){ armed = true; btn.textContent = T("backupRestoreConfirm"); btn.classList.add("danger"); return; }
try{
const res = await api("/me/data/restore", { body:{ versionId: v.id } });
DATA_REV = typeof res.rev === "number" ? res.rev : DATA_REV;
const d = res.data || {};
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
settingsMsg.textContent = T("backupRestored");
settingsMsg.classList.add("ok");
loadBackups();
}catch(e){ settingsMsg.textContent = e.message; }
});
row.appendChild(when);
row.appendChild(btn);
list.appendChild(row);
});
}
document.getElementById("btnUser").addEventListener("click", ()=>{ document.getElementById("btnUser").addEventListener("click", ()=>{
if(currentUser){ openSettings(); return; } if(currentUser){ openSettings(); return; }
loginMsg.textContent = ""; loginMsg.classList.remove("ok"); loginMsg.textContent = ""; loginMsg.classList.remove("ok");

View file

@ -166,7 +166,27 @@ export default async function api(app) {
const n = Number(h); const n = Number(h);
return Number.isInteger(n) && n >= 0 ? n : null; return Number.isInteger(n) && n >= 0 ? n : null;
}; };
// Versiegeschiedenis: vóór een overschrijving de oude data bewaren, maar
// hooguit één snapshot per 10 minuten (autosave draait elke 2,5s) en
// maximaal 10 versies per gebruiker.
const SNAPSHOT_MIN_AGE = "10 minutes";
const SNAPSHOT_KEEP = 10;
const snapshotUserData = async (userId) => {
const fresh = (await pool.query(
`SELECT 1 FROM user_data_versions WHERE user_id = $1 AND created_at > now() - $2::interval LIMIT 1`,
[userId, SNAPSHOT_MIN_AGE])).rows[0];
if (fresh) return;
await pool.query(
`INSERT INTO user_data_versions (user_id, data)
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
[userId]);
await pool.query(
`DELETE FROM user_data_versions WHERE user_id = $1 AND id NOT IN (
SELECT id FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2
)`, [userId, SNAPSHOT_KEEP]);
};
const writeUserData = async (req, reply, data) => { const writeUserData = async (req, reply, data) => {
await snapshotUserData(req.user.id);
const rev = revFromRequest(req); const rev = revFromRequest(req);
const r = rev === null const r = rev === null
? await pool.query( ? await pool.query(
@ -178,6 +198,34 @@ export default async function api(app) {
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt'); if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
return { ok: true, rev: Number(r.rows[0].data_rev) }; return { ok: true, rev: Number(r.rows[0].data_rev) };
}; };
app.get('/me/data/versions', async (req, reply) => {
need(req, reply);
if (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten');
const rows = (await pool.query(
'SELECT id, created_at FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id])).rows;
return { versions: rows.map((v) => ({ id: Number(v.id), createdAt: v.created_at })) };
});
app.post('/me/data/restore', async (req, reply) => {
need(req, reply);
if (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten');
const { versionId } = req.body ?? {};
if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie');
const v = (await pool.query(
'SELECT data FROM user_data_versions WHERE id = $1 AND user_id = $2',
[versionId, req.user.id])).rows[0];
if (!v) return fail(reply, 404, 'versie onbekend');
// de huidige data eerst zelf als versie bewaren, zodat een herstel ook
// weer ongedaan gemaakt kan worden (zonder 10-minuten-drempel)
await pool.query(
`INSERT INTO user_data_versions (user_id, data)
SELECT id, data FROM users WHERE id = $1 AND data IS NOT NULL AND data <> '{}'::jsonb`,
[req.user.id]);
const r = await pool.query(
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
[v.data, req.user.id]);
return { data: v.data, rev: Number(r.rows[0].data_rev) };
});
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => { app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
need(req, reply); need(req, reply);
const data = req.body ?? {}; const data = req.body ?? {};

View file

@ -76,3 +76,71 @@ test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async
assert.ok(!upd.sql.includes('AND data_rev')); assert.ok(!upd.sql.includes('AND data_rev'));
await app.close(); await app.close();
}); });
test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
assert.equal(res.statusCode, 200, res.body);
const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions'));
const cleanIdx = calls.findIndex((c) => c.sql.includes('DELETE FROM user_data_versions'));
const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(snapIdx >= 0 && cleanIdx >= 0);
assert.ok(snapIdx < updIdx, 'snapshot vóór de overschrijving');
assert.equal(calls[cleanIdx].params[1], 10);
await app.close();
});
test('GET /me/data/versions weigert leerlingen', async () => {
const { app } = await makeApp({ dataRev: 3 });
// aparte app met pupil-rol
const pupilApp = await (async () => {
const calls = [];
const pool = { async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
id: 8, username: 'kind', role: 'pupil', school_id: 2, class_id: 5, data: {}, data_rev: 0,
}] };
return { rows: [] };
} };
const a = Fastify({ trustProxy: 2 });
await a.register(cookie);
await a.register(rateLimit, { global: false });
a.decorate('pg', pool);
await a.register(api, { prefix: '/api' });
await a.ready();
return a;
})();
const res = await pupilApp.inject({ method: 'GET', url: '/api/me/data/versions', cookies: sessionCookie });
assert.equal(res.statusCode, 403);
await pupilApp.close();
await app.close();
});
test('POST /me/data/restore zet een versie terug en bewaart de huidige eerst', async () => {
const calls = [];
const pool = { async query(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
id: 7, username: 'docent', role: 'teacher', school_id: 2, class_id: null, data: { boards: { f: 1 } }, data_rev: 6,
}] };
if (sql.includes('FROM user_roles')) return { rows: [] };
if (sql.startsWith('SELECT data FROM user_data_versions')) return { rows: [{ data: { boards: { f: 0 } } }] };
if (sql.startsWith('UPDATE users SET data =')) return { rows: [{ data_rev: 7 }] };
return { rows: [] };
} };
const app = Fastify({ trustProxy: 2 });
await app.register(cookie);
await app.register(rateLimit, { global: false });
app.decorate('pg', pool);
await app.register(api, { prefix: '/api' });
await app.ready();
const res = await app.inject({ method: 'POST', url: '/api/me/data/restore', cookies: sessionCookie,
payload: { versionId: 12 } });
assert.equal(res.statusCode, 200, res.body);
assert.deepEqual(res.json(), { data: { boards: { f: 0 } }, rev: 7 });
const snapIdx = calls.findIndex((c) => c.sql.includes('INSERT INTO user_data_versions'));
const updIdx = calls.findIndex((c) => c.sql.startsWith('UPDATE users SET data ='));
assert.ok(snapIdx >= 0 && snapIdx < updIdx, 'huidige data eerst bewaard');
await app.close();
});