move to indexedb and add about page
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 7s
This commit is contained in:
parent
a8809afdfd
commit
772b9951de
12 changed files with 855 additions and 249 deletions
|
|
@ -8,15 +8,23 @@ import { INITIAL_DIABLO4_STATE, INITIAL_MHWILDS_STATE, loadDiablo4Data, loadMhwi
|
|||
import { AddToolControls, ToolboxModules, TOOLBOX_MODULES } from "./features/toolboxes/modules/index.jsx";
|
||||
import { usePointerReorder } from "./hooks/usePointerReorder.js";
|
||||
import { lockBodyScroll } from "./utils/bodyScrollLock.js";
|
||||
import {
|
||||
getAllModuleData as dbGetAllModuleData,
|
||||
getLinks as dbGetLinks,
|
||||
getSetting as dbGetSetting,
|
||||
getStorageEstimate,
|
||||
getToolboxes as dbGetToolboxes,
|
||||
removeModuleData as dbRemoveModuleData,
|
||||
removeModuleDataKeys as dbRemoveModuleDataKeys,
|
||||
setLinks as dbSetLinks,
|
||||
setModuleData as dbSetModuleData,
|
||||
setSetting as dbSetSetting,
|
||||
setToolboxes as dbSetToolboxes
|
||||
} from "./utils/indexedDbStorage.js";
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
registry: "sokkog:toolboxes",
|
||||
links: "sokkog:game-toolbox-links",
|
||||
drawerWidth: "sokkog:drawer-width"
|
||||
};
|
||||
const APP_STORAGE_PREFIX = "sokkog:";
|
||||
const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
|
||||
const APP_STORAGE_WARNING_RATIO = 0.85;
|
||||
const APP_STORAGE_SOFT_LIMIT_BYTES = 250 * 1024 * 1024;
|
||||
const DRAWER_WIDTH_SETTING = "drawerWidth";
|
||||
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c", calc: "r", marker: "k" };
|
||||
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
|
||||
|
|
@ -46,7 +54,7 @@ const DEFAULT_TOOLBOX_ICON = `${TOOLBOX_ICON_BASE}toolbox.png`;
|
|||
|
||||
const DEFAULT_SITE_CONTENT = {
|
||||
brand: { name: "Sokko G", homeAriaLabel: "Accueil Sokko G" },
|
||||
navigation: { home: "Accueil", toolboxes: "Toolboxes", games: "Jeux", mobileGames: "Infos" },
|
||||
navigation: { home: "Accueil", toolboxes: "Toolboxes", games: "Jeux", about: "C'est quoi Sokko G ?", mobileGames: "Infos" },
|
||||
sidebar: { badge: "", note: "" },
|
||||
topbar: { dashboard: "", toolbox: "", games: "" },
|
||||
gamesPage: {
|
||||
|
|
@ -78,11 +86,30 @@ const DEFAULT_SITE_CONTENT = {
|
|||
caption: "",
|
||||
lines: []
|
||||
},
|
||||
aboutLink: "Comprendre Sokko G",
|
||||
legal: {
|
||||
copyright: "",
|
||||
disclaimer: ""
|
||||
}
|
||||
},
|
||||
about: {
|
||||
eyebrow: "À propos",
|
||||
title: "C'est quoi Sokko G ?",
|
||||
description: "Sokko G est une webapp locale pensée pour garder les bons outils et les bonnes informations à portée de main pendant une session gaming.",
|
||||
sections: [],
|
||||
toolsTitle: "Outils disponibles",
|
||||
tools: [
|
||||
{ icon: "notepad", name: "Bloc notes", description: "Notes rapides pendant une session." },
|
||||
{ icon: "checklist", name: "Checklist", description: "Objectifs et quantités à suivre." },
|
||||
{ icon: "picture", name: "Images", description: "Images ajoutées par fichier ou collage." },
|
||||
{ icon: "map", name: "Annotation d'images", description: "Marqueurs sur une carte ou un plan." },
|
||||
{ icon: "link", name: "Liens", description: "Raccourcis vers guides, builds ou cartes." },
|
||||
{ icon: "abacus", name: "Compteurs", description: "Scores, ressources, essais ou résultats." },
|
||||
{ icon: "calculator", name: "Calculateur", description: "Calculs enregistrés et chaînes de craft." }
|
||||
],
|
||||
limitsTitle: "À retenir",
|
||||
limits: []
|
||||
},
|
||||
toolboxes: {
|
||||
eyebrow: "",
|
||||
title: "Toolboxes",
|
||||
|
|
@ -194,51 +221,13 @@ function uid(prefix) {
|
|||
return `${ID_PREFIXES[prefix] || "x"}${randomToken()}`;
|
||||
}
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringStorageBytes(value) {
|
||||
return String(value || "").length * 2;
|
||||
}
|
||||
|
||||
function getAppStorageUsage(projected = {}) {
|
||||
let used = 0;
|
||||
for (let index = 0; index < localStorage.length; index += 1) {
|
||||
const key = localStorage.key(index);
|
||||
if (!key?.startsWith(APP_STORAGE_PREFIX) || Object.prototype.hasOwnProperty.call(projected, key)) continue;
|
||||
used += stringStorageBytes(key) + stringStorageBytes(localStorage.getItem(key));
|
||||
}
|
||||
Object.entries(projected).forEach(([key, value]) => {
|
||||
if (!key.startsWith(APP_STORAGE_PREFIX) || value == null) return;
|
||||
used += stringStorageBytes(key) + stringStorageBytes(value);
|
||||
});
|
||||
return { used, limit: APP_STORAGE_LIMIT_BYTES, ratio: Math.min(1, used / APP_STORAGE_LIMIT_BYTES) };
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return "0 o";
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1).replace(".", ",")} Mio`;
|
||||
if (bytes >= 1024) return `${Math.ceil(bytes / 1024)} Kio`;
|
||||
return `${bytes} o`;
|
||||
}
|
||||
|
||||
function writeStorageValue(key, value) {
|
||||
const projectedUsage = getAppStorageUsage({ [key]: value });
|
||||
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) {
|
||||
throw new Error(`Quota local atteint (${formatBytes(projectedUsage.used)} / ${formatBytes(projectedUsage.limit)}).`);
|
||||
}
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
writeStorageValue(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function getDefaultModuleTitle(type) {
|
||||
return TOOLBOX_MODULES[type]?.label || "Outil";
|
||||
}
|
||||
|
|
@ -295,37 +284,14 @@ function compactToolboxesForStorage(toolboxes) {
|
|||
return (Array.isArray(toolboxes) ? toolboxes : []).map(compactToolboxForStorage).filter(Boolean);
|
||||
}
|
||||
|
||||
function loadToolboxes() {
|
||||
return (Array.isArray(readJson(STORAGE_KEYS.registry, [])) ? readJson(STORAGE_KEYS.registry, []) : [])
|
||||
.map(normalizeToolbox)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function saveToolboxes(toolboxes) {
|
||||
writeJson(STORAGE_KEYS.registry, compactToolboxesForStorage(toolboxes));
|
||||
}
|
||||
|
||||
function loadLinks() {
|
||||
return readJson(STORAGE_KEYS.links, {});
|
||||
}
|
||||
|
||||
function saveLinks(links) {
|
||||
writeJson(STORAGE_KEYS.links, links);
|
||||
}
|
||||
|
||||
function moduleStorageKey(toolboxId, moduleId) {
|
||||
return `sokkog:toolbox:${toolboxId}:module:${moduleId}`;
|
||||
return `${toolboxId}:${moduleId}`;
|
||||
}
|
||||
|
||||
function globalModuleKey(toolboxId, moduleId) {
|
||||
return `${toolboxId}:${moduleId}`;
|
||||
}
|
||||
|
||||
function getModuleData(toolboxId, moduleId, fallback) {
|
||||
const value = readJson(moduleStorageKey(toolboxId, moduleId), undefined);
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
function parsePositiveInt(value, fallback = 1) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
|
|
@ -517,15 +483,9 @@ function compactModuleDataForStorage(type, value) {
|
|||
return value;
|
||||
}
|
||||
|
||||
function setModuleData(toolboxes, toolboxId, moduleId, value, moduleType = "") {
|
||||
function prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType = "") {
|
||||
const module = toolboxes.find((toolbox) => toolbox.id === toolboxId)?.modules.find((item) => item.id === moduleId);
|
||||
const compact = compactModuleDataForStorage(moduleType || module?.type, value);
|
||||
const key = moduleStorageKey(toolboxId, moduleId);
|
||||
if (compact == null) {
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
writeJson(key, compact);
|
||||
return compactModuleDataForStorage(moduleType || module?.type, value);
|
||||
}
|
||||
|
||||
function createExportIdFactory() {
|
||||
|
|
@ -578,7 +538,7 @@ function remapModuleDataForExport(type, data, nextId) {
|
|||
return compact;
|
||||
}
|
||||
|
||||
function createToolboxExportPayload(toolbox) {
|
||||
function createToolboxExportPayload(toolbox, moduleData) {
|
||||
const source = normalizeToolbox(toolbox);
|
||||
const nextId = createExportIdFactory();
|
||||
const moduleIdMap = new Map();
|
||||
|
|
@ -594,17 +554,17 @@ function createToolboxExportPayload(toolbox) {
|
|||
const modules = Object.fromEntries(source.modules
|
||||
.map((module) => [
|
||||
moduleIdMap.get(module.id),
|
||||
remapModuleDataForExport(module.type, getModuleData(source.id, module.id, null), nextId)
|
||||
remapModuleDataForExport(module.type, moduleData[moduleStorageKey(source.id, module.id)] || null, nextId)
|
||||
])
|
||||
.filter(([, data]) => data != null));
|
||||
return { toolbox: exportedToolbox, modules };
|
||||
}
|
||||
|
||||
function createGlobalExportPayload(toolboxes, links) {
|
||||
function createGlobalExportPayload(toolboxes, links, moduleData) {
|
||||
const modules = {};
|
||||
toolboxes.forEach((toolbox) => {
|
||||
toolbox.modules.forEach((module) => {
|
||||
const data = compactModuleDataForStorage(module.type, getModuleData(toolbox.id, module.id, null));
|
||||
const data = compactModuleDataForStorage(module.type, moduleData[moduleStorageKey(toolbox.id, module.id)] || null);
|
||||
if (data) modules[globalModuleKey(toolbox.id, module.id)] = data;
|
||||
});
|
||||
});
|
||||
|
|
@ -707,56 +667,118 @@ function useModalScrollLock() {
|
|||
useEffect(() => lockBodyScroll(), []);
|
||||
}
|
||||
|
||||
function useLocalToolboxes(onError) {
|
||||
const [toolboxes, setToolboxesState] = useState(loadToolboxes);
|
||||
const [links, setLinksState] = useState(loadLinks);
|
||||
const [, setQuotaTick] = useState(0);
|
||||
function useIndexedToolboxes(onError) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [toolboxes, setToolboxesState] = useState([]);
|
||||
const [links, setLinksState] = useState({});
|
||||
const [moduleData, setModuleDataState] = useState({});
|
||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||
|
||||
function refreshQuota() {
|
||||
setQuotaTick((value) => value + 1);
|
||||
async function refreshQuota() {
|
||||
try {
|
||||
const estimate = await getStorageEstimate();
|
||||
const used = estimate.usage || 0;
|
||||
const limit = estimate.quota || 0;
|
||||
setStorageUsage({ used, limit, ratio: limit ? Math.min(1, used / limit) : 0 });
|
||||
} catch {
|
||||
setStorageUsage({ used: 0, limit: 0, ratio: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
function persistToolboxes(nextToolboxes) {
|
||||
try {
|
||||
saveToolboxes(nextToolboxes);
|
||||
setToolboxesState(loadToolboxes());
|
||||
refreshQuota();
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error.message);
|
||||
return false;
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadStore() {
|
||||
try {
|
||||
const [storedToolboxes, storedLinks, storedModules] = await Promise.all([
|
||||
dbGetToolboxes(),
|
||||
dbGetLinks(),
|
||||
dbGetAllModuleData()
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setToolboxesState((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
|
||||
setLinksState(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
|
||||
setModuleDataState(Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data])));
|
||||
setReady(true);
|
||||
refreshQuota();
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
onError(error.message || "Stockage IndexedDB indisponible.");
|
||||
}
|
||||
}
|
||||
}
|
||||
loadStore();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
function persistToolboxes(nextToolboxes) {
|
||||
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
|
||||
setToolboxesState(normalized);
|
||||
dbSetToolboxes(compactToolboxesForStorage(normalized))
|
||||
.then(refreshQuota)
|
||||
.catch((error) => onError(error.message));
|
||||
return true;
|
||||
}
|
||||
|
||||
function persistLinks(nextLinks) {
|
||||
try {
|
||||
saveLinks(nextLinks);
|
||||
setLinksState(loadLinks());
|
||||
refreshQuota();
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error.message);
|
||||
return false;
|
||||
}
|
||||
setLinksState(nextLinks);
|
||||
dbSetLinks(nextLinks)
|
||||
.then(refreshQuota)
|
||||
.catch((error) => onError(error.message));
|
||||
return true;
|
||||
}
|
||||
|
||||
function getModuleData(toolboxId, moduleId, fallback) {
|
||||
const value = moduleData[moduleStorageKey(toolboxId, moduleId)];
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
||||
try {
|
||||
setModuleData(toolboxes, toolboxId, moduleId, value, moduleType);
|
||||
refreshQuota();
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error.message);
|
||||
return false;
|
||||
}
|
||||
const compact = prepareModuleDataForStorage(toolboxes, toolboxId, moduleId, value, moduleType);
|
||||
const key = moduleStorageKey(toolboxId, moduleId);
|
||||
setModuleDataState((state) => {
|
||||
const nextState = { ...state };
|
||||
if (compact == null) delete nextState[key];
|
||||
else nextState[key] = compact;
|
||||
return nextState;
|
||||
});
|
||||
const operation = compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
|
||||
operation.then(refreshQuota).catch((error) => onError(error.message));
|
||||
return true;
|
||||
}
|
||||
|
||||
function removeModuleData(toolboxId, moduleId) {
|
||||
const key = moduleStorageKey(toolboxId, moduleId);
|
||||
setModuleDataState((state) => {
|
||||
const nextState = { ...state };
|
||||
delete nextState[key];
|
||||
return nextState;
|
||||
});
|
||||
dbRemoveModuleData(key).then(refreshQuota).catch((error) => onError(error.message));
|
||||
}
|
||||
|
||||
function removeToolboxModuleData(toolbox) {
|
||||
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
||||
setModuleDataState((state) => {
|
||||
const nextState = { ...state };
|
||||
keys.forEach((key) => delete nextState[key]);
|
||||
return nextState;
|
||||
});
|
||||
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
|
||||
}
|
||||
|
||||
return {
|
||||
ready,
|
||||
toolboxes,
|
||||
links,
|
||||
moduleData,
|
||||
storageUsage,
|
||||
setToolboxes: persistToolboxes,
|
||||
setLinks: persistLinks,
|
||||
getModuleData,
|
||||
updateModuleData,
|
||||
removeModuleData,
|
||||
removeToolboxModuleData,
|
||||
refreshQuota
|
||||
};
|
||||
}
|
||||
|
|
@ -779,7 +801,7 @@ function App() {
|
|||
const [drawerGameId, setDrawerGameId] = useState("");
|
||||
const [screenshot, setScreenshot] = useState(null);
|
||||
const [storageError, setStorageError] = useState("");
|
||||
const store = useLocalToolboxes((message) => setStorageError(message));
|
||||
const store = useIndexedToolboxes((message) => setStorageError(message));
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
|
|
@ -842,7 +864,7 @@ function App() {
|
|||
|
||||
function deleteToolbox(id) {
|
||||
const toolbox = store.toolboxes.find((item) => item.id === id);
|
||||
toolbox?.modules.forEach((module) => localStorage.removeItem(moduleStorageKey(id, module.id)));
|
||||
store.removeToolboxModuleData(toolbox);
|
||||
store.setToolboxes(store.toolboxes.filter((item) => item.id !== id));
|
||||
const nextLinks = { ...store.links };
|
||||
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
|
||||
|
|
@ -866,7 +888,7 @@ function App() {
|
|||
async function addScreenshotFiles(toolboxId, moduleId, files) {
|
||||
const imageFiles = [...files].filter((file) => file?.type?.startsWith("image/"));
|
||||
if (!imageFiles.length) return false;
|
||||
const data = getModuleData(toolboxId, moduleId, { shots: [] });
|
||||
const data = store.getModuleData(toolboxId, moduleId, { shots: [] });
|
||||
for (const file of imageFiles) {
|
||||
data.shots.unshift({ id: uid("shot"), dataUrl: await compressImage(file) });
|
||||
}
|
||||
|
|
@ -886,7 +908,8 @@ function App() {
|
|||
if (!store.setToolboxes([imported, ...store.toolboxes])) return null;
|
||||
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
|
||||
const nextId = moduleIdMap.get(oldId);
|
||||
if (nextId) store.updateModuleData(imported.id, nextId, data);
|
||||
const module = imported.modules.find((item) => item.id === nextId);
|
||||
if (nextId) store.updateModuleData(imported.id, nextId, data, module?.type);
|
||||
});
|
||||
if (gameId) linkToolboxToGame(gameId, imported.id);
|
||||
return imported;
|
||||
|
|
@ -914,10 +937,6 @@ function App() {
|
|||
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
||||
if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
|
||||
});
|
||||
const projected = {
|
||||
[STORAGE_KEYS.registry]: JSON.stringify(compactToolboxesForStorage([...importedToolboxes, ...store.toolboxes])),
|
||||
[STORAGE_KEYS.links]: JSON.stringify(nextLinks)
|
||||
};
|
||||
Object.entries(payload.modules).forEach(([key, data]) => {
|
||||
const [oldToolboxId] = key.split(":");
|
||||
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
||||
|
|
@ -925,13 +944,10 @@ function App() {
|
|||
const toolbox = importedToolboxes.find((item) => item.id === nextToolboxId);
|
||||
const module = toolbox?.modules.find((item) => item.id === nextModuleId);
|
||||
const compact = compactModuleDataForStorage(module?.type, data);
|
||||
if (nextToolboxId && nextModuleId && compact) projected[moduleStorageKey(nextToolboxId, nextModuleId)] = JSON.stringify(compact);
|
||||
if (nextToolboxId && nextModuleId && compact) store.updateModuleData(nextToolboxId, nextModuleId, compact, module?.type);
|
||||
});
|
||||
const projectedUsage = getAppStorageUsage(projected);
|
||||
if (projectedUsage.used > APP_STORAGE_LIMIT_BYTES) throw new Error(`Quota local atteint (${formatBytes(projectedUsage.used)} / ${formatBytes(projectedUsage.limit)}).`);
|
||||
Object.entries(projected).forEach(([key, value]) => writeStorageValue(key, value));
|
||||
store.setToolboxes(loadToolboxes());
|
||||
store.setLinks(loadLinks());
|
||||
store.setToolboxes([...importedToolboxes, ...store.toolboxes]);
|
||||
store.setLinks(nextLinks);
|
||||
store.refreshQuota();
|
||||
}
|
||||
|
||||
|
|
@ -946,6 +962,7 @@ function App() {
|
|||
setDrawerGameId,
|
||||
setScreenshot,
|
||||
addScreenshotFiles,
|
||||
removeModuleData: store.removeModuleData,
|
||||
updateModuleData: store.updateModuleData,
|
||||
updateToolboxOrder: (orderedIds) => {
|
||||
const order = new Map(orderedIds.map((id, index) => [id, index]));
|
||||
|
|
@ -983,9 +1000,9 @@ function App() {
|
|||
exportToolbox: (id) => {
|
||||
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id));
|
||||
if (!toolbox) return;
|
||||
downloadJson(createToolboxExportPayload(toolbox), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
|
||||
},
|
||||
exportAllToolboxes: () => downloadJson(createGlobalExportPayload(store.toolboxes, store.links), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
exportAllToolboxes: () => downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`)
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -1005,6 +1022,8 @@ function App() {
|
|||
getGame={getGame}
|
||||
getToolboxGame={getToolboxGame}
|
||||
actions={actions}
|
||||
storageUsage={store.storageUsage}
|
||||
getModuleData={store.getModuleData}
|
||||
updateToolbox={updateToolbox}
|
||||
updateModuleData={store.updateModuleData}
|
||||
addScreenshotFiles={addScreenshotFiles}
|
||||
|
|
@ -1017,6 +1036,8 @@ function App() {
|
|||
toolboxes={store.toolboxes}
|
||||
links={store.links}
|
||||
actions={actions}
|
||||
storageUsage={store.storageUsage}
|
||||
getModuleData={store.getModuleData}
|
||||
updateToolbox={updateToolbox}
|
||||
updateModuleData={store.updateModuleData}
|
||||
addScreenshotFiles={addScreenshotFiles}
|
||||
|
|
@ -1027,7 +1048,7 @@ function App() {
|
|||
setConfirmModal(null);
|
||||
onResolve?.(value);
|
||||
}} />}
|
||||
{createModal && <CreateToolboxModal gameId={createModal.gameId || ""} onClose={(name) => {
|
||||
{createModal && <CreateToolboxModal gameId={createModal.gameId || ""} initialName={getGame(createModal.gameId)?.title || ""} onClose={(name) => {
|
||||
const gameId = createModal.gameId || "";
|
||||
setCreateModal(null);
|
||||
if (!name) return;
|
||||
|
|
@ -1067,6 +1088,7 @@ function Shell({ route, content, games, toolboxes, links, actions, children }) {
|
|||
<a className={`nav-item ${route.startsWith("/games") ? "active" : ""}`} href="#/games"><span className="nav-icon nav-icon-controller" aria-hidden="true" /><strong>{content.navigation.games}</strong></a>
|
||||
<a className={`nav-item ${route.startsWith("/toolboxes") || route.startsWith("/toolbox") ? "active" : ""}`} href="#/toolboxes"><span className="nav-icon nav-icon-toolbox" aria-hidden="true" /><strong>{content.navigation.toolboxes}</strong></a>
|
||||
</nav>
|
||||
<a className={`sidebar-about-link ${route === "/about" ? "active" : ""}`} href="#/about"><span className="sidebar-about-icon" aria-hidden="true">?</span><strong>{content.navigation.about}</strong></a>
|
||||
<div className="sidebar-note">
|
||||
<span className="badge">{content.sidebar.badge}</span>
|
||||
<p>{content.sidebar.note}</p>
|
||||
|
|
@ -1102,6 +1124,7 @@ function Shell({ route, content, games, toolboxes, links, actions, children }) {
|
|||
function RouteContent(props) {
|
||||
const { route } = props;
|
||||
if (route === "/") return <HomePage {...props} />;
|
||||
if (route === "/about") return <AboutPage siteContent={props.siteContent} />;
|
||||
if (route === "/toolboxes") return <ToolboxesPage {...props} />;
|
||||
if (route.startsWith("/toolbox/")) return <ToolboxPage {...props} toolboxId={route.split("/")[2]} />;
|
||||
if (route === "/games") return <GamesPage {...props} />;
|
||||
|
|
@ -1130,7 +1153,7 @@ function HomePage({ siteContent, toolboxes }) {
|
|||
<article><strong>{toolCount}</strong><span>{toolCount > 1 ? content.stats.toolPlural : content.stats.toolSingular}</span></article>
|
||||
</div>
|
||||
</section>
|
||||
<section className="origin-section" aria-labelledby="origin-title">
|
||||
<section className="origin-section nebula-panel" aria-labelledby="origin-title">
|
||||
<div className="origin-copy">
|
||||
<p className="eyebrow">{content.origin.eyebrow}</p>
|
||||
<h2 id="origin-title">{content.origin.title}</h2>
|
||||
|
|
@ -1151,6 +1174,83 @@ function HomePage({ siteContent, toolboxes }) {
|
|||
);
|
||||
}
|
||||
|
||||
function AboutPage({ siteContent }) {
|
||||
const content = siteContent.about || DEFAULT_SITE_CONTENT.about;
|
||||
const limits = Array.isArray(content.limits) ? content.limits : [];
|
||||
const storageSectionIndex = content.sections.findIndex((section) => section.title.toLowerCase().includes("stockage"));
|
||||
const normalizeReminder = (item, index) => typeof item === "string" ? { title: `Point ${index + 1}`, text: item, icon: "toolbox" } : item;
|
||||
return (
|
||||
<div className="about-page">
|
||||
<section className="page-hero">
|
||||
<div>
|
||||
<p className="eyebrow">{content.eyebrow}</p>
|
||||
<h1>{content.title}</h1>
|
||||
<p>{content.description}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section className="about-story-section nebula-panel" aria-labelledby="about-story-title">
|
||||
<div className="about-section-heading">
|
||||
<p className="eyebrow">Principes</p>
|
||||
<h2 id="about-story-title">Comment ça fonctionne</h2>
|
||||
</div>
|
||||
<div className="about-story-list">
|
||||
{content.sections.map((section, index) => (
|
||||
<article className="about-story-item" key={section.title}>
|
||||
<span>{String(index + 1).padStart(2, "0")}</span>
|
||||
<div>
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.text}</p>
|
||||
{index === storageSectionIndex && limits.length > 0 && (
|
||||
<div className="about-storage-reminders">
|
||||
<p className="eyebrow">{content.limitsTitle}</p>
|
||||
<ul className="about-reminders-list">
|
||||
{limits.map((item, itemIndex) => {
|
||||
const reminder = normalizeReminder(item, itemIndex);
|
||||
return (
|
||||
<li className="about-reminder-item" key={reminder.title || reminder.text}>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${reminder.icon || "toolbox"}`} />
|
||||
</span>
|
||||
<div>
|
||||
<h4>{reminder.title}</h4>
|
||||
<p>{reminder.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="about-tools-section nebula-panel">
|
||||
<div className="page-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Toolbox</p>
|
||||
<h2>{content.toolsTitle}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="about-tools-list">
|
||||
{content.tools.map((tool) => (
|
||||
<article className="about-tool-item" key={tool.name}>
|
||||
<span className="module-icon" aria-hidden="true">
|
||||
<span className={`module-icon-svg module-icon-${tool.icon || "notepad"}`} />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{tool.name}</h3>
|
||||
<p>{tool.description}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogueLine({ line }) {
|
||||
return <p className={`dialogue-line dialogue-line-${line.speaker === "app" ? "app" : "user"}`}><RichText text={line.text || ""} /></p>;
|
||||
}
|
||||
|
|
@ -1169,7 +1269,7 @@ function RichText({ text }) {
|
|||
});
|
||||
}
|
||||
|
||||
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
||||
function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) {
|
||||
const content = siteContent.toolboxes;
|
||||
const updateToolboxOrder = actions.updateToolboxOrder;
|
||||
const {
|
||||
|
|
@ -1226,7 +1326,7 @@ function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
|
|||
<div className="empty"><h2>{content.emptyTitle}</h2><p>{content.emptyText}</p></div>
|
||||
)}
|
||||
</section>
|
||||
<StorageQuota />
|
||||
<StorageQuota usage={storageUsage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1237,7 +1337,7 @@ function StorageHelpCard({ help }) {
|
|||
<div className="card-body">
|
||||
<div className="storage-help-heading">
|
||||
<span className="storage-help-mark" aria-hidden="true" />
|
||||
<div><p className="eyebrow">LocalStorage</p><h2 id="storage-help-title">{help.title}</h2></div>
|
||||
<div><p className="eyebrow">IndexedDB</p><h2 id="storage-help-title">{help.title}</h2></div>
|
||||
</div>
|
||||
<p className="storage-help-intro">{help.text}</p>
|
||||
<ul className="storage-help-list">
|
||||
|
|
@ -1379,7 +1479,7 @@ function ToolboxGameIcon({ game }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
||||
function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
||||
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
|
||||
const toolboxGameCover = getGameCardCover(toolboxGame);
|
||||
const moduleText = siteContent?.toolboxes?.modules || DEFAULT_SITE_CONTENT.toolboxes.modules;
|
||||
|
|
@ -1475,27 +1575,35 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, upd
|
|||
danger: true,
|
||||
onResolve: (confirmed) => {
|
||||
if (!confirmed) return;
|
||||
localStorage.removeItem(moduleStorageKey(toolbox.id, moduleId));
|
||||
actions.removeModuleData(toolbox.id, moduleId);
|
||||
updateToolbox({ ...toolbox, modules: toolbox.modules.filter((module) => module.id !== moduleId) });
|
||||
}
|
||||
})}
|
||||
onMove={moveModule}
|
||||
/>
|
||||
{!embedded && <StorageQuota />}
|
||||
{!embedded && <StorageQuota usage={storageUsage} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
||||
function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, storageUsage, getModuleData, updateToolbox, updateModuleData, addScreenshotFiles }) {
|
||||
const selectedId = links[gameId] || "";
|
||||
const toolbox = toolboxes.find((item) => item.id === selectedId);
|
||||
const [width, setWidth] = useState(() => {
|
||||
const value = Number(localStorage.getItem(STORAGE_KEYS.drawerWidth));
|
||||
if (!Number.isFinite(value) || value <= 0) return "";
|
||||
return Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94));
|
||||
});
|
||||
const [width, setWidth] = useState("");
|
||||
const panelRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
dbGetSetting(DRAWER_WIDTH_SETTING, "")
|
||||
.then((storedWidth) => {
|
||||
if (cancelled) return;
|
||||
const value = Number(storedWidth);
|
||||
if (Number.isFinite(value) && value > 0) setWidth(Math.min(Math.max(value, 360), Math.floor(window.innerWidth * 0.94)));
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
function startResize(event) {
|
||||
const panel = panelRef.current;
|
||||
if (!panel) return;
|
||||
|
|
@ -1508,7 +1616,7 @@ function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, u
|
|||
const onMove = (moveEvent) => {
|
||||
const nextWidth = Math.min(Math.max(startWidth + startX - moveEvent.clientX, minWidth), maxWidth);
|
||||
setWidth(nextWidth);
|
||||
writeStorageValue(STORAGE_KEYS.drawerWidth, String(Math.round(nextWidth)));
|
||||
dbSetSetting(DRAWER_WIDTH_SETTING, Math.round(nextWidth)).catch(() => {});
|
||||
};
|
||||
const stop = () => {
|
||||
document.body.classList.remove("is-resizing-drawer");
|
||||
|
|
@ -1538,10 +1646,10 @@ function ToolboxDrawer({ gameId, game, siteContent, toolboxes, links, actions, u
|
|||
</div>
|
||||
<button className="drawer-close-button" onClick={() => actions.setDrawerGameId("")} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
||||
</header>
|
||||
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addScreenshotFiles={addScreenshotFiles} /> : (
|
||||
{toolbox ? <ToolboxView siteContent={siteContent} toolbox={toolbox} toolboxGame={game} embedded actions={actions} storageUsage={storageUsage} getModuleData={getModuleData} updateToolbox={updateToolbox} updateModuleData={updateModuleData} addScreenshotFiles={addScreenshotFiles} /> : (
|
||||
<div className="empty"><p>Aucune toolbox associée à cette page jeu.</p><button className="primary" onClick={() => actions.setCreateModal({ gameId })}>Créer et associer</button></div>
|
||||
)}
|
||||
<StorageQuota />
|
||||
<StorageQuota usage={storageUsage} />
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
|
@ -1595,9 +1703,9 @@ function ConfirmModal({ title, message, confirmLabel = "Confirmer", cancelLabel
|
|||
);
|
||||
}
|
||||
|
||||
function CreateToolboxModal({ gameId, onClose }) {
|
||||
function CreateToolboxModal({ gameId, initialName = "", onClose }) {
|
||||
useModalScrollLock();
|
||||
const [name, setName] = useState("");
|
||||
const [name, setName] = useState(initialName);
|
||||
return (
|
||||
<div className="confirm-modal-root">
|
||||
<div className="confirm-backdrop" onClick={() => onClose("")} />
|
||||
|
|
@ -1770,14 +1878,16 @@ function ScreenshotViewer({ shot, onClose }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StorageQuota() {
|
||||
const usage = getAppStorageUsage();
|
||||
const percent = Math.round(usage.ratio * 100);
|
||||
const state = usage.ratio >= 1 ? "danger" : usage.ratio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok";
|
||||
function StorageQuota({ usage }) {
|
||||
const safeUsage = usage || { used: 0, limit: 0, ratio: 0 };
|
||||
const softRatio = Math.min(1, safeUsage.used / APP_STORAGE_SOFT_LIMIT_BYTES);
|
||||
const percent = Math.round(softRatio * 100);
|
||||
const state = safeUsage.used >= APP_STORAGE_SOFT_LIMIT_BYTES ? "danger" : softRatio >= APP_STORAGE_WARNING_RATIO ? "warning" : "ok";
|
||||
const browserQuota = safeUsage.limit ? `Quota navigateur estimé : ${formatBytes(safeUsage.limit)}` : "Quota navigateur estimé indisponible";
|
||||
return (
|
||||
<section className={`storage-quota storage-quota-${state}`} aria-label="Quota de stockage local">
|
||||
<div><span>Stockage local</span><strong>{formatBytes(usage.used)} / {formatBytes(usage.limit)}</strong></div>
|
||||
<div className="storage-quota-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={percent} aria-label="Stockage local utilisé"><span style={{ width: `${percent}%` }} /></div>
|
||||
<section className={`storage-quota storage-quota-${state}`} aria-label="Budget recommandé de stockage local" title={`${browserQuota}. Le budget affiché est une limite de confort pour préserver les performances.`}>
|
||||
<div><span>Stockage local recommandé</span><strong>{formatBytes(safeUsage.used)} / {formatBytes(APP_STORAGE_SOFT_LIMIT_BYTES)}</strong></div>
|
||||
<div className="storage-quota-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={percent} aria-label="Budget recommandé utilisé"><span style={{ width: `${percent}%` }} /></div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,47 +186,28 @@ span {
|
|||
filter: drop-shadow(0 22px 36px rgba(0, 0, 0, 0.34));
|
||||
}
|
||||
|
||||
.origin-section {
|
||||
.nebula-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(16, 20, 38, 0.42);
|
||||
box-shadow: var(--shadow-sm);
|
||||
|
||||
@include mixins.gold-panel-frame;
|
||||
}
|
||||
|
||||
.nebula-panel > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.origin-section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.65fr);
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-6);
|
||||
padding: var(--space-6);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-lg);
|
||||
background: rgba(16, 20, 38, 0.42);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.origin-section::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: rgba(165, 180, 252, 0.14);
|
||||
@include mixins.border-mask;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.origin-section::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(circle at calc(100% - var(--radius-lg)) calc(100% - var(--radius-lg)), rgba(246, 196, 83, 0.9) 0 18px, transparent 27px),
|
||||
linear-gradient(90deg, transparent 0 30%, rgba(246, 196, 83, 0.12) 42%, rgba(246, 196, 83, 0.72) 72%, rgba(246, 196, 83, 0.9) 100%),
|
||||
linear-gradient(180deg, transparent 0 30%, rgba(246, 196, 83, 0.12) 42%, rgba(246, 196, 83, 0.72) 72%, rgba(246, 196, 83, 0.9) 100%);
|
||||
@include mixins.border-mask;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.origin-section > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.origin-copy {
|
||||
|
|
@ -280,6 +261,181 @@ span {
|
|||
text-align: right;
|
||||
}
|
||||
|
||||
.about-page {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.about-story-section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr);
|
||||
gap: var(--space-6);
|
||||
align-items: start;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.about-section-heading {
|
||||
position: sticky;
|
||||
top: var(--space-5);
|
||||
}
|
||||
|
||||
.about-section-heading h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.about-story-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.about-story-item {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(0, 1fr);
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) 0;
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||
}
|
||||
|
||||
.about-story-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.about-story-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.about-story-item > span {
|
||||
color: var(--color-accent-gold);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.about-story-item h3 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.about-story-item > div > p {
|
||||
max-width: 78ch;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.62;
|
||||
}
|
||||
|
||||
.about-tools-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.about-tools-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 var(--space-6);
|
||||
}
|
||||
|
||||
.about-tool-item {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||
}
|
||||
|
||||
.about-tool-item:nth-last-child(-n + 2) {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.about-tool-item .module-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.about-tool-item .module-icon-svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.about-tool-item h3 {
|
||||
margin: 0 0 4px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.about-tool-item p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.about-storage-reminders {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: var(--space-4);
|
||||
padding-left: 44px;
|
||||
}
|
||||
|
||||
.about-storage-reminders .eyebrow {
|
||||
margin: 0;
|
||||
color: var(--color-accent-gold);
|
||||
}
|
||||
|
||||
.about-reminders-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 2px 0 2px var(--space-5);
|
||||
list-style: none;
|
||||
border-left: 1px solid rgba(246, 196, 83, 0.22);
|
||||
}
|
||||
|
||||
.about-reminder-item {
|
||||
display: grid;
|
||||
grid-template-columns: 38px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||
}
|
||||
|
||||
.about-reminder-item:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.about-reminder-item:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.about-reminder-item .module-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
border: 1px solid rgba(246, 196, 83, 0.26);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(246, 196, 83, 0.1);
|
||||
}
|
||||
|
||||
.about-reminder-item h4 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.about-reminder-item p {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.48;
|
||||
}
|
||||
|
||||
.home-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
|
|
|||
|
|
@ -20,3 +20,32 @@
|
|||
border-box;
|
||||
-webkit-mask-composite: xor;
|
||||
}
|
||||
|
||||
@mixin gold-panel-frame($transition: false) {
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
|
||||
@include border-mask;
|
||||
|
||||
@if $transition {
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: rgba(165, 180, 252, 0.14);
|
||||
}
|
||||
|
||||
&::after {
|
||||
background:
|
||||
radial-gradient(circle at calc(100% - var(--radius-lg)) calc(100% - var(--radius-lg)), rgba(246, 196, 83, 0.52) 0 18px, transparent 27px),
|
||||
linear-gradient(90deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%),
|
||||
linear-gradient(180deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,14 +19,24 @@
|
|||
|
||||
.brand span:last-child,
|
||||
.nav-item strong,
|
||||
.sidebar-about-link strong,
|
||||
.sidebar-note {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.nav-item {
|
||||
.nav-item,
|
||||
.sidebar-about-link {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.about-story-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-section-heading {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
|
|
@ -63,11 +73,25 @@
|
|||
.game-hero,
|
||||
.page-hero,
|
||||
.origin-section,
|
||||
.about-story-section,
|
||||
.about-tools-list,
|
||||
.section-grid,
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-tool-item:nth-last-child(-n + 2) {
|
||||
border-bottom: 1px solid rgba(165, 180, 252, 0.1);
|
||||
}
|
||||
|
||||
.about-tool-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.about-storage-reminders {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.game-hero,
|
||||
.page-hero {
|
||||
|
|
|
|||
|
|
@ -143,10 +143,60 @@
|
|||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sidebar-about-link {
|
||||
display: flex;
|
||||
min-height: 34px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: auto;
|
||||
margin-bottom: calc(var(--space-3) * -1);
|
||||
padding: 7px 12px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
transition: var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sidebar-about-link strong {
|
||||
color: inherit;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sidebar-about-icon {
|
||||
display: inline-grid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--color-accent-gold);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
transition:
|
||||
color var(--duration-fast) var(--ease-standard),
|
||||
filter var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sidebar-about-link:hover {
|
||||
background: var(--color-bg-hover);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-about-link:hover .sidebar-about-icon {
|
||||
color: #ffd66b;
|
||||
filter: drop-shadow(0 0 6px rgba(246, 196, 83, 0.24));
|
||||
}
|
||||
|
||||
.sidebar-about-link.active {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.sidebar-note {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
margin-top: auto;
|
||||
padding: var(--space-4);
|
||||
overflow: hidden;
|
||||
border: 1px solid transparent;
|
||||
|
|
|
|||
|
|
@ -320,33 +320,8 @@
|
|||
border-color: transparent;
|
||||
background: rgba(16, 20, 38, 0.42);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.module::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background: rgba(165, 180, 252, 0.14);
|
||||
@include mixins.border-mask;
|
||||
pointer-events: none;
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.module::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 1px;
|
||||
border-radius: inherit;
|
||||
background:
|
||||
radial-gradient(circle at calc(100% - var(--radius-lg)) calc(100% - var(--radius-lg)), rgba(246, 196, 83, 0.52) 0 18px, transparent 27px),
|
||||
linear-gradient(90deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%),
|
||||
linear-gradient(180deg, transparent 0 30%, rgba(246, 196, 83, 0.08) 42%, rgba(246, 196, 83, 0.38) 72%, rgba(246, 196, 83, 0.52) 100%);
|
||||
@include mixins.border-mask;
|
||||
pointer-events: none;
|
||||
transition: background var(--duration-fast) var(--ease-standard);
|
||||
@include mixins.gold-panel-frame(true);
|
||||
}
|
||||
|
||||
.module > * {
|
||||
|
|
@ -554,6 +529,16 @@
|
|||
-webkit-mask-image: url("/static/icons/link.svg");
|
||||
}
|
||||
|
||||
.module-icon-toolbox {
|
||||
mask-image: url("/static/icons/toolbox.svg");
|
||||
-webkit-mask-image: url("/static/icons/toolbox.svg");
|
||||
}
|
||||
|
||||
.module-icon-export {
|
||||
mask-image: url("/static/icons/export.svg");
|
||||
-webkit-mask-image: url("/static/icons/export.svg");
|
||||
}
|
||||
|
||||
.module-icon-abacus {
|
||||
mask-image: url("/static/icons/abacus.svg");
|
||||
-webkit-mask-image: url("/static/icons/abacus.svg");
|
||||
|
|
|
|||
100
website/src/utils/indexedDbStorage.js
Normal file
100
website/src/utils/indexedDbStorage.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
const DB_NAME = "sokkog";
|
||||
const DB_VERSION = 1;
|
||||
const KV_STORE = "kv";
|
||||
const MODULE_STORE = "modules";
|
||||
|
||||
let dbPromise;
|
||||
|
||||
function openDatabase() {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(KV_STORE)) db.createObjectStore(KV_STORE);
|
||||
if (!db.objectStoreNames.contains(MODULE_STORE)) db.createObjectStore(MODULE_STORE, { keyPath: "key" });
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function requestToPromise(request) {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function storeRequest(storeName, mode, callback) {
|
||||
const db = await openDatabase();
|
||||
const transaction = db.transaction(storeName, mode);
|
||||
const store = transaction.objectStore(storeName);
|
||||
return requestToPromise(callback(store));
|
||||
}
|
||||
|
||||
export async function getValue(key, fallback) {
|
||||
const value = await storeRequest(KV_STORE, "readonly", (store) => store.get(key));
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
export async function setValue(key, value) {
|
||||
await storeRequest(KV_STORE, "readwrite", (store) => store.put(value, key));
|
||||
}
|
||||
|
||||
export async function getToolboxes() {
|
||||
return getValue("toolboxes", []);
|
||||
}
|
||||
|
||||
export async function setToolboxes(toolboxes) {
|
||||
await setValue("toolboxes", toolboxes);
|
||||
}
|
||||
|
||||
export async function getLinks() {
|
||||
return getValue("links", {});
|
||||
}
|
||||
|
||||
export async function setLinks(links) {
|
||||
await setValue("links", links);
|
||||
}
|
||||
|
||||
export async function getSetting(key, fallback) {
|
||||
return getValue(`setting:${key}`, fallback);
|
||||
}
|
||||
|
||||
export async function setSetting(key, value) {
|
||||
await setValue(`setting:${key}`, value);
|
||||
}
|
||||
|
||||
export async function getAllModuleData() {
|
||||
return storeRequest(MODULE_STORE, "readonly", (store) => store.getAll());
|
||||
}
|
||||
|
||||
export async function setModuleData(key, data) {
|
||||
await storeRequest(MODULE_STORE, "readwrite", (store) => store.put({ key, data }));
|
||||
}
|
||||
|
||||
export async function removeModuleData(key) {
|
||||
await storeRequest(MODULE_STORE, "readwrite", (store) => store.delete(key));
|
||||
}
|
||||
|
||||
export async function removeModuleDataKeys(keys) {
|
||||
if (!keys.length) return;
|
||||
const db = await openDatabase();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(MODULE_STORE, "readwrite");
|
||||
const store = transaction.objectStore(MODULE_STORE);
|
||||
keys.forEach((key) => store.delete(key));
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getStorageEstimate() {
|
||||
if (!navigator.storage?.estimate) return { usage: 0, quota: 0 };
|
||||
return navigator.storage.estimate();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue