Add custom toolbox card covers
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
25889a23ba
commit
06de3a3a6e
13 changed files with 7480 additions and 38 deletions
|
|
@ -43,6 +43,7 @@ Les toolboxes sont stockées dans `kv/toolboxes`.
|
||||||
"name": "Monster Hunter Wilds",
|
"name": "Monster Hunter Wilds",
|
||||||
"gameId": "mhwilds",
|
"gameId": "mhwilds",
|
||||||
"icon": "/static/img/toolbox-icons/toolbox.png",
|
"icon": "/static/img/toolbox-icons/toolbox.png",
|
||||||
|
"customCover": "data:image/webp;base64,...",
|
||||||
"modules": [
|
"modules": [
|
||||||
{
|
{
|
||||||
"id": "mod1",
|
"id": "mod1",
|
||||||
|
|
@ -64,6 +65,7 @@ Notes :
|
||||||
|
|
||||||
- `gameId` est vide pour une toolbox libre.
|
- `gameId` est vide pour une toolbox libre.
|
||||||
- `icon` est utilisé uniquement pour les toolboxes libres.
|
- `icon` est utilisé uniquement pour les toolboxes libres.
|
||||||
|
- `customCover` est une image rectangulaire optionnelle utilisée comme cover de card pour les toolboxes libres. Elle est stockée en data URL image après recadrage, sans conserver le fichier original.
|
||||||
- `moduleColumns` vaut `1` ou `2` et contrôle l'affichage de la page toolbox complète ; le panneau latéral utilise toujours l'ordre une colonne.
|
- `moduleColumns` vaut `1` ou `2` et contrôle l'affichage de la page toolbox complète ; le panneau latéral utilise toujours l'ordre une colonne.
|
||||||
- `moduleOrder.one` stocke l'ordre du mode une colonne.
|
- `moduleOrder.one` stocke l'ordre du mode une colonne.
|
||||||
- `moduleOrder.two` stocke l'ordre explicite des colonnes gauche et droite en mode deux colonnes.
|
- `moduleOrder.two` stocke l'ordre explicite des colonnes gauche et droite en mode deux colonnes.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
validateSiteContent,
|
validateSiteContent,
|
||||||
validateTranslationKeys
|
validateTranslationKeys
|
||||||
} from "./helpers/data-validation.mjs";
|
} from "./helpers/data-validation.mjs";
|
||||||
|
import { compactModuleDataForStorage, normalizeToolbox } from "../website/src/features/toolboxes/storage/toolboxStorage.js";
|
||||||
|
|
||||||
test("site content json is complete", async () => {
|
test("site content json is complete", async () => {
|
||||||
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
|
|
@ -25,6 +26,27 @@ test("library examples are available", async () => {
|
||||||
assert.equal(notepadModule?.title, "Bloc Note");
|
assert.equal(notepadModule?.title, "Bloc Note");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("library toolbox export is import-compatible", async () => {
|
||||||
|
const library = JSON.parse(await readFile("website/public/data/library.json", "utf8"));
|
||||||
|
const payload = JSON.parse(await readFile("website/public/data/library-toolbox-export.json", "utf8"));
|
||||||
|
const toolbox = normalizeToolbox(payload.toolbox);
|
||||||
|
const moduleIds = new Set(toolbox.modules.map((module) => module.id));
|
||||||
|
|
||||||
|
assert.equal(toolbox.name, "Librairie");
|
||||||
|
assert.equal(toolbox.modules.length, library.toolbox.modules.length);
|
||||||
|
assert.deepEqual(toolbox.modules.map((module) => module.type), library.toolbox.modules.map((module) => module.type));
|
||||||
|
assert.deepEqual(new Set(Object.keys(payload.modules)), moduleIds);
|
||||||
|
|
||||||
|
toolbox.modules.forEach((module) => {
|
||||||
|
const exportedModule = payload.toolbox.modules.find((item) => item.id === module.id);
|
||||||
|
assert.ok(exportedModule, `module ${module.id} must be declared`);
|
||||||
|
assert.equal(exportedModule.controls, undefined, `${module.id} must not include library docs controls`);
|
||||||
|
assert.equal(exportedModule.features, undefined, `${module.id} must not include library docs features`);
|
||||||
|
assert.equal(exportedModule.importFormat, undefined, `${module.id} must not include library docs import format`);
|
||||||
|
assert.ok(compactModuleDataForStorage(module.type, payload.modules[module.id]), `${module.id} data must be compactable`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("mhwilds data and assets are available", async () => {
|
test("mhwilds data and assets are available", async () => {
|
||||||
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
const site = JSON.parse(await readFile("website/public/data/site.json", "utf8"));
|
||||||
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
const games = JSON.parse(await readFile("website/public/data/games.json", "utf8"));
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ test("style entrypoint and theme layers are wired", async () => {
|
||||||
"website/src/styles/toolboxes/_layout.scss",
|
"website/src/styles/toolboxes/_layout.scss",
|
||||||
"website/src/styles/toolboxes/layout/_header.scss",
|
"website/src/styles/toolboxes/layout/_header.scss",
|
||||||
"website/src/styles/toolboxes/layout/_icon-picker.scss",
|
"website/src/styles/toolboxes/layout/_icon-picker.scss",
|
||||||
|
"website/src/styles/toolboxes/layout/_cover-editor.scss",
|
||||||
"website/src/styles/toolboxes/layout/_tutorial.scss",
|
"website/src/styles/toolboxes/layout/_tutorial.scss",
|
||||||
"website/src/styles/toolboxes/layout/_add-tools.scss",
|
"website/src/styles/toolboxes/layout/_add-tools.scss",
|
||||||
"website/src/styles/toolboxes/layout/_preferences.scss",
|
"website/src/styles/toolboxes/layout/_preferences.scss",
|
||||||
|
|
@ -197,6 +198,13 @@ test("style entrypoint and theme layers are wired", async () => {
|
||||||
assert.match(styleToolboxCalendar, /\.calendar-alert-mode-switch/);
|
assert.match(styleToolboxCalendar, /\.calendar-alert-mode-switch/);
|
||||||
assert.match(styles, /@use "design-system\/attributes"/);
|
assert.match(styles, /@use "design-system\/attributes"/);
|
||||||
assert.match(styleToolboxLayout, /\.toolbox-icon-picker/);
|
assert.match(styleToolboxLayout, /\.toolbox-icon-picker/);
|
||||||
|
assert.match(styleToolboxLayout, /border-radius: inherit/);
|
||||||
|
assert.match(styleToolboxLayout, /\.toolbox-icon-picker-button\.has-custom-cover img/);
|
||||||
|
assert.match(styleToolboxLayout, /grid-column: 1 \/ -1/);
|
||||||
|
assert.match(styleToolboxLayout, /@use "layout\/cover-editor"/);
|
||||||
|
assert.match(styleToolboxLayout, /\.toolbox-cover-editor/);
|
||||||
|
assert.match(styleToolboxLayout, /\.toolbox-cover-crop-frame/);
|
||||||
|
assert.match(styleToolboxLayout, /aspect-ratio: 620 \/ 248/);
|
||||||
assert.match(styleToolboxLayout, /\.toolbox-game-icon/);
|
assert.match(styleToolboxLayout, /\.toolbox-game-icon/);
|
||||||
assert.match(styleToolboxLayout, /\.toolbox-hero-link/);
|
assert.match(styleToolboxLayout, /\.toolbox-hero-link/);
|
||||||
assert.match(styleToolboxLayout, /\.toolbox-actions-row/);
|
assert.match(styleToolboxLayout, /\.toolbox-actions-row/);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8");
|
const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8");
|
||||||
const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
|
const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
|
||||||
|
const toolboxCoverEditor = await readFile("website/src/features/toolboxes/ToolboxCoverEditor.jsx", "utf8");
|
||||||
const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8");
|
const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8");
|
||||||
const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8");
|
const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8");
|
||||||
const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8");
|
const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8");
|
||||||
|
|
@ -55,6 +56,9 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/);
|
assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/);
|
||||||
assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/);
|
assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/);
|
||||||
assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
|
assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
|
||||||
|
assert.match(toolboxStorage, /export function normalizeToolboxCustomCover/);
|
||||||
|
assert.match(toolboxStorage, /customCover: normalizeToolboxCustomCover/);
|
||||||
|
assert.match(toolboxStorage, /compact\.customCover = normalized\.customCover/);
|
||||||
assert.match(toolboxStorage, /export function createGlobalExportPayload/);
|
assert.match(toolboxStorage, /export function createGlobalExportPayload/);
|
||||||
assert.match(checklistStorage, /qtyTarget/);
|
assert.match(checklistStorage, /qtyTarget/);
|
||||||
assert.match(checklistStorage, /qtyCurrent/);
|
assert.match(checklistStorage, /qtyCurrent/);
|
||||||
|
|
@ -84,6 +88,19 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(calendarStorage, /normalizeCalendarData/);
|
assert.match(calendarStorage, /normalizeCalendarData/);
|
||||||
assert.match(toolboxStorage, /nextId\("calendar"\)/);
|
assert.match(toolboxStorage, /nextId\("calendar"\)/);
|
||||||
assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/);
|
assert.match(toolboxCard, /DEFAULT_TOOLBOX_ICON/);
|
||||||
|
assert.match(toolboxCard, /toolbox\.customCover/);
|
||||||
|
assert.match(toolboxCard, /customCoverImage = !gameCoverImage/);
|
||||||
|
assert.match(toolboxCard, /triggerImage = toolbox\.customCover \|\| icon/);
|
||||||
|
assert.match(toolboxCard, /has-custom-cover/);
|
||||||
|
assert.match(toolboxCard, /toolbox-cover-menu-action nds-button/);
|
||||||
|
assert.match(toolboxCard, /!toolbox\.customCover && path === icon/);
|
||||||
|
assert.match(toolboxCard, /aria-pressed=\{Boolean\(toolbox\.customCover\)\}/);
|
||||||
|
assert.match(toolboxCard, /<span>Image personnalisée<\/span>/);
|
||||||
|
assert.match(toolboxCard, /onChange\(path, ""\)/);
|
||||||
|
assert.match(toolboxCard, /ToolboxCoverEditor/);
|
||||||
|
assert.match(toolboxCard, /accept="image\/\*"/);
|
||||||
|
assert.doesNotMatch(toolboxCard, /Modifier l'image de card/);
|
||||||
|
assert.doesNotMatch(toolboxCoverEditor, /Supprimer l'image/);
|
||||||
assert.match(toolboxCard, /export function ToolboxCard/);
|
assert.match(toolboxCard, /export function ToolboxCard/);
|
||||||
assert.match(toolboxCard, /export function ToolboxIconPicker/);
|
assert.match(toolboxCard, /export function ToolboxIconPicker/);
|
||||||
assert.match(toolboxCard, /export function ToolboxGameIcon/);
|
assert.match(toolboxCard, /export function ToolboxGameIcon/);
|
||||||
|
|
@ -92,6 +109,7 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(toolboxCard, /toolbox-card-cover-link/);
|
assert.match(toolboxCard, /toolbox-card-cover-link/);
|
||||||
assert.match(toolboxCard, /toolbox-icon-cover/);
|
assert.match(toolboxCard, /toolbox-icon-cover/);
|
||||||
assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/);
|
assert.match(toolboxPages, /\.\/ToolboxCard\.jsx/);
|
||||||
|
assert.match(toolboxPages, /customCover = toolbox\.customCover/);
|
||||||
assert.match(toolboxPages, /updateToolboxOrder/);
|
assert.match(toolboxPages, /updateToolboxOrder/);
|
||||||
assert.match(toolboxPages, /useGroupedReorder/);
|
assert.match(toolboxPages, /useGroupedReorder/);
|
||||||
assert.match(toolboxPages, /useInlineEdit/);
|
assert.match(toolboxPages, /useInlineEdit/);
|
||||||
|
|
@ -141,6 +159,12 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(draftFormHook, /getFieldProps/);
|
assert.match(draftFormHook, /getFieldProps/);
|
||||||
assert.match(draftFormHook, /handleSubmit/);
|
assert.match(draftFormHook, /handleSubmit/);
|
||||||
assert.match(draftFormHook, /preventDefault/);
|
assert.match(draftFormHook, /preventDefault/);
|
||||||
|
assert.match(toolboxCoverEditor, /createImageBitmap/);
|
||||||
|
assert.match(toolboxCoverEditor, /image\/webp/);
|
||||||
|
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
|
||||||
|
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);
|
||||||
|
assert.match(toolboxCoverEditor, /getCoverCropSourceRect/);
|
||||||
|
assert.doesNotMatch(toolboxCoverEditor, /react-easy-crop|react-image-crop/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("toolbox module registry and modules expose expected behavior", async () => {
|
test("toolbox module registry and modules expose expected behavior", async () => {
|
||||||
|
|
|
||||||
7015
website/public/data/library-toolbox-export.json
Normal file
7015
website/public/data/library-toolbox-export.json
Normal file
File diff suppressed because one or more lines are too long
BIN
website/public/static/img/toolbox-icons/reflexion.png
Normal file
BIN
website/public/static/img/toolbox-icons/reflexion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -2,11 +2,13 @@
|
||||||
/* eslint-disable react-refresh/only-export-components -- expose aussi les helpers publics de rendu des cartes toolbox. */
|
/* eslint-disable react-refresh/only-export-components -- expose aussi les helpers publics de rendu des cartes toolbox. */
|
||||||
import { CompactDropdown } from "../../components/CompactDropdown.jsx";
|
import { CompactDropdown } from "../../components/CompactDropdown.jsx";
|
||||||
import { Icon } from "../../components/Icon.jsx";
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
import { ToolboxCoverEditor } from "./ToolboxCoverEditor.jsx";
|
||||||
import {
|
import {
|
||||||
DEFAULT_TOOLBOX_ICON,
|
DEFAULT_TOOLBOX_ICON,
|
||||||
normalizeToolboxIcon,
|
normalizeToolboxIcon,
|
||||||
TOOLBOX_ICONS
|
TOOLBOX_ICONS
|
||||||
} from "./storage/toolboxStorage.js";
|
} from "./storage/toolboxStorage.js";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
export function getGameCardCover(game) {
|
export function getGameCardCover(game) {
|
||||||
return game?.images?.cardCover || game?.image || "";
|
return game?.images?.cardCover || game?.image || "";
|
||||||
|
|
@ -22,7 +24,9 @@ export function formatDate(value) {
|
||||||
|
|
||||||
export function ToolboxCard({ toolbox, game, actions, reorder }) {
|
export function ToolboxCard({ toolbox, game, actions, reorder }) {
|
||||||
const gameCoverImage = getGameCardCover(game);
|
const gameCoverImage = getGameCardCover(game);
|
||||||
const coverImage = gameCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
|
const customCoverImage = !gameCoverImage && toolbox.customCover ? toolbox.customCover : "";
|
||||||
|
const coverImage = gameCoverImage || customCoverImage || toolbox.icon || DEFAULT_TOOLBOX_ICON;
|
||||||
|
const hasRectCover = Boolean(gameCoverImage || customCoverImage);
|
||||||
const className = [
|
const className = [
|
||||||
"card",
|
"card",
|
||||||
"nds-card",
|
"nds-card",
|
||||||
|
|
@ -46,13 +50,13 @@ export function ToolboxCard({ toolbox, game, actions, reorder }) {
|
||||||
<Icon name="drag" />
|
<Icon name="drag" />
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
className={`card-cover nds-card-cover toolbox-card-cover-link ${gameCoverImage ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
|
className={`card-cover nds-card-cover toolbox-card-cover-link ${hasRectCover ? "toolbox-card-cover" : "toolbox-icon-cover"}`}
|
||||||
href={`#/toolbox/${toolbox.id}`}
|
href={`#/toolbox/${toolbox.id}`}
|
||||||
aria-label={`Ouvrir ${toolbox.name}`}
|
aria-label={`Ouvrir ${toolbox.name}`}
|
||||||
title={`Ouvrir ${toolbox.name}`}
|
title={`Ouvrir ${toolbox.name}`}
|
||||||
style={{ "--game-cover": getGameCardBackground(game), background: gameCoverImage ? "" : "var(--gradient-nebula)" }}
|
style={{ "--game-cover": getGameCardBackground(game), background: hasRectCover ? "" : "var(--gradient-nebula)" }}
|
||||||
>
|
>
|
||||||
<img src={coverImage} alt={game ? game.title : `Icône de ${toolbox.name}`} loading="lazy" />
|
<img src={coverImage} alt={game ? game.title : customCoverImage ? `Image de ${toolbox.name}` : `Icône de ${toolbox.name}`} loading="lazy" />
|
||||||
</a>
|
</a>
|
||||||
<div className="card-body nds-card-body">
|
<div className="card-body nds-card-body">
|
||||||
<p className="eyebrow">{game ? game.title : "Toolbox libre"}</p>
|
<p className="eyebrow">{game ? game.title : "Toolbox libre"}</p>
|
||||||
|
|
@ -76,49 +80,97 @@ export function ToolboxCard({ toolbox, game, actions, reorder }) {
|
||||||
|
|
||||||
export function ToolboxIconPicker({ toolbox, onChange, tutorialTarget = "" }) {
|
export function ToolboxIconPicker({ toolbox, onChange, tutorialTarget = "" }) {
|
||||||
const icon = normalizeToolboxIcon(toolbox.icon);
|
const icon = normalizeToolboxIcon(toolbox.icon);
|
||||||
|
const triggerImage = toolbox.customCover || icon;
|
||||||
|
const fileInputRef = useRef(null);
|
||||||
|
const [coverEditor, setCoverEditor] = useState(null);
|
||||||
|
|
||||||
|
function closeCoverEditor() {
|
||||||
|
setCoverEditor(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CompactDropdown
|
<>
|
||||||
className="toolbox-icon-picker"
|
<input
|
||||||
menuClassName="toolbox-icon-picker-menu"
|
ref={fileInputRef}
|
||||||
label="Icônes de toolbox"
|
type="file"
|
||||||
renderTrigger={({ open, toggle }) => (
|
accept="image/*"
|
||||||
<button
|
hidden
|
||||||
className={`toolbox-icon-picker-button nds-large-icon-picker-trigger ${open ? "active" : ""}`}
|
onChange={(event) => {
|
||||||
type="button"
|
const file = event.target.files?.[0];
|
||||||
onClick={toggle}
|
if (!file?.type?.startsWith("image/")) return;
|
||||||
aria-label="Changer l'icône de la toolbox"
|
setCoverEditor({ file });
|
||||||
aria-expanded={open}
|
}}
|
||||||
aria-pressed={open}
|
/>
|
||||||
data-toolbox-tutorial-target={tutorialTarget || undefined}
|
<CompactDropdown
|
||||||
title="Changer l'icône"
|
className="toolbox-icon-picker"
|
||||||
>
|
menuClassName="toolbox-icon-picker-menu"
|
||||||
<img src={icon} alt="" aria-hidden="true" />
|
label="Icônes de toolbox"
|
||||||
</button>
|
renderTrigger={({ open, toggle }) => (
|
||||||
)}
|
<button
|
||||||
>
|
className={`toolbox-icon-picker-button nds-large-icon-picker-trigger ${toolbox.customCover ? "has-custom-cover" : ""} ${open ? "active" : ""}`}
|
||||||
{({ close }) => (
|
type="button"
|
||||||
<>
|
onClick={toggle}
|
||||||
{TOOLBOX_ICONS.map((path) => (
|
aria-label="Changer l'image de la toolbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-pressed={open}
|
||||||
|
data-toolbox-tutorial-target={tutorialTarget || undefined}
|
||||||
|
title="Changer l'image"
|
||||||
|
>
|
||||||
|
<img src={triggerImage} alt="" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{({ close }) => (
|
||||||
|
<>
|
||||||
|
{TOOLBOX_ICONS.map((path) => (
|
||||||
|
<button
|
||||||
|
key={path}
|
||||||
|
className={`nds-icon-button ${!toolbox.customCover && path === icon ? "active" : ""}`}
|
||||||
|
data-size="md"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(path, "");
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
|
||||||
|
aria-pressed={!toolbox.customCover && path === icon}
|
||||||
|
title={path.split("/").pop().replace(".png", "")}
|
||||||
|
>
|
||||||
|
<img src={path} alt="" aria-hidden="true" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
<button
|
<button
|
||||||
key={path}
|
className={`toolbox-cover-menu-action nds-button ${toolbox.customCover ? "active" : ""}`}
|
||||||
className={`nds-icon-button ${path === icon ? "active" : ""}`}
|
data-size="sm"
|
||||||
data-size="md"
|
data-variant="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange(path);
|
fileInputRef.current?.click();
|
||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
aria-label={`Utiliser l'icône ${path.split("/").pop().replace(".png", "")}`}
|
aria-label={toolbox.customCover ? "Remplacer l'image de card" : "Ajouter une image de card"}
|
||||||
aria-pressed={path === icon}
|
aria-pressed={Boolean(toolbox.customCover)}
|
||||||
title={path.split("/").pop().replace(".png", "")}
|
title={toolbox.customCover ? "Remplacer l'image de card" : "Ajouter une image de card"}
|
||||||
>
|
>
|
||||||
<img src={path} alt="" aria-hidden="true" loading="lazy" />
|
<Icon name="picture" />
|
||||||
|
<span>Image personnalisée</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
</>
|
||||||
</>
|
)}
|
||||||
|
</CompactDropdown>
|
||||||
|
{coverEditor && (
|
||||||
|
<ToolboxCoverEditor
|
||||||
|
file={coverEditor.file}
|
||||||
|
existingCover={coverEditor.existingCover || toolbox.customCover || ""}
|
||||||
|
onClose={closeCoverEditor}
|
||||||
|
onSave={(customCover) => {
|
||||||
|
onChange(icon, customCover);
|
||||||
|
closeCoverEditor();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</CompactDropdown>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
213
website/src/features/toolboxes/ToolboxCoverEditor.jsx
Normal file
213
website/src/features/toolboxes/ToolboxCoverEditor.jsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
// Rôle : édite une image utilisateur en cover rectangulaire de card toolbox.
|
||||||
|
/* eslint-disable react-refresh/only-export-components -- expose aussi les helpers purs de calcul/export du crop. */
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { Icon } from "../../components/Icon.jsx";
|
||||||
|
import { lockBodyScroll } from "../../utils/bodyScrollLock.js";
|
||||||
|
|
||||||
|
const OUTPUT_WIDTH = 620;
|
||||||
|
const OUTPUT_HEIGHT = 248;
|
||||||
|
const WEBP_QUALITY = 0.82;
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function canvasToDataUrl(canvas) {
|
||||||
|
const webp = canvas.toDataURL("image/webp", WEBP_QUALITY);
|
||||||
|
return webp.startsWith("data:image/webp") ? webp : canvas.toDataURL("image/jpeg", WEBP_QUALITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoverCropSourceRect({ imageWidth, imageHeight, frameWidth, frameHeight, scale, offsetX, offsetY }) {
|
||||||
|
const displayWidth = imageWidth * scale;
|
||||||
|
const displayHeight = imageHeight * scale;
|
||||||
|
const sourceX = ((displayWidth - frameWidth) / 2 - offsetX) / scale;
|
||||||
|
const sourceY = ((displayHeight - frameHeight) / 2 - offsetY) / scale;
|
||||||
|
const x = clamp(sourceX, 0, imageWidth);
|
||||||
|
const y = clamp(sourceY, 0, imageHeight);
|
||||||
|
return {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: Math.min(imageWidth - x, frameWidth / scale),
|
||||||
|
height: Math.min(imageHeight - y, frameHeight / scale)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createToolboxCoverDataUrl(bitmap, frame, transform) {
|
||||||
|
const crop = getCoverCropSourceRect({
|
||||||
|
imageWidth: bitmap.width,
|
||||||
|
imageHeight: bitmap.height,
|
||||||
|
frameWidth: frame.width,
|
||||||
|
frameHeight: frame.height,
|
||||||
|
scale: transform.scale,
|
||||||
|
offsetX: transform.x,
|
||||||
|
offsetY: transform.y
|
||||||
|
});
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = OUTPUT_WIDTH;
|
||||||
|
canvas.height = OUTPUT_HEIGHT;
|
||||||
|
canvas.getContext("2d").drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT);
|
||||||
|
return canvasToDataUrl(canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPreviewSource(file, existingCover) {
|
||||||
|
if (file) return URL.createObjectURL(file);
|
||||||
|
return existingCover || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decodePreviewImage(source, file) {
|
||||||
|
if (file) return createImageBitmap(file);
|
||||||
|
const response = await fetch(source);
|
||||||
|
return createImageBitmap(await response.blob());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolboxCoverEditor({ file, existingCover = "", onClose, onSave }) {
|
||||||
|
const frameRef = useRef(null);
|
||||||
|
const imageBitmapRef = useRef(null);
|
||||||
|
const dragRef = useRef(null);
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
|
||||||
|
const [frame, setFrame] = useState({ width: 0, height: 0 });
|
||||||
|
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
|
||||||
|
const [baseScale, setBaseScale] = useState(1);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const maxScale = useMemo(() => baseScale * 4, [baseScale]);
|
||||||
|
|
||||||
|
useEffect(() => lockBodyScroll(), []);
|
||||||
|
|
||||||
|
useEffect(() => () => imageBitmapRef.current?.close?.(), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const nextSource = createPreviewSource(file, existingCover);
|
||||||
|
setSource(nextSource);
|
||||||
|
setError("");
|
||||||
|
return () => {
|
||||||
|
if (file && nextSource) URL.revokeObjectURL(nextSource);
|
||||||
|
};
|
||||||
|
}, [existingCover, file]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!source) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
decodePreviewImage(source, file)
|
||||||
|
.then((bitmap) => {
|
||||||
|
if (cancelled) {
|
||||||
|
bitmap.close?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imageBitmapRef.current?.close?.();
|
||||||
|
imageBitmapRef.current = bitmap;
|
||||||
|
setImageSize({ width: bitmap.width, height: bitmap.height });
|
||||||
|
})
|
||||||
|
.catch(() => setError("Image illisible. Choisis un autre fichier."));
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [file, source]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const frameElement = frameRef.current;
|
||||||
|
if (!frameElement) return undefined;
|
||||||
|
const updateFrame = () => {
|
||||||
|
const rect = frameElement.getBoundingClientRect();
|
||||||
|
setFrame({ width: rect.width, height: rect.height });
|
||||||
|
};
|
||||||
|
updateFrame();
|
||||||
|
const observer = new ResizeObserver(updateFrame);
|
||||||
|
observer.observe(frameElement);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!frame.width || !frame.height || !imageSize.width || !imageSize.height) return;
|
||||||
|
const nextBaseScale = Math.max(frame.width / imageSize.width, frame.height / imageSize.height);
|
||||||
|
setBaseScale(nextBaseScale);
|
||||||
|
setTransform({ x: 0, y: 0, scale: nextBaseScale });
|
||||||
|
}, [frame.height, frame.width, imageSize.height, imageSize.width]);
|
||||||
|
|
||||||
|
function clampTransform(nextTransform) {
|
||||||
|
const displayWidth = imageSize.width * nextTransform.scale;
|
||||||
|
const displayHeight = imageSize.height * nextTransform.scale;
|
||||||
|
const maxX = Math.max(0, (displayWidth - frame.width) / 2);
|
||||||
|
const maxY = Math.max(0, (displayHeight - frame.height) / 2);
|
||||||
|
return {
|
||||||
|
scale: nextTransform.scale,
|
||||||
|
x: clamp(nextTransform.x, -maxX, maxX),
|
||||||
|
y: clamp(nextTransform.y, -maxY, maxY)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScale(value) {
|
||||||
|
const scale = clamp(Number(value) || baseScale, baseScale, maxScale);
|
||||||
|
setTransform((current) => clampTransform({ ...current, scale }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(event) {
|
||||||
|
if (!imageSize.width) return;
|
||||||
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, start: transform };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(event) {
|
||||||
|
const drag = dragRef.current;
|
||||||
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
|
setTransform(clampTransform({
|
||||||
|
...drag.start,
|
||||||
|
x: drag.start.x + event.clientX - drag.x,
|
||||||
|
y: drag.start.y + event.clientY - drag.y
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(event) {
|
||||||
|
if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCover() {
|
||||||
|
const bitmap = imageBitmapRef.current;
|
||||||
|
if (!bitmap || !frame.width || !frame.height) return;
|
||||||
|
onSave(await createToolboxCoverDataUrl(bitmap, frame, transform));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-modal-root toolbox-cover-editor-root">
|
||||||
|
<div className="confirm-backdrop nds-modal-backdrop" onClick={onClose} />
|
||||||
|
<section className="confirm-modal nds-modal toolbox-cover-editor" role="dialog" aria-modal="true" aria-labelledby="toolbox-cover-editor-title">
|
||||||
|
<header>
|
||||||
|
<h2 id="toolbox-cover-editor-title">Image de card</h2>
|
||||||
|
<button className="nds-icon-button" data-size="xs" type="button" onClick={onClose} aria-label="Fermer" title="Fermer"><Icon name="close" /></button>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
className="toolbox-cover-crop-frame"
|
||||||
|
ref={frameRef}
|
||||||
|
onPointerDown={startDrag}
|
||||||
|
onPointerMove={moveDrag}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
>
|
||||||
|
{source && imageSize.width > 0 && (
|
||||||
|
<img
|
||||||
|
src={source}
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
style={{
|
||||||
|
width: `${imageSize.width * transform.scale}px`,
|
||||||
|
height: `${imageSize.height * transform.scale}px`,
|
||||||
|
transform: `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <p className="nds-inline-notice toolbox-cover-editor-error" data-variant="danger">{error}</p>}
|
||||||
|
<label className="toolbox-cover-zoom-field">
|
||||||
|
<span>Zoom</span>
|
||||||
|
<input className="nds-input" data-size="sm" type="range" min={baseScale} max={maxScale} step={Math.max(0.001, baseScale / 50)} value={transform.scale} onChange={(event) => updateScale(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<footer className="nds-modal-footer">
|
||||||
|
<button className="nds-button" data-size="sm" type="button" onClick={() => setTransform({ x: 0, y: 0, scale: baseScale })}>Réinitialiser</button>
|
||||||
|
<button className="nds-button" data-size="sm" type="button" onClick={onClose}>Annuler</button>
|
||||||
|
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={!imageSize.width || Boolean(error)} onClick={saveCover}>Valider</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -261,7 +261,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Toolbox</p>
|
<p className="eyebrow">Toolbox</p>
|
||||||
<div className="toolbox-title-row">
|
<div className="toolbox-title-row">
|
||||||
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon) => updateToolbox({ ...toolbox, icon })} tutorialTarget="icon" />}
|
{!embedded && !toolboxGame && <ToolboxIconPicker toolbox={toolbox} onChange={(icon, customCover = toolbox.customCover) => updateToolbox({ ...toolbox, icon, customCover })} tutorialTarget="icon" />}
|
||||||
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
|
{!embedded && toolboxGameCover && <ToolboxGameIcon game={toolboxGame} />}
|
||||||
{embedded ? <h1>{toolbox.name}</h1> : (
|
{embedded ? <h1>{toolbox.name}</h1> : (
|
||||||
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" tutorialTarget="title" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
|
<EditableTitle className="toolbox-title" value={toolbox.name} fallback="Nouvelle toolbox" tutorialTarget="title" onSave={(name) => name !== toolbox.name && updateToolbox({ ...toolbox, name })} />
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ const TOOLBOX_ICON_FILES = [
|
||||||
"mining.png",
|
"mining.png",
|
||||||
"parachute.png",
|
"parachute.png",
|
||||||
"puzzle.png",
|
"puzzle.png",
|
||||||
|
"reflexion.png",
|
||||||
"shield.png",
|
"shield.png",
|
||||||
"sword.png",
|
"sword.png",
|
||||||
"target.png",
|
"target.png",
|
||||||
|
|
@ -53,6 +54,11 @@ export function normalizeToolboxIcon(icon) {
|
||||||
return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON;
|
return TOOLBOX_ICON_FILES.includes(file) ? `${TOOLBOX_ICON_BASE}${file}` : DEFAULT_TOOLBOX_ICON;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeToolboxCustomCover(customCover) {
|
||||||
|
const value = String(customCover || "").trim();
|
||||||
|
return /^data:image\/(?:jpeg|jpg|png|webp);base64,[a-z0-9+/]+=*$/i.test(value) ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeToolboxModule(module) {
|
function normalizeToolboxModule(module) {
|
||||||
if (!module || typeof module !== "object" || !module.type) return null;
|
if (!module || typeof module !== "object" || !module.type) return null;
|
||||||
const title = String(module.title || "").trim();
|
const title = String(module.title || "").trim();
|
||||||
|
|
@ -123,6 +129,7 @@ export function normalizeToolbox(toolbox) {
|
||||||
id: toolbox.id || uid("tbx"),
|
id: toolbox.id || uid("tbx"),
|
||||||
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
|
name: String(toolbox.name || "Nouvelle toolbox").trim() || "Nouvelle toolbox",
|
||||||
icon: normalizeToolboxIcon(toolbox.icon),
|
icon: normalizeToolboxIcon(toolbox.icon),
|
||||||
|
customCover: normalizeToolboxCustomCover(toolbox.customCover),
|
||||||
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
|
moduleColumns: Number(toolbox.moduleColumns) === 1 ? 1 : 2,
|
||||||
modules,
|
modules,
|
||||||
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules),
|
moduleOrder: normalizeModuleOrder(toolbox.moduleOrder, modules),
|
||||||
|
|
@ -144,6 +151,7 @@ export function compactToolboxForStorage(toolbox) {
|
||||||
};
|
};
|
||||||
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
|
if (normalized.moduleColumns === 1) compact.moduleColumns = 1;
|
||||||
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
|
if (normalized.icon !== DEFAULT_TOOLBOX_ICON) compact.icon = normalized.icon;
|
||||||
|
if (normalized.customCover) compact.customCover = normalized.customCover;
|
||||||
if (normalized.tutorial === true) compact.tutorial = true;
|
if (normalized.tutorial === true) compact.tutorial = true;
|
||||||
return compact;
|
return compact;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
// Rôle : orchestre les styles de structure des pages toolbox depuis ses partials spécialisés.
|
// Rôle : orchestre les styles de structure des pages toolbox depuis ses partials spécialisés.
|
||||||
@use "layout/header";
|
@use "layout/header";
|
||||||
@use "layout/icon-picker";
|
@use "layout/icon-picker";
|
||||||
|
@use "layout/cover-editor";
|
||||||
@use "layout/tutorial";
|
@use "layout/tutorial";
|
||||||
@use "layout/add-tools";
|
@use "layout/add-tools";
|
||||||
@use "layout/preferences";
|
@use "layout/preferences";
|
||||||
|
|
|
||||||
74
website/src/styles/toolboxes/layout/_cover-editor.scss
Normal file
74
website/src/styles/toolboxes/layout/_cover-editor.scss
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// Rôle : stylise l'éditeur de cover personnalisée des cards toolbox.
|
||||||
|
.toolbox-cover-editor {
|
||||||
|
width: min(720px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-editor header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-crop-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 620 / 248;
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(246, 196, 83, 0.72);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, rgba(255, 255, 255, 0.05) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.05) 75%),
|
||||||
|
linear-gradient(45deg, rgba(255, 255, 255, 0.05) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.05) 75%),
|
||||||
|
rgba(5, 7, 17, 0.92);
|
||||||
|
background-position: 0 0, 10px 10px;
|
||||||
|
background-size: 20px 20px;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-crop-frame:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-crop-frame::after {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.24);
|
||||||
|
box-shadow: inset 0 0 0 999px rgba(5, 7, 17, 0.06);
|
||||||
|
content: "";
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-crop-frame img {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: fill;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-zoom-field {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-zoom-field span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-zoom-field input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
padding-inline: 0;
|
||||||
|
accent-color: var(--color-accent-gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-editor-error {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbox-icon-picker-button {
|
.toolbox-icon-picker-button {
|
||||||
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,9 +31,16 @@
|
||||||
.toolbox-game-icon img {
|
.toolbox-game-icon img {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
|
border-radius: inherit;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbox-icon-picker-button.has-custom-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbox-game-icon img {
|
.toolbox-game-icon img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -74,6 +82,21 @@
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbox-icon-picker-menu .toolbox-cover-menu-action {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: inline-flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbox-cover-menu-action .ui-icon {
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbox-title:focus {
|
.toolbox-title:focus {
|
||||||
background: rgba(7, 10, 24, 0.72);
|
background: rgba(7, 10, 24, 0.72);
|
||||||
outline: 1px solid rgba(246, 196, 83, 0.72);
|
outline: 1px solid rgba(246, 196, 83, 0.72);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue