improve style & add icon toolboxes
All checks were successful
Deploy Sokko G / deploy (push) Successful in 5s

This commit is contained in:
Shinuwa 2026-07-21 13:47:19 +02:00
parent 9e275e0167
commit 0493db233a
31 changed files with 317 additions and 57 deletions

View file

@ -1,11 +1,13 @@
export function GamesPage({ games, gamesError }) {
export function GamesPage({ siteContent, games, gamesError }) {
const content = siteContent.gamesPage;
return (
<>
<section className="page-heading">
<div>
<p className="eyebrow">Pages informatives</p>
<h1>Jeux disponibles</h1>
<p>Choisissez un jeu pour consulter ses données maintenues et associer une toolbox locale.</p>
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
<p>{content.description}</p>
</div>
</section>
<section className="game-list">
@ -25,8 +27,8 @@ export function GamesPage({ games, gamesError }) {
</article>
)) : (
<div className="empty">
<h2>Aucun jeu disponible</h2>
<p>{gamesError || "Ajoutez des entrées dans /data/games.json."}</p>
<h2>{content.emptyTitle}</h2>
<p>{gamesError || content.emptyText}</p>
</div>
)}
</section>

View file

@ -16,12 +16,43 @@ const APP_STORAGE_LIMIT_BYTES = 5 * 1024 * 1024;
const APP_STORAGE_WARNING_RATIO = 0.85;
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", shot: "s", link: "l", counter: "c" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [
"toolbox.png",
"ball.png",
"capture.png",
"card.png",
"city.png",
"compass.png",
"crash.png",
"detective.png",
"fire.png",
"horror.png",
"jump.png",
"mining.png",
"parachute.png",
"puzzle.png",
"shield.png",
"sword.png",
"target.png",
"tower.png",
"wheel.png"
];
const TOOLBOX_ICONS = TOOLBOX_ICON_FILES.map((file) => `${TOOLBOX_ICON_BASE}${file}`);
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" },
sidebar: { badge: "Local only", note: "Données stockées dans ce navigateur." },
topbar: { dashboard: "Dashboard", toolbox: "Toolbox active", games: "Informations jeu" },
gamesPage: {
eyebrow: "Pages informatives",
title: "Jeux disponibles",
description: "Choisissez un jeu pour consulter ses données maintenues et associer une toolbox locale.",
emptyTitle: "Aucun jeu disponible",
emptyText: "Ajoutez des entrées dans /data/games.json."
},
home: {
hero: {
eyebrow: "Session gaming efficace",
@ -52,6 +83,7 @@ const DEFAULT_SITE_CONTENT = {
importAll: "Importer tout",
exportAll: "Exporter tout",
importOne: "Importer une toolbox",
summary: "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs modules restent accessibles rapidement, en local, par jeu ou par usage.",
storageHelp: {
title: "Sauvegardes locales",
text: "Vos toolboxes restent sur cet appareil, dans ce navigateur. Rien nest envoyé sur un serveur.",
@ -128,6 +160,12 @@ function getDefaultModuleTitle(type) {
return TOOLBOX_MODULES[type]?.label || "Outil";
}
function normalizeToolboxIcon(icon) {
const value = String(icon || "").trim();
const file = value.replace(TOOLBOX_ICON_BASE, "").split("/").pop();
return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON;
}
function normalizeToolboxModule(module) {
if (!module || typeof module !== "object" || !module.type) return null;
const title = String(module.title || "").trim();
@ -141,6 +179,7 @@ function normalizeToolbox(toolbox) {
return {
id: toolbox.id || uid("tbx"),
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
icon: normalizeToolboxIcon(toolbox.icon),
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
modules: (Array.isArray(toolbox.modules) ? toolbox.modules : []).map(normalizeToolboxModule).filter(Boolean),
updatedAt: toolbox.updatedAt || new Date().toISOString()
@ -157,6 +196,7 @@ function compactToolboxForStorage(toolbox) {
updatedAt: normalized.updatedAt
};
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
return compact;
}
@ -935,9 +975,15 @@ function DialogueLine({ line }) {
function RichText({ text }) {
const parts = String(text).split(/(<i>.*?<\/i>)/g).filter(Boolean);
return parts.map((part, index) => {
return parts.flatMap((part, index) => {
const italic = part.match(/^<i>(.*?)<\/i>$/);
return italic ? <i key={index}>{italic[1]}</i> : <React.Fragment key={index}>{part}</React.Fragment>;
const value = italic ? italic[1] : part;
const lines = value.split("\n");
return lines.flatMap((line, lineIndex) => {
const key = `${index}-${lineIndex}`;
const node = italic ? <i key={key}>{line}</i> : <React.Fragment key={key}>{line}</React.Fragment>;
return lineIndex === lines.length - 1 ? [node] : [node, <br key={`${key}-br`} />];
});
});
}
@ -950,15 +996,18 @@ function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions }) {
<p className="eyebrow">{content.eyebrow}</p>
<h1>{content.title}</h1>
</div>
<div className="actions">
<button className="primary" onClick={() => actions.setCreateModal({})}>{content.newButton}</button>
<ImportButton className="import-button" label={content.importOne} onFile={actions.importToolbox} />
</div>
</section>
<section className="toolbar toolbar-stack">
<div>
<ImportButton className="import-button" label={content.importAll} onFile={actions.importAllToolboxes} />
<button onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
<p className="toolbox-summary">{content.summary}</p>
<div className="toolbox-actions-row">
<div>
<button className="primary" onClick={() => actions.setCreateModal({})}>{content.newButton}</button>
<ImportButton className="import-button" label={content.importOne} onFile={actions.importToolbox} />
</div>
<div>
<ImportButton className="import-button" label={content.importAll} onFile={actions.importAllToolboxes} />
<button onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
</div>
</div>
</section>
<section className="cards">
@ -990,16 +1039,17 @@ function StorageHelpCard({ help }) {
}
function ToolboxCard({ toolbox, game, actions }) {
const coverImage = game?.image || toolbox.icon || DEFAULT_TOOLBOX_ICON;
return (
<article className="card toolbox-card">
<a
className={`card-cover toolbox-card-cover-link ${game?.image ? "toolbox-card-cover" : ""}`}
className={`card-cover toolbox-card-cover-link ${game?.image ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
href={`#/toolbox/${toolbox.id}`}
aria-label={`Ouvrir ${toolbox.name}`}
title={`Ouvrir ${toolbox.name}`}
style={{ "--game-cover": game?.cover || "var(--gradient-nebula)", background: game?.image ? "" : "var(--gradient-nebula)" }}
>
{game?.image && <img src={game.image} alt={game.title} loading="lazy" />}
<img src={coverImage} alt={game ? game.title : `Icône de ${toolbox.name}`} loading="lazy" />
</a>
<div className="card-body">
<p className="eyebrow">{game ? game.title : "Toolbox libre"}</p>
@ -1022,13 +1072,83 @@ function ToolboxCard({ toolbox, game, actions }) {
}
function ToolboxPage(props) {
const { toolboxId, toolboxes } = props;
const { toolboxId, toolboxes, getToolboxGame } = props;
const toolbox = toolboxes.find((item) => item.id === toolboxId);
if (!toolbox) return <div className="empty"><h1>Toolbox introuvable</h1><a className="button" href="#/toolboxes">Retour</a></div>;
return <ToolboxView {...props} toolbox={toolbox} embedded={false} />;
return <ToolboxView {...props} toolbox={toolbox} toolboxGame={getToolboxGame(toolbox)} embedded={false} />;
}
function ToolboxView({ toolbox, embedded, actions, updateToolbox, updateModuleData, addScreenshotFiles }) {
function ToolboxIconPicker({ toolbox, onChange }) {
const [open, setOpen] = useState(false);
const pickerRef = useRef(null);
const icon = normalizeToolboxIcon(toolbox.icon);
useEffect(() => {
if (!open) return undefined;
function handlePointerDown(event) {
if (!pickerRef.current?.contains(event.target)) setOpen(false);
}
function handleKeyDown(event) {
if (event.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [open]);
function selectIcon(nextIcon) {
onChange(nextIcon);
setOpen(false);
}
return (
<div className="toolbox-icon-picker" ref={pickerRef}>
<button
className="toolbox-icon-picker-button"
type="button"
onClick={() => setOpen((value) => !value)}
aria-label="Changer l'icône de la toolbox"
aria-expanded={open}
title="Changer l'icône"
>
<img src={icon} alt="" aria-hidden="true" />
</button>
{open && (
<div className="toolbox-icon-picker-menu" aria-label="Icônes de toolbox">
{TOOLBOX_ICONS.map((path) => (
<button
key={path}
className={path === icon ? "active" : ""}
type="button"
onClick={() => selectIcon(path)}
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
aria-pressed={path === icon}
title={path.split("/").pop().replace(".png", "")}
>
<img src={path} alt="" aria-hidden="true" loading="lazy" />
</button>
))}
</div>
)}
</div>
);
}
function ToolboxGameIcon({ game }) {
return (
<div className="toolbox-game-icon" title={game.title} aria-label={`Jeu associé : ${game.title}`}>
<img src={game.image} alt="" aria-hidden="true" />
</div>
);
}
function ToolboxView({ toolbox, toolboxGame, embedded, actions, updateToolbox, updateModuleData, addScreenshotFiles }) {
const moduleColumns = Number(toolbox.moduleColumns) === 1 ? 1 : 2;
const moduleContext = {
getModuleData,
@ -1068,9 +1188,13 @@ function ToolboxView({ toolbox, embedded, actions, updateToolbox, updateModuleDa
<section className="toolbox-head">
<div>
<p className="eyebrow">Toolbox</p>
{embedded ? <h1>{toolbox.name}</h1> : (
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
)}
<div className="toolbox-title-row">
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} />}
{!embedded && toolboxGame?.image && <ToolboxGameIcon game={toolboxGame} />}
{embedded ? <h1>{toolbox.name}</h1> : (
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
)}
</div>
</div>
<div className="actions">
<AddToolControls onAdd={addModule} />

View file

@ -18,6 +18,14 @@
rgba(5, 7, 17, 0.72);
}
.toolbox-icon-cover {
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 38%, rgba(34, 211, 238, 0.16), transparent 42%),
var(--gradient-nebula);
}
.game-card-cover img,
.toolbox-card-cover img {
display: block;
@ -27,6 +35,14 @@
object-fit: cover;
}
.toolbox-icon-cover img {
display: block;
width: min(78px, 42%);
height: auto;
object-fit: contain;
filter: drop-shadow(0 16px 24px rgba(0, 0, 0, 0.34));
}
.badge {
display: inline-flex;
min-height: 24px;

View file

@ -182,8 +182,8 @@ span {
}
.dialogue-line i {
color: var(--color-text-muted);
font-style: normal;
color: var(--color-text-secondary);
font-style: italic;
}
.dialogue-caption {

View file

@ -142,6 +142,16 @@
grid-template-columns: 1fr;
}
.toolbox-actions-row {
align-items: stretch;
flex-direction: column;
}
.toolbox-actions-row > div,
.toolbox-actions-row > div:last-child {
justify-content: flex-start;
}
.tool-add-modal {
width: min(420px, calc(100vw - 32px));
}

View file

@ -17,6 +17,92 @@
cursor: text;
}
.toolbox-title-row {
display: flex;
align-items: center;
gap: var(--space-3);
}
.toolbox-icon-picker {
position: relative;
z-index: 70;
flex: 0 0 auto;
}
.toolbox-icon-picker-button,
.toolbox-game-icon {
width: 100px;
min-width: 100px;
min-height: 100px;
border-color: var(--color-primary-border);
background:
radial-gradient(circle at 50% 35%, rgba(34, 211, 238, 0.18), transparent 44%),
rgba(21, 26, 48, 0.86);
}
.toolbox-icon-picker-button {
padding: 0;
}
.toolbox-game-icon {
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--color-primary-border);
border-radius: var(--radius-md);
}
.toolbox-icon-picker-button img,
.toolbox-game-icon img {
width: 72px;
height: 72px;
object-fit: contain;
}
.toolbox-game-icon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.toolbox-icon-picker-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
z-index: 120;
display: grid;
grid-template-columns: repeat(5, 42px);
gap: 7px;
width: max-content;
max-width: min(260px, calc(100vw - 48px));
padding: 9px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(12, 15, 32, 0.96);
box-shadow: var(--shadow-lg);
}
.toolbox-icon-picker-menu button {
width: 42px;
min-width: 42px;
min-height: 42px;
padding: 0;
border-color: var(--color-border);
background: rgba(21, 26, 48, 0.72);
}
.toolbox-icon-picker-menu button.active {
border-color: var(--color-accent-cyan);
background: rgba(34, 211, 238, 0.12);
box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.12);
}
.toolbox-icon-picker-menu img {
width: 26px;
height: 26px;
object-fit: contain;
}
.toolbox-title:focus {
background: rgba(7, 10, 24, 0.72);
outline: 1px solid rgba(34, 211, 238, 0.72);
@ -121,6 +207,31 @@
margin-top: var(--space-4);
}
.toolbox-summary {
max-width: 760px;
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
.toolbox-actions-row {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.toolbox-actions-row > div {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.toolbox-actions-row > div:last-child {
justify-content: flex-end;
}
.modules {
align-items: start;
}