new tool timer & alert revamp
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s

This commit is contained in:
Shinuwa 2026-07-26 11:41:42 +02:00
parent 0c7ae46b4d
commit 68005ef918
38 changed files with 2048 additions and 111 deletions

View file

@ -19,6 +19,8 @@ export function AppOverlays({
image,
setImage,
notification,
setNotification,
toastPosition,
getGame,
toolboxes,
links
@ -47,7 +49,7 @@ export function AppOverlays({
}} />}
{storageError && <ConfirmModal title="Quota local atteint" message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
{notification && <NotificationToast key={notification.id} message={notification.message} />}
{notification && <NotificationToast key={notification.id} message={notification.message} position={toastPosition} onClose={() => setNotification(null)} />}
</>
);
}

View file

@ -147,7 +147,7 @@ export function ImageViewer({ image, onClose, createMarkerId = () => globalThis.
onChange={(event) => updateViewerMarker(marker.id, (item) => ({ ...item, label: event.target.value }))}
aria-label={`Libellé du marqueur ${index + 1}`}
/>
<button className="calculator-action-button danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${image.deleteMarkerTitle || "Supprimer"} ${marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}`} title={image.deleteMarkerTitle || "Supprimer"}>
<button className="tool-split-action-button danger" type="button" onClick={() => removeViewerMarker(marker.id)} aria-label={`${image.deleteMarkerTitle || "Supprimer"} ${marker.label || `${image.markerPrefix || "Marqueur"} ${index + 1}`}`} title={image.deleteMarkerTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</li>

View file

@ -0,0 +1,25 @@
// Rôle : fournit un contrôle d'onglets réutilisable avec rôles ARIA.
export function Tabs({ tabs, activeTab, onChange, className = "", iconOnly = false }) {
return (
<div className={`tabs ${className}`.trim()} role="tablist">
{tabs.map((tab) => {
const active = tab.id === activeTab;
return (
<button
key={tab.id}
className={active ? "active" : ""}
type="button"
role="tab"
aria-selected={active}
aria-label={tab.label}
title={tab.label}
onClick={() => onChange(tab.id)}
>
{tab.icon && <span className={`module-icon-svg module-icon-${tab.icon}`} aria-hidden="true" />}
{!iconOnly && <span>{tab.label}</span>}
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,20 @@
// Rôle : affiche le switch global de position des notifications internes.
import React from "react";
import { Icon } from "./Icon.jsx";
export function ToastPositionSwitch({ position, labels, onChange }) {
const normalizedPosition = position === "left" ? "left" : "right";
return (
<button
className="toast-position-switch"
type="button"
data-position={normalizedPosition}
onClick={() => onChange(normalizedPosition === "left" ? "right" : "left")}
aria-label={normalizedPosition === "left" ? labels.left : labels.right}
title={labels.title}
>
<span><Icon name="toast-left" /></span>
<span><Icon name="toast-right" /></span>
</button>
);
}

View file

@ -1,5 +1,6 @@
// Rôle : fournit les modales globales liées aux toolboxes et confirmations.
import { useEffect, useState } from "react";
import { Icon } from "./Icon.jsx";
import { lockBodyScroll } from "../utils/bodyScrollLock.js";
function useModalScrollLock() {
@ -23,10 +24,13 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance
);
}
export function NotificationToast({ message }) {
export function NotificationToast({ message, position = "right", onClose }) {
return (
<div className="notification-toast" role="status" aria-live="polite">
{message}
<div className={`notification-toast is-${position === "left" ? "left" : "right"}`} role="status" aria-live="polite">
<span>{message}</span>
<button type="button" onClick={onClose} aria-label="Fermer l'alerte" title="Fermer">
<Icon name="close" />
</button>
</div>
);
}

View file

@ -3,6 +3,7 @@ import React, { useEffect, useRef, useState } from "react";
import { Icon } from "../../components/Icon.jsx";
import { ImportButton } from "../../components/ImportButton.jsx";
import { StorageQuota } from "../../components/StorageQuota.jsx";
import { ToastPositionSwitch } from "../../components/ToastPositionSwitch.jsx";
import { usePointerReorder } from "../../hooks/usePointerReorder.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getSetting as dbGetSetting, setSetting as dbSetSetting } from "../../utils/indexedDbStorage.js";
@ -16,6 +17,7 @@ import {
normalizeCountersData,
normalizeImageAnnotationData,
normalizeLinksData,
normalizeTimerData,
normalizeUrl,
uid
} from "./storage/toolboxStorage.js";
@ -31,7 +33,7 @@ async function copyText(value) {
}
}
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage }) {
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition }) {
const content = siteContent.toolboxes;
const {
draggingId: draggingToolboxId,
@ -71,6 +73,21 @@ export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions,
<ImportButton className="import-button" label={content.importAll} onFile={actions.importAllToolboxes} />
</div>
</section>
<section className="toolbox-preferences-row" aria-label={content.preferencesLabel}>
<div>
<p className="eyebrow">{content.preferencesLabel}</p>
<strong>{content.toastPositionLabel}</strong>
</div>
<ToastPositionSwitch
position={toastPosition}
onChange={setToastPosition}
labels={{
title: content.toastPositionLabel,
left: content.toastPositionLeft,
right: content.toastPositionRight
}}
/>
</section>
<section className="cards">
<StorageHelpCard help={content.storageHelp} />
{toolboxes.length ? toolboxes.map((toolbox) => (
@ -128,6 +145,7 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
normalizeCountersData,
normalizeCalculatorData,
normalizeImageAnnotationData,
normalizeTimerData,
normalizeUrl,
hostnameFromUrl,
copyText,

View file

@ -136,23 +136,23 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
</div>
</form>
<div
className={`calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
className={`tool-split-results calculator-saved ${data.scrollResults ? "is-scrollable" : ""}`}
style={data.scrollResults && calculatorHeight ? { "--calculator-scroll-height": `${calculatorHeight}px` } : undefined}
>
<div className="calculator-result-actions">
<div className="tool-split-actions">
{activeParent && (
<button className="calculator-root-button" type="button" onClick={() => setActiveParentId("")}>
<button className="tool-split-root-button" type="button" onClick={() => setActiveParentId("")}>
{textContent.rootButton || "Revenir à la racine"}
</button>
)}
<button className="calculator-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<button className="tool-split-action-button danger" type="button" onClick={resetCalculator} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" />
</button>
<button className="calculator-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<button className="tool-split-action-button" type="button" onClick={copyChecklistImport} disabled={!data.entries.length} aria-label={textContent.copyTitle || "Copier pour checklist"} title={copied ? textContent.copiedTitle || "Copié" : textContent.copyTitle || "Copier pour checklist"}>
<Icon name="copy" />
</button>
<button
className={`calculator-scroll-toggle ${data.scrollResults ? "active" : ""}`}
className={`tool-split-scroll-toggle ${data.scrollResults ? "active" : ""}`}
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
@ -163,7 +163,7 @@ export function CalculatorModule({ toolboxId, moduleId, context }) {
<i aria-hidden="true" />
</button>
</div>
<div className={`calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
<div className={`tool-split-tree calculator-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{data.entries.length ? (
<CalculatorEntries entries={data.entries} parentId="" activeParentId={activeParentId} textContent={textContent} onUse={useEntry} onRename={renameEntry} onDelete={deleteEntry} />
) : (
@ -181,19 +181,19 @@ function CalculatorEntries({ entries, parentId, activeParentId, textContent, onU
if (!children.length) return null;
return (
<ul className="calculator-entry-list">
<ul className="tool-split-entry-list calculator-entry-list">
{children.map((entry) => (
<li className={`${entry.id === activeParentId ? "active" : ""} ${entry.id === editingId ? "is-editing" : ""}`} key={entry.id}>
<div className="calculator-entry">
<div className="tool-split-entry calculator-entry">
{entry.id === editingId ? (
<>
<span className="calculator-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
<span className="tool-split-entry-value is-readonly" title={textContent.readonlyValueTitle || "Quantité non modifiable"}>
<strong>{formatResult(entry.value)}</strong>
</span>
<EditableCalculatorLabel entry={entry} textContent={textContent} onRename={onRename} onDone={() => setEditingId("")} />
</>
) : (
<button className="calculator-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<button className="tool-split-entry-summary" type="button" onClick={() => onUse(entry)} title={textContent.useEntryTitle || "Utiliser comme base"}>
<span><strong>{formatResult(entry.value)}</strong> {entry.label}</span>
</button>
)}
@ -228,7 +228,7 @@ function EditableCalculatorLabel({ entry, textContent, onRename, onDone }) {
return (
<input
ref={inputRef}
className="calculator-entry-label"
className="tool-split-entry-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
onBlur={saveLabel}

View file

@ -83,7 +83,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
<img src={data.image} alt={textContent.imageAlt || "Image annotée"} draggable="false" />
<div className="annotation-preview-actions">
<button
className="calculator-action-button"
className="tool-split-action-button"
type="button"
onClick={() => context.setImage({
dataUrl: data.image,
@ -103,7 +103,7 @@ export function ImageAnnotationModule({ toolboxId, moduleId, context, editing })
<Icon name="zoom" />
</button>
{editing && (
<button className="calculator-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
<button className="tool-split-action-button danger" type="button" onClick={removeImage} aria-label={textContent.deleteImageTitle || "Supprimer l'image"} title={textContent.deleteImageTitle || "Supprimer l'image"}>
<Icon name="trash" />
</button>
)}

View file

@ -0,0 +1,572 @@
// Rôle : fournit l'outil timer avec chronomètre à étapes et comptes à rebours multiples.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
import { Tabs } from "../../../components/Tabs.jsx";
import {
formatDuration,
formatDurationWithCentiseconds,
formatTargetTime,
getCentiseconds,
getCountdownTargetMs,
getDailyTargetMs,
getNowMs,
getTimePatternTargetMs,
timePartsToDurationMs,
timePartsToString
} from "./timerUtils.js";
const TIMER_TABS = [
{ id: "stopwatch", label: "Chronomètre", icon: "stopwatch" },
{ id: "countdown", label: "Compte à rebours", icon: "hourglass" }
];
const COUNTDOWN_TYPES = ["duration", "daily_time", "time_pattern", "interval"];
const ALERT_MODES = [
{ mode: "off", icon: "sound-mute" },
{ mode: "visible", icon: "sound-min" },
{ mode: "site", icon: "sound-max" }
];
const ALERT_MIN_DELAY_MS = 5 * 60 * 1000;
function TimerValue({ ms, compact = false, showCentiseconds = true }) {
if (!showCentiseconds) return <span className="timer-value">{formatDuration(ms)}</span>;
if (!compact) return <span className="timer-value">{formatDurationWithCentiseconds(ms)}</span>;
return (
<span className="timer-value is-compact">
<span>{formatDuration(ms)}</span>
<small>{getCentiseconds(ms)}</small>
</span>
);
}
function getCountdownMeta(countdown, textContent) {
if (countdown.type === "duration") return `${textContent.durationType || "Durée"} · ${formatDuration(countdown.durationMs || 0)}`;
if (countdown.type === "daily_time") return `${textContent.dailyTimeType || "Heure précise"} · ${countdown.time}`;
if (countdown.type === "time_pattern") return `${textContent.timePatternType || "Heure / minute / seconde"} · ${countdown.pattern}`;
if (countdown.type === "interval") return `${textContent.intervalType || "Intervalle"} · ${formatDuration(countdown.intervalMs || 0)}`;
return "";
}
function handleEditableKeyDown(event, callback) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
callback();
}
export function TimerModule({ toolboxId, moduleId, context }) {
const data = context.normalizeTimerData(context.getModuleData(toolboxId, moduleId, { activeTab: "stopwatch" }));
const textContent = context.moduleText?.timer || {};
const activeTab = data.activeTab;
const [nowMs, setNowMs] = useState(getNowMs);
const [countdownForm, setCountdownForm] = useState({
label: "",
type: "duration",
duration: { hours: "", minutes: "", seconds: "" },
time: { hours: "", minutes: "", seconds: "" },
pattern: { hours: "", minutes: "", seconds: "" },
interval: { hours: "", minutes: "", seconds: "" }
});
useEffect(() => {
const intervalId = window.setInterval(() => setNowMs(getNowMs()), 50);
return () => window.clearInterval(intervalId);
}, []);
const stopwatchElapsedMs = data.stopwatch.startedAt
? data.stopwatch.elapsedMs + Math.max(0, nowMs - data.stopwatch.startedAt)
: data.stopwatch.elapsedMs;
const visibleCountdowns = useMemo(() => {
const countdowns = data.countdowns.map((countdown) => ({ ...countdown, targetMs: getCountdownTargetMs(countdown, nowMs) }));
if (!data.sortResults) return countdowns;
return [...countdowns].sort((a, b) => (a.targetMs || Number.POSITIVE_INFINITY) - (b.targetMs || Number.POSITIVE_INFINITY));
}, [data.countdowns, data.sortResults, nowMs]);
function save(nextData) {
context.setModuleData(toolboxId, moduleId, nextData);
}
function setActiveTab(nextActiveTab) {
save({ ...data, activeTab: nextActiveTab });
}
function setScrollResults(scrollResults) {
save({ ...data, scrollResults });
}
function setSortResults(sortResults) {
save({ ...data, sortResults });
}
function updateStopwatch(stopwatch) {
save({ ...data, stopwatch });
}
function startStopwatch() {
if (data.stopwatch.startedAt) return;
updateStopwatch({ ...data.stopwatch, startedAt: getNowMs() });
}
function pauseStopwatch() {
if (!data.stopwatch.startedAt) return;
updateStopwatch({
...data.stopwatch,
startedAt: 0,
elapsedMs: stopwatchElapsedMs
});
}
function resetStopwatch() {
updateStopwatch({ ...data.stopwatch, elapsedMs: 0, startedAt: 0 });
}
function addLap() {
if (!stopwatchElapsedMs) return;
updateStopwatch({
...data.stopwatch,
laps: [
{
id: context.uid("timer"),
label: "",
elapsedMs: stopwatchElapsedMs
},
...data.stopwatch.laps
]
});
}
function deleteLap(lapId) {
updateStopwatch({ ...data.stopwatch, laps: data.stopwatch.laps.filter((lap) => lap.id !== lapId) });
}
function renameLap(lapId, label) {
updateStopwatch({
...data.stopwatch,
laps: data.stopwatch.laps.map((lap) => lap.id === lapId ? { ...lap, label: label.trim() } : lap)
});
}
function updateCountdownForm(patch) {
setCountdownForm((current) => ({ ...current, ...patch }));
}
function createCountdown(event) {
event.preventDefault();
const type = COUNTDOWN_TYPES.includes(countdownForm.type) ? countdownForm.type : "duration";
const label = countdownForm.label.trim() || textContent.defaultCountdownLabel || "Timer";
const now = getNowMs();
let countdown = null;
if (type === "duration") {
const durationMs = timePartsToDurationMs(countdownForm.duration);
if (durationMs > 0) countdown = { id: context.uid("timer"), label, type, durationMs, targetAt: now + durationMs };
}
if (type === "daily_time") {
const time = timePartsToString(countdownForm.time);
const targetAt = getDailyTargetMs(time, now);
if (time && targetAt) countdown = { id: context.uid("timer"), label, type, time, targetAt };
}
if (type === "time_pattern") {
const pattern = timePartsToString(countdownForm.pattern, { allowWildcard: true });
if (pattern && getTimePatternTargetMs(pattern, now)) countdown = { id: context.uid("timer"), label, type, pattern };
}
if (type === "interval") {
const intervalMs = timePartsToDurationMs(countdownForm.interval);
if (intervalMs > 0) countdown = { id: context.uid("timer"), label, type, intervalMs, anchorAt: now };
}
if (!countdown) return;
save({ ...data, countdowns: [...data.countdowns, countdown] });
setCountdownForm((current) => ({ ...current, label: "" }));
}
function deleteCountdown(countdownId) {
save({ ...data, countdowns: data.countdowns.filter((countdown) => countdown.id !== countdownId) });
}
function resetCountdown(countdown) {
if (countdown.type !== "duration" && countdown.type !== "daily_time" && countdown.type !== "interval") return;
const now = getNowMs();
let nextCountdown = null;
if (countdown.type === "duration") nextCountdown = { ...countdown, targetAt: now + countdown.durationMs };
if (countdown.type === "daily_time") nextCountdown = { ...countdown, targetAt: getDailyTargetMs(countdown.time, now) };
if (countdown.type === "interval") nextCountdown = { ...countdown, anchorAt: now };
if (!nextCountdown) return;
save({ ...data, countdowns: data.countdowns.map((item) => item.id === countdown.id ? nextCountdown : item) });
}
function renameCountdown(countdownId, label) {
save({
...data,
countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, label: label.trim() } : countdown)
});
}
function setCountdownAlertMode(countdownId, alertMode) {
const countdown = data.countdowns.find((item) => item.id === countdownId);
const targetMs = countdown ? getCountdownTargetMs(countdown, nowMs) : 0;
if (alertMode !== "off" && targetMs && targetMs - nowMs < ALERT_MIN_DELAY_MS) {
context.notify?.(textContent.alertTooSoonTitle || "Alerte indisponible à moins de 5 minutes");
return;
}
save({
...data,
countdowns: data.countdowns.map((countdown) => countdown.id === countdownId ? { ...countdown, alertMode } : countdown)
});
}
function resetActiveResults() {
if (activeTab === "stopwatch") {
resetStopwatch();
return;
}
save({ ...data, countdowns: [] });
}
return (
<div className="timer-module">
<section className="timer-control-card">
<Tabs
className="timer-tabs"
tabs={[
{ ...TIMER_TABS[0], label: textContent.stopwatchTab || TIMER_TABS[0].label },
{ ...TIMER_TABS[1], label: textContent.countdownTab || TIMER_TABS[1].label }
]}
activeTab={activeTab}
onChange={setActiveTab}
iconOnly
/>
{activeTab === "stopwatch" ? (
<StopwatchControls
textContent={textContent}
elapsedMs={stopwatchElapsedMs}
running={Boolean(data.stopwatch.startedAt)}
onStart={startStopwatch}
onPause={pauseStopwatch}
onReset={resetStopwatch}
onLap={addLap}
/>
) : (
<CountdownControls
textContent={textContent}
form={countdownForm}
onChange={updateCountdownForm}
onSubmit={createCountdown}
/>
)}
</section>
<section className={`tool-split-results timer-results-card ${data.scrollResults ? "is-scrollable" : ""}`}>
<div className="tool-split-actions">
<button className="tool-split-action-button danger" type="button" onClick={resetActiveResults} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="rubber" />
</button>
{activeTab === "countdown" && (
<button
className={`tool-split-scroll-toggle timer-sort-toggle ${data.sortResults ? "active" : ""}`}
type="button"
onClick={() => setSortResults(!data.sortResults)}
aria-pressed={data.sortResults}
title={textContent.sortTitle || "Trier par temps restant"}
aria-label={textContent.sortTitle || "Trier par temps restant"}
>
<Icon name="sort-time" />
<i aria-hidden="true" />
</button>
)}
<button
className={`tool-split-scroll-toggle timer-scroll-toggle ${data.scrollResults ? "active" : ""}`}
type="button"
onClick={() => setScrollResults(!data.scrollResults)}
aria-pressed={data.scrollResults}
title={textContent.scrollableTitle || "Liste scrollable"}
aria-label={textContent.scrollableTitle || "Liste scrollable"}
>
<Icon name="scrollable" />
<i aria-hidden="true" />
</button>
</div>
<div className={`tool-split-tree timer-tree ${data.scrollResults ? "is-scrollable legacy-scrollbar" : ""}`}>
{activeTab === "stopwatch" ? (
<StopwatchLapList laps={data.stopwatch.laps} textContent={textContent} onRename={renameLap} onDelete={deleteLap} />
) : (
<CountdownList countdowns={visibleCountdowns} nowMs={nowMs} textContent={textContent} onRename={renameCountdown} onAlertModeChange={setCountdownAlertMode} onReset={resetCountdown} onDelete={deleteCountdown} />
)}
</div>
</section>
</div>
);
}
function TimerAlertModeSwitch({ mode, blocked, textContent, onChange }) {
const labels = {
off: textContent.alertOffTitle || "Pas d'alerte",
visible: textContent.alertVisibleTitle || "Alerte quand la toolbox est affichée",
site: textContent.alertSiteTitle || "Alerte sur tout le site"
};
const blockedTitle = textContent.alertTooSoonTitle || "Alerte indisponible à moins de 5 minutes";
return (
<div
className={`timer-alert-mode-switch ${blocked ? "is-blocked" : ""}`}
role="group"
aria-label={textContent.alertModeTitle || "Mode d'alerte"}
title={blocked ? blockedTitle : undefined}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
>
{ALERT_MODES.map((option) => {
const label = labels[option.mode];
const disabled = blocked && option.mode !== "off";
return (
<button
key={option.mode}
className={`timer-alert-mode-button ${mode === option.mode ? "active" : ""}`}
type="button"
onClick={() => onChange(option.mode)}
disabled={disabled}
aria-pressed={mode === option.mode}
aria-label={disabled ? blockedTitle : label}
title={disabled ? blockedTitle : label}
>
<Icon name={option.icon} />
</button>
);
})}
</div>
);
}
function StopwatchControls({ textContent, elapsedMs, running, onStart, onPause, onReset, onLap }) {
return (
<div className="timer-control-panel" role="tabpanel">
<span>{textContent.stopwatchTitle || "Chronomètre à étapes"}</span>
<strong><TimerValue ms={elapsedMs} compact /></strong>
<div className="timer-actions">
<button className="primary timer-action-button" type="button" onClick={running ? onPause : onStart} aria-label={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"} title={running ? textContent.pauseButton || "Pause" : textContent.startButton || "Lancer"}>
<Icon name={running ? "pause-circle" : "play-circle"} />
</button>
<button className="timer-action-button" type="button" onClick={onLap} disabled={!elapsedMs} aria-label={textContent.lapButton || "Étape"} title={textContent.lapButton || "Étape"}>
<Icon name="record-circle" />
</button>
<button className="danger timer-action-button" type="button" onClick={onReset} aria-label={textContent.resetTitle || "Réinitialiser"} title={textContent.resetTitle || "Réinitialiser"}>
<Icon name="stop-circle" />
</button>
</div>
</div>
);
}
function CountdownControls({ textContent, form, onChange, onSubmit }) {
return (
<form className="timer-control-panel timer-countdown-form" role="tabpanel" onSubmit={onSubmit}>
<label>
<span>{textContent.countdownLabel || "Libellé"}</span>
<input value={form.label} onChange={(event) => onChange({ label: event.target.value })} placeholder={textContent.countdownLabelPlaceholder || "Boss, event, craft..."} />
</label>
<label>
<span>{textContent.countdownTypeLabel || "Type"}</span>
<select value={form.type} onChange={(event) => onChange({ type: event.target.value })}>
<option value="duration">{textContent.durationType || "Durée"}</option>
<option value="daily_time">{textContent.dailyTimeType || "Heure précise"}</option>
<option value="time_pattern">{textContent.timePatternType || "Horaire spécifique"}</option>
<option value="interval">{textContent.intervalType || "Intervalle"}</option>
</select>
</label>
{form.type === "duration" && (
<label>
<span>{textContent.durationLabel || "Durée"}</span>
<TimePartsInput value={form.duration} onChange={(duration) => onChange({ duration })} />
</label>
)}
{form.type === "daily_time" && (
<label>
<span>{textContent.dailyTimeLabel || "Heure"}</span>
<TimePartsInput value={form.time} onChange={(time) => onChange({ time })} />
</label>
)}
{form.type === "time_pattern" && (
<label>
<span>{textContent.timePatternLabel || "Heure / minute / seconde"}</span>
<TimePartsInput value={form.pattern} onChange={(pattern) => onChange({ pattern })} allowWildcard />
</label>
)}
{form.type === "interval" && (
<label>
<span>{textContent.intervalLabel || "Toutes les"}</span>
<TimePartsInput value={form.interval} onChange={(interval) => onChange({ interval })} />
</label>
)}
<button className="primary" type="submit">{textContent.addCountdownButton || "Ajouter"}</button>
</form>
);
}
function TimePartsInput({ value, onChange, allowWildcard = false }) {
function updatePart(part, nextValue) {
const cleanValue = allowWildcard && /^x+$/i.test(nextValue.trim()) ? "X" : nextValue.replace(/[^\d]/g, "").slice(0, 2);
onChange({ ...value, [part]: cleanValue });
}
return (
<div className="time-parts-input" role="group" aria-label="Format heure minute seconde">
<input value={value.hours} onChange={(event) => updatePart("hours", event.target.value)} placeholder="hh" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.minutes} onChange={(event) => updatePart("minutes", event.target.value)} placeholder="mm" inputMode={allowWildcard ? "text" : "numeric"} />
<span>:</span>
<input value={value.seconds} onChange={(event) => updatePart("seconds", event.target.value)} placeholder="ss" inputMode={allowWildcard ? "text" : "numeric"} />
</div>
);
}
function StopwatchLapList({ laps, textContent, onRename, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!laps.length) return <p className="muted">{textContent.emptySteps || "Aucune étape enregistrée."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{laps.map((lap, index) => (
<li key={lap.id} className={`timer-list-item ${editingId === lap.id ? "is-editing" : ""}`}>
<div className="tool-split-entry timer-entry has-single-action">
{editingId === lap.id ? (
<EditableTimerLabel
value={lap.label}
placeholder={`${textContent.stepLabel || "Étape"} ${laps.length - index}`}
ariaLabel={textContent.lapLabelAriaLabel || "Libellé de l'étape"}
onRename={(label) => onRename(lap.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={lap.elapsedMs} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(lap.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(lap.id))}
>
<span className="timer-entry-line">
<TimerValue ms={lap.elapsedMs} />
<span>{lap.label || `${textContent.stepLabel || "Étape"} ${laps.length - index}`}</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(lap.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
</li>
))}
</ul>
);
}
function CountdownList({ countdowns, nowMs, textContent, onRename, onAlertModeChange, onReset, onDelete }) {
const [editingId, setEditingId] = useState("");
if (!countdowns.length) return <p className="muted">{textContent.emptyCountdowns || "Aucun compte à rebours configuré."}</p>;
return (
<ul className="tool-split-entry-list timer-list">
{countdowns.map((countdown) => {
const remainingMs = countdown.targetMs - nowMs;
const expired = remainingMs <= 0;
const canReset = countdown.type === "duration"
|| countdown.type === "interval"
|| (countdown.type === "daily_time" && expired);
return (
<li key={countdown.id} className={`timer-list-item ${expired ? "is-expired" : ""} ${editingId === countdown.id ? "is-editing" : ""}`}>
<div className={`tool-split-entry timer-entry has-single-action ${editingId !== countdown.id ? "has-inline-controls" : ""} ${canReset && editingId !== countdown.id ? "has-inline-reset" : ""}`}>
{editingId === countdown.id ? (
<EditableTimerLabel
value={countdown.label}
placeholder={textContent.defaultCountdownLabel || "Timer"}
ariaLabel={textContent.countdownLabel || "Libellé"}
onRename={(label) => onRename(countdown.id, label)}
onDone={() => setEditingId("")}
>
<TimerValue ms={remainingMs} showCentiseconds={false} />
</EditableTimerLabel>
) : (
<span
className="tool-split-entry-summary timer-entry-summary"
role="button"
tabIndex={0}
onClick={() => setEditingId(countdown.id)}
onKeyDown={(event) => handleEditableKeyDown(event, () => setEditingId(countdown.id))}
>
<span className="timer-entry-line">
<TimerValue ms={remainingMs} showCentiseconds={false} />
<span>{countdown.label}</span>
<span className="timer-inline-controls">
{canReset && (
<button
className="timer-inline-reset"
type="button"
onClick={(event) => {
event.stopPropagation();
onReset(countdown);
}}
aria-label={textContent.resetTitle || "Réinitialiser"}
title={textContent.resetTitle || "Réinitialiser"}
>
<Icon name="refresh" />
</button>
)}
<TimerAlertModeSwitch
mode={countdown.alertMode}
blocked={countdown.targetMs && countdown.targetMs - nowMs < ALERT_MIN_DELAY_MS}
textContent={textContent}
onChange={(alertMode) => onAlertModeChange(countdown.id, alertMode)}
/>
</span>
</span>
</span>
)}
<button className="danger" type="button" onClick={() => onDelete(countdown.id)} aria-label={textContent.deleteTitle || "Supprimer"} title={textContent.deleteTitle || "Supprimer"}>
<Icon name="trash" />
</button>
</div>
<p className="timer-entry-meta">
<span>{getCountdownMeta(countdown, textContent)}</span>
<em>{expired ? textContent.finishedLabel || "Terminé" : `${textContent.targetLabel || "Prochaine occurrence"} · ${formatTargetTime(countdown.targetMs)}`}</em>
</p>
</li>
);
})}
</ul>
);
}
function EditableTimerLabel({ value, placeholder, ariaLabel, children, onRename, onDone }) {
const [label, setLabel] = useState(value);
const inputRef = useRef(null);
useLayoutEffect(() => {
inputRef.current?.focus();
}, []);
function saveLabel() {
onRename(label);
onDone();
}
return (
<>
<span className="tool-split-entry-value timer-entry-value is-readonly">{children}</span>
<input
ref={inputRef}
className="tool-split-entry-label timer-lap-label"
value={label}
onChange={(event) => setLabel(event.target.value)}
onBlur={saveLabel}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
setLabel(value);
onDone();
}
}}
placeholder={placeholder}
aria-label={ariaLabel}
/>
</>
);
}

View file

@ -11,6 +11,7 @@ import { ImageAnnotationModule } from "./ImageAnnotationModule.jsx";
import { LinksModule } from "./LinksModule.jsx";
import { NotepadModule } from "./NotepadModule.jsx";
import { ImagesModule } from "./ImagesModule.jsx";
import { TimerModule } from "./TimerModule.jsx";
const MODULE_COMPONENTS = {
notepad: { label: "Bloc notes", icon: "notepad", Component: NotepadModule, editable: false },
@ -19,6 +20,7 @@ const MODULE_COMPONENTS = {
links: { label: "Liens", icon: "link", Component: LinksModule, editable: true, scrollable: true },
counters: { label: "Compteurs", icon: "abacus", Component: CountersModule, editable: true, scrollable: true },
calculator: { label: "Calculateur", icon: "calculator", Component: CalculatorModule, editable: false },
timer: { label: "Timer", icon: "clock", Component: TimerModule, editable: false },
imageAnnotation: { label: "Annotation d'images", icon: "map", Component: ImageAnnotationModule, editable: true }
};

View file

@ -0,0 +1,108 @@
// Rôle : regroupe les calculs purs de formatage et d'échéance des timers.
export function getNowMs() {
return Date.now();
}
export function pad(value) {
return String(value).padStart(2, "0");
}
export function formatDuration(ms) {
const safeMs = Math.max(0, Math.floor(ms));
const totalSeconds = Math.floor(safeMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
}
export function getCentiseconds(ms) {
return pad(Math.floor((Math.max(0, Math.floor(ms)) % 1000) / 10));
}
export function formatDurationWithCentiseconds(ms) {
return `${formatDuration(ms)}:${getCentiseconds(ms)}`;
}
export function formatTargetTime(ms) {
if (!Number.isFinite(ms)) return "";
return new Intl.DateTimeFormat("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
}).format(new Date(ms));
}
export function normalizeTimePart(value, max, allowWildcard = false) {
const cleanValue = String(value || "").trim().toUpperCase();
if (allowWildcard && !cleanValue) return "X";
if (allowWildcard && /^X+$/.test(cleanValue)) return "X";
const parsed = cleanValue ? Number.parseInt(cleanValue, 10) : 0;
if (!Number.isFinite(parsed) || parsed < 0 || parsed > max) return "";
return pad(parsed);
}
export function normalizeTimeParts(parts, { allowWildcard = false, allowLargeHours = false } = {}) {
const hours = normalizeTimePart(parts?.hours, allowLargeHours ? 999 : 23, allowWildcard);
const minutes = normalizeTimePart(parts?.minutes, 59, allowWildcard);
const seconds = normalizeTimePart(parts?.seconds, 59, allowWildcard);
if (!hours || !minutes || !seconds) return null;
return { hours, minutes, seconds };
}
export function timePartsToDurationMs(parts) {
const normalized = normalizeTimeParts(parts, { allowLargeHours: true });
if (!normalized) return 0;
return ((Number(normalized.hours) * 3600) + (Number(normalized.minutes) * 60) + Number(normalized.seconds)) * 1000;
}
export function timePartsToString(parts, options) {
const normalized = normalizeTimeParts(parts, options);
return normalized ? `${normalized.hours}:${normalized.minutes}:${normalized.seconds}` : "";
}
export function parseTimeString(value, options) {
const [hours = "", minutes = "", seconds = ""] = String(value || "").split(":");
return normalizeTimeParts({ hours, minutes, seconds }, options);
}
export function getDailyTargetMs(time, nowMs) {
const parsed = parseTimeString(time);
if (!parsed) return 0;
const target = new Date(nowMs);
target.setHours(Number(parsed.hours), Number(parsed.minutes), Number(parsed.seconds), 0);
if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1);
return target.getTime();
}
export function getTimePatternTargetMs(pattern, nowMs) {
const parsed = parseTimeString(pattern, { allowWildcard: true });
if (!parsed) return 0;
const now = new Date(nowMs);
const candidate = new Date(nowMs);
candidate.setMilliseconds(0);
for (let offset = 1; offset <= 86400; offset += 1) {
candidate.setTime(now.getTime() + offset * 1000);
const hours = parsed.hours === "X" || Number(parsed.hours) === candidate.getHours();
const minutes = parsed.minutes === "X" || Number(parsed.minutes) === candidate.getMinutes();
const seconds = parsed.seconds === "X" || Number(parsed.seconds) === candidate.getSeconds();
if (hours && minutes && seconds) return candidate.getTime();
}
return 0;
}
export function getIntervalTargetMs(intervalMs, anchorAt, nowMs) {
const safeIntervalMs = Math.max(1000, Number(intervalMs) || 0);
const safeAnchor = Number.isFinite(anchorAt) && anchorAt > 0 ? anchorAt : nowMs;
if (safeAnchor > nowMs) return safeAnchor;
return safeAnchor + Math.ceil((nowMs - safeAnchor + 1) / safeIntervalMs) * safeIntervalMs;
}
export function getCountdownTargetMs(countdown, nowMs) {
if (countdown.type === "duration") return Number(countdown.targetAt) || 0;
if (countdown.type === "daily_time") return Number(countdown.targetAt) || 0;
if (countdown.type === "time_pattern") return getTimePatternTargetMs(countdown.pattern, nowMs);
if (countdown.type === "interval") return getIntervalTargetMs(countdown.intervalMs, countdown.anchorAt, nowMs);
return 0;
}

View file

@ -1,6 +1,9 @@
// Rôle : normalise, compacte et sérialise les données toolbox persistées.
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k" };
const ID_PREFIXES = { tbx: "t", mod: "m", item: "i", section: "g", image: "s", link: "l", counter: "c", calc: "r", marker: "k", timer: "z" };
const ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
const TIMER_TABS = new Set(["stopwatch", "countdown"]);
const COUNTDOWN_TYPES = new Set(["duration", "daily_time", "time_pattern", "interval"]);
const TIMER_ALERT_MODES = new Set(["off", "visible", "site"]);
const TOOLBOX_ICON_BASE = "/static/img/toolbox-icons/";
const TOOLBOX_ICON_FILES = [
"toolbox.png",
@ -30,6 +33,7 @@ const DEFAULT_MODULE_TITLES = {
links: "Liens",
counters: "Compteurs",
calculator: "Calculateur",
timer: "Timer",
imageAnnotation: "Annotation d'images"
};
@ -210,6 +214,16 @@ function normalizeCalculatorValue(value) {
return Number.isFinite(parsed) ? parsed : 0;
}
function isTimeString(value, allowWildcard = false) {
const pattern = allowWildcard ? /^(?:\d{2}|X):(?:\d{2}|X):(?:\d{2}|X)$/ : /^\d{2}:\d{2}:\d{2}$/;
if (!pattern.test(String(value || ""))) return false;
return String(value).split(":").every((part, index) => {
if (part === "X") return allowWildcard;
const number = Number(part);
return Number.isInteger(number) && number >= 0 && number <= (index === 0 ? 23 : 59);
});
}
export function labelFromFileName(name) {
if (!name || name === "image.png") return "";
return name.replace(/\.[^.]+$/, "").trim();
@ -230,6 +244,62 @@ export function normalizeCalculatorData(data) {
};
}
export function normalizeTimerData(data) {
const stopwatch = data?.stopwatch || {};
const legacyAlertMode = TIMER_ALERT_MODES.has(data?.alertMode) ? data.alertMode : "off";
const laps = (Array.isArray(stopwatch.laps) ? stopwatch.laps : [])
.map((lap) => ({
id: lap?.id || uid("timer"),
label: String(lap?.label || "").trim(),
elapsedMs: Math.max(0, Number(lap?.elapsedMs) || 0)
}))
.filter((lap) => lap.elapsedMs > 0);
const countdowns = (Array.isArray(data?.countdowns) ? data.countdowns : [])
.map((countdown) => {
const type = COUNTDOWN_TYPES.has(countdown?.type) ? countdown.type : "duration";
const normalized = {
id: countdown?.id || uid("timer"),
label: String(countdown?.label || "Timer").trim() || "Timer",
type,
alertMode: TIMER_ALERT_MODES.has(countdown?.alertMode) ? countdown.alertMode : legacyAlertMode
};
if (type === "duration") {
const durationMs = Math.max(0, Number(countdown?.durationMs) || 0);
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return durationMs > 0 && targetAt > 0 ? { ...normalized, durationMs, targetAt } : null;
}
if (type === "daily_time") {
const time = String(countdown?.time || "").trim();
const targetAt = Math.max(0, Number(countdown?.targetAt) || 0);
return isTimeString(time) && targetAt > 0 ? { ...normalized, time, targetAt } : null;
}
if (type === "time_pattern") {
const pattern = String(countdown?.pattern || "").trim().toUpperCase();
return isTimeString(pattern, true) ? { ...normalized, pattern } : null;
}
if (type === "interval") {
const legacyIntervalMs = (Number.parseInt(countdown?.intervalMinutes, 10) || 0) * 60000;
const intervalMs = Math.max(0, Number(countdown?.intervalMs) || legacyIntervalMs);
const anchorAt = Math.max(0, Number(countdown?.anchorAt) || 0);
return intervalMs > 0 ? { ...normalized, intervalMs, anchorAt } : null;
}
return null;
})
.filter(Boolean);
return {
activeTab: TIMER_TABS.has(data?.activeTab) ? data.activeTab : "stopwatch",
scrollResults: data?.scrollResults === true,
sortResults: data?.sortResults === true,
stopwatch: {
elapsedMs: Math.max(0, Number(stopwatch.elapsedMs) || 0),
startedAt: Math.max(0, Number(stopwatch.startedAt) || 0),
laps
},
countdowns
};
}
function clampPercent(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
@ -339,6 +409,30 @@ export function compactModuleDataForStorage(type, value) {
if (!entries.length && !normalized.scrollResults) return null;
return normalized.scrollResults ? { entries, scrollResults: true } : { entries };
}
if (type === "timer") {
const normalized = normalizeTimerData(value);
const compact = {};
if (normalized.activeTab !== "stopwatch") compact.activeTab = normalized.activeTab;
if (normalized.scrollResults) compact.scrollResults = true;
if (normalized.sortResults) compact.sortResults = true;
if (normalized.stopwatch.elapsedMs || normalized.stopwatch.startedAt || normalized.stopwatch.laps.length) {
compact.stopwatch = {
...(normalized.stopwatch.elapsedMs ? { elapsedMs: normalized.stopwatch.elapsedMs } : {}),
...(normalized.stopwatch.startedAt ? { startedAt: normalized.stopwatch.startedAt } : {}),
...(normalized.stopwatch.laps.length ? { laps: normalized.stopwatch.laps } : {})
};
}
if (normalized.countdowns.length) {
compact.countdowns = normalized.countdowns.map((countdown) => {
if (countdown.alertMode === "off") {
const { alertMode, ...compactCountdown } = countdown;
return compactCountdown;
}
return countdown;
});
}
return Object.keys(compact).length ? compact : null;
}
return value;
}
@ -403,6 +497,20 @@ function remapModuleDataForExport(type, data, nextId) {
return remapped;
}
if (type === "timer") {
const remapped = { ...compact };
if (compact.stopwatch?.laps) {
remapped.stopwatch = {
...compact.stopwatch,
laps: compact.stopwatch.laps.map((lap) => ({ ...lap, id: nextId("timer") }))
};
}
if (compact.countdowns) {
remapped.countdowns = compact.countdowns.map((countdown) => ({ ...countdown, id: nextId("timer") }));
}
return remapped;
}
return compact;
}

View file

@ -3,12 +3,14 @@ import { useEffect, useState } from "react";
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";
import {
@ -23,6 +25,7 @@ export function useIndexedToolboxes(onError) {
const [toolboxes, setToolboxesState] = useState([]);
const [links, setLinksState] = useState({});
const [moduleData, setModuleDataState] = useState({});
const [toastPosition, setToastPositionState] = useState("right");
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
async function refreshQuota() {
@ -40,15 +43,17 @@ export function useIndexedToolboxes(onError) {
let cancelled = false;
async function loadStore() {
try {
const [storedToolboxes, storedLinks, storedModules] = await Promise.all([
const [storedToolboxes, storedLinks, storedModules, storedToastPosition] = await Promise.all([
dbGetToolboxes(),
dbGetLinks(),
dbGetAllModuleData()
dbGetAllModuleData(),
dbGetSetting("toastPosition", "right")
]);
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])));
setToastPositionState(storedToastPosition === "left" ? "left" : "right");
setReady(true);
refreshQuota();
} catch (error) {
@ -118,11 +123,20 @@ export function useIndexedToolboxes(onError) {
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => onError(error.message));
}
function setToastPosition(position) {
const normalized = position === "left" ? "left" : "right";
setToastPositionState(normalized);
dbSetSetting("toastPosition", normalized)
.then(refreshQuota)
.catch((error) => onError(error.message));
}
return {
ready,
toolboxes,
links,
moduleData,
toastPosition,
storageUsage,
setToolboxes: persistToolboxes,
setLinks: persistLinks,
@ -130,6 +144,7 @@ export function useIndexedToolboxes(onError) {
updateModuleData,
removeModuleData,
removeToolboxModuleData,
setToastPosition,
refreshQuota
};
}

View file

@ -0,0 +1,63 @@
// Rôle : surveille globalement les comptes à rebours actifs et déclenche les alertes internes autorisées.
import { useEffect, useRef } from "react";
import { getCountdownTargetMs } from "../features/toolboxes/modules/timerUtils.js";
import { moduleStorageKey } from "../features/toolboxes/storage/toolboxStorage.js";
function getVisibleToolboxIds(route, drawerGameId, links) {
const visibleIds = new Set();
const toolboxMatch = route.match(/^\/toolbox\/([^/]+)/);
if (toolboxMatch?.[1]) visibleIds.add(toolboxMatch[1]);
const drawerToolboxId = drawerGameId ? links[drawerGameId] : "";
if (drawerToolboxId) visibleIds.add(drawerToolboxId);
return visibleIds;
}
function getAlertMessage(template, toolbox, countdown) {
return template
.replaceAll("{label}", countdown.label)
.replaceAll("{toolbox}", toolbox.name);
}
export function useTimerAlerts({ toolboxes, moduleData, links, route, drawerGameId, normalizeTimerData, textContent, notify }) {
const previousTargetsRef = useRef(new Map());
const lastTickRef = useRef(Date.now());
useEffect(() => {
const visibleToolboxIds = getVisibleToolboxIds(route, drawerGameId, links);
const alertMessageTemplate = textContent?.alertMessage || "Compte à rebours terminé : {label}";
function tick() {
const nowMs = Date.now();
const lastTickMs = lastTickRef.current;
const nextTargets = new Map();
toolboxes.forEach((toolbox) => {
toolbox.modules.forEach((module) => {
if (module.type !== "timer") return;
const data = normalizeTimerData(moduleData[moduleStorageKey(toolbox.id, module.id)]);
data.countdowns.forEach((countdown) => {
if (countdown.alertMode === "off") return;
if (countdown.alertMode === "visible" && !visibleToolboxIds.has(toolbox.id)) return;
const key = `${toolbox.id}:${module.id}:${countdown.id}`;
const targetMs = getCountdownTargetMs(countdown, nowMs);
const previousTargetMs = previousTargetsRef.current.get(key) || targetMs;
nextTargets.set(key, targetMs);
if (!targetMs || previousTargetMs <= lastTickMs || previousTargetMs > nowMs) return;
notify(getAlertMessage(alertMessageTemplate, toolbox, countdown));
});
});
});
previousTargetsRef.current = nextTargets;
lastTickRef.current = nowMs;
}
tick();
const intervalId = window.setInterval(tick, 500);
return () => window.clearInterval(intervalId);
}, [drawerGameId, links, moduleData, normalizeTimerData, notify, route, textContent, toolboxes]);
}

View file

@ -1,5 +1,5 @@
// Rôle : point d'entrée React, assemble données, routes, shell et overlays.
import React, { useEffect, useState } from "react";
import React, { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import "./styles/main.scss";
import { AppOverlays } from "./components/AppOverlays.jsx";
@ -9,9 +9,10 @@ import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedTool
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
import { useAppData } from "./hooks/useAppData.js";
import { useGameFilters } from "./hooks/useGameFilters.js";
import { useTimerAlerts } from "./hooks/useTimerAlerts.js";
import { RouteContent } from "./router/RouteContent.jsx";
import { useHashRoute } from "./router/hashRouter.js";
import { moduleStorageKey } from "./features/toolboxes/storage/toolboxStorage.js";
import { normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
function App() {
const route = useHashRoute();
@ -26,9 +27,9 @@ function App() {
const [storageError, setStorageError] = useState("");
const store = useIndexedToolboxes((message) => setStorageError(message));
function notify(message) {
const notify = useCallback((message) => {
setNotification({ id: Date.now(), message });
}
}, []);
useEffect(() => {
function handleNotification(event) {
@ -37,11 +38,11 @@ function App() {
window.addEventListener("sokkog:notify", handleNotification);
return () => window.removeEventListener("sokkog:notify", handleNotification);
}, []);
}, [notify]);
useEffect(() => {
if (!notification) return undefined;
const timeoutId = window.setTimeout(() => setNotification(null), 1800);
const timeoutId = window.setTimeout(() => setNotification(null), 120000);
return () => window.clearTimeout(timeoutId);
}, [notification]);
@ -63,6 +64,17 @@ function App() {
setImage
});
useTimerAlerts({
toolboxes: store.toolboxes,
moduleData: store.moduleData,
links: store.links,
route,
drawerGameId,
normalizeTimerData,
textContent: siteContent?.toolboxes?.modules?.timer,
notify
});
if (!siteContent) {
return (
<main className="startup-error">
@ -92,6 +104,8 @@ function App() {
getToolboxGame={getToolboxGame}
actions={actions}
storageUsage={store.storageUsage}
toastPosition={store.toastPosition}
setToastPosition={store.setToastPosition}
getModuleData={store.getModuleData}
updateToolbox={actions.updateToolbox}
updateModuleData={store.updateModuleData}
@ -126,6 +140,8 @@ function App() {
image={image}
setImage={setImage}
notification={notification}
setNotification={setNotification}
toastPosition={store.toastPosition}
getGame={getGame}
toolboxes={store.toolboxes}
links={store.links}

View file

@ -72,6 +72,41 @@
-webkit-mask-image: url("/static/icons/calculator.svg");
}
.ui-icon-clock {
mask-image: url("/static/icons/clock.svg");
-webkit-mask-image: url("/static/icons/clock.svg");
}
.ui-icon-stopwatch {
mask-image: url("/static/icons/stopwatch.svg");
-webkit-mask-image: url("/static/icons/stopwatch.svg");
}
.ui-icon-hourglass {
mask-image: url("/static/icons/hourglass.svg");
-webkit-mask-image: url("/static/icons/hourglass.svg");
}
.ui-icon-play-circle {
mask-image: url("/static/icons/play-circle.svg");
-webkit-mask-image: url("/static/icons/play-circle.svg");
}
.ui-icon-pause-circle {
mask-image: url("/static/icons/pause-circle.svg");
-webkit-mask-image: url("/static/icons/pause-circle.svg");
}
.ui-icon-record-circle {
mask-image: url("/static/icons/record-circle.svg");
-webkit-mask-image: url("/static/icons/record-circle.svg");
}
.ui-icon-stop-circle {
mask-image: url("/static/icons/stop-circle.svg");
-webkit-mask-image: url("/static/icons/stop-circle.svg");
}
.ui-icon-map {
mask-image: url("/static/icons/map.svg");
-webkit-mask-image: url("/static/icons/map.svg");
@ -82,6 +117,36 @@
-webkit-mask-image: url("/static/icons/scrollable.svg");
}
.ui-icon-sort-time {
mask-image: url("/static/icons/sort-time.svg");
-webkit-mask-image: url("/static/icons/sort-time.svg");
}
.ui-icon-sound-mute {
mask-image: url("/static/icons/sound-mute.svg");
-webkit-mask-image: url("/static/icons/sound-mute.svg");
}
.ui-icon-sound-min {
mask-image: url("/static/icons/sound-min.svg");
-webkit-mask-image: url("/static/icons/sound-min.svg");
}
.ui-icon-sound-max {
mask-image: url("/static/icons/sound-max.svg");
-webkit-mask-image: url("/static/icons/sound-max.svg");
}
.ui-icon-toast-left {
mask-image: url("/static/icons/toast-left.svg");
-webkit-mask-image: url("/static/icons/toast-left.svg");
}
.ui-icon-toast-right {
mask-image: url("/static/icons/toast-right.svg");
-webkit-mask-image: url("/static/icons/toast-right.svg");
}
.ui-icon-hide {
mask-image: url("/static/icons/hide.svg");
-webkit-mask-image: url("/static/icons/hide.svg");
@ -142,6 +207,11 @@
-webkit-mask-image: url("/static/icons/rubber.svg");
}
.ui-icon-refresh {
mask-image: url("/static/icons/refresh.svg");
-webkit-mask-image: url("/static/icons/refresh.svg");
}
.ui-icon-trash {
mask-image: url("/static/icons/trashcan.svg");
-webkit-mask-image: url("/static/icons/trashcan.svg");

View file

@ -321,25 +321,135 @@ body.is-resizing-drawer * {
gap: 10px;
}
@property --notification-toast-angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
.notification-toast {
--notification-toast-angle: 0deg;
position: fixed;
right: var(--space-5);
bottom: var(--space-5);
z-index: 360;
max-width: min(360px, calc(100vw - 32px));
padding: 11px 14px;
border: 1px solid rgba(246, 196, 83, 0.34);
border-radius: var(--radius-md);
display: grid;
grid-template-columns: minmax(0, 1fr) 34px;
align-items: center;
gap: var(--space-3);
max-width: min(460px, calc(100vw - 32px));
min-width: min(360px, calc(100vw - 32px));
padding: 14px 14px 14px 18px;
border: 2px solid transparent;
border-radius: var(--radius-lg);
background:
linear-gradient(135deg, rgba(16, 20, 38, 0.94), rgba(24, 20, 42, 0.94)) padding-box,
linear-gradient(135deg, rgba(246, 196, 83, 0.48), rgba(139, 92, 246, 0.28)) border-box;
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.12), transparent 38%) padding-box,
linear-gradient(135deg, rgba(16, 20, 38, 0.98), rgba(24, 20, 42, 0.98)) padding-box,
conic-gradient(
from var(--notification-toast-angle),
rgba(246, 196, 83, 0.95),
rgba(139, 92, 246, 0.8),
rgba(56, 189, 248, 0.62),
rgba(246, 196, 83, 0.95)
) border-box;
color: var(--color-text-primary);
box-shadow:
var(--shadow-sm),
0 0 18px rgba(139, 92, 246, 0.16);
font-size: var(--font-size-sm);
font-weight: 800;
pointer-events: none;
var(--shadow-md),
0 0 24px rgba(139, 92, 246, 0.22),
0 0 18px rgba(246, 196, 83, 0.1);
font-size: var(--font-size-md);
font-weight: 900;
pointer-events: auto;
animation:
notification-toast-border-spin 2.4s linear infinite,
notification-toast-pulse 1.8s ease-in-out infinite;
}
.notification-toast.is-right {
right: var(--space-5);
animation:
notification-toast-enter-right 320ms var(--ease-standard),
notification-toast-border-spin 2.4s linear infinite,
notification-toast-pulse 1.8s ease-in-out infinite;
}
.notification-toast.is-left {
left: var(--space-5);
animation:
notification-toast-enter-left 320ms var(--ease-standard),
notification-toast-border-spin 2.4s linear infinite,
notification-toast-pulse 1.8s ease-in-out infinite;
}
.notification-toast span {
min-width: 0;
}
.notification-toast button {
display: inline-grid;
width: 34px;
min-width: 34px;
min-height: 34px;
place-items: center;
padding: 0;
border-color: rgba(165, 180, 252, 0.12);
background: rgba(5, 7, 17, 0.28);
color: var(--color-text-secondary);
}
.notification-toast button:hover {
border-color: rgba(196, 181, 253, 0.26);
background: rgba(21, 26, 48, 0.72);
color: var(--color-text-primary);
}
.notification-toast button .ui-icon {
width: 16px;
height: 16px;
}
@keyframes notification-toast-border-spin {
to { --notification-toast-angle: 360deg; }
}
@keyframes notification-toast-enter-right {
from {
opacity: 0;
transform: translateX(calc(100% + var(--space-5)));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes notification-toast-enter-left {
from {
opacity: 0;
transform: translateX(calc(-100% - var(--space-5)));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes notification-toast-pulse {
0%,
100% {
box-shadow:
var(--shadow-md),
0 0 24px rgba(139, 92, 246, 0.22),
0 0 18px rgba(246, 196, 83, 0.1);
}
50% {
box-shadow:
var(--shadow-md),
0 0 34px rgba(139, 92, 246, 0.3),
0 0 26px rgba(246, 196, 83, 0.18);
}
}
.tool-add-modal {

View file

@ -200,7 +200,8 @@
grid-template-columns: 1fr;
}
.calculator-module {
.calculator-module,
.timer-module {
grid-template-columns: 1fr;
}
@ -218,6 +219,11 @@
justify-content: flex-start;
}
.toolbox-preferences-row {
max-width: none;
justify-self: stretch;
}
.tool-add-modal {
width: min(420px, calc(100vw - 32px));
}

View file

@ -272,7 +272,7 @@
.topbar {
position: sticky;
top: 0;
z-index: 20;
z-index: 80;
display: flex;
min-height: 68px;
align-items: center;

View file

@ -296,6 +296,116 @@
justify-content: flex-end;
}
.toolbox-preferences-row {
position: relative;
isolation: isolate;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
width: 100%;
max-width: 520px;
align-items: center;
justify-self: end;
gap: var(--space-4);
margin-top: var(--space-4);
padding: var(--space-4);
overflow: hidden;
border: 1px solid transparent;
border-radius: var(--radius-lg);
background:
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.08), transparent 34%),
linear-gradient(135deg, rgba(16, 20, 38, 0.84), rgba(5, 7, 17, 0.76));
color: var(--color-text-muted);
font-size: var(--font-size-sm);
font-weight: 800;
box-shadow: var(--shadow-sm);
}
.toolbox-preferences-row::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
padding: 1px;
border-radius: inherit;
background:
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.38), transparent 30%),
linear-gradient(135deg, rgba(246, 196, 83, 0.26), rgba(139, 92, 246, 0.16), rgba(56, 189, 248, 0.18));
@include mixins.border-mask;
}
.toolbox-preferences-row .eyebrow {
margin-bottom: 4px;
font-size: 0.68rem;
}
.toolbox-preferences-row strong {
display: block;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
}
.toolbox-preferences-row + .cards {
margin-top: 0;
}
.toast-position-switch {
position: relative;
display: inline-grid;
grid-template-columns: repeat(2, 32px);
width: 74px;
min-height: 38px;
align-items: center;
justify-items: center;
gap: 0;
padding: 3px 4px;
border-color: rgba(165, 180, 252, 0.12);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.32);
color: var(--color-text-secondary);
}
.toast-position-switch::before {
content: "";
position: absolute;
top: 3px;
bottom: 3px;
left: 4px;
width: 30px;
border: 1px solid rgba(246, 196, 83, 0.3);
border-radius: 50%;
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.1), rgba(139, 92, 246, 0.08)),
rgba(21, 26, 48, 0.58);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
transition: transform var(--duration-fast) var(--ease-standard);
}
.toast-position-switch[data-position="right"]::before {
transform: translateX(32px);
}
.toast-position-switch span {
position: relative;
z-index: 1;
display: inline-grid;
width: 30px;
height: 30px;
place-items: center;
justify-self: center;
align-self: center;
border-radius: 50%;
}
.toast-position-switch .ui-icon {
width: 16px;
height: 16px;
}
.toast-position-switch:hover {
border-color: rgba(196, 181, 253, 0.24);
color: var(--color-text-primary);
}
.modules {
align-items: start;
}
@ -606,11 +716,71 @@
-webkit-mask-image: url("/static/icons/calculator.svg");
}
.module-icon-clock {
mask-image: url("/static/icons/clock.svg");
-webkit-mask-image: url("/static/icons/clock.svg");
}
.module-icon-stopwatch {
mask-image: url("/static/icons/stopwatch.svg");
-webkit-mask-image: url("/static/icons/stopwatch.svg");
}
.module-icon-hourglass {
mask-image: url("/static/icons/hourglass.svg");
-webkit-mask-image: url("/static/icons/hourglass.svg");
}
.module-icon-map {
mask-image: url("/static/icons/map.svg");
-webkit-mask-image: url("/static/icons/map.svg");
}
.tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 6px;
padding: 5px;
border: 1px solid rgba(165, 180, 252, 0.12);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.34);
}
.tabs button {
display: inline-flex;
min-width: 0;
min-height: 36px;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 10px;
border: 1px solid transparent;
border-radius: calc(var(--radius-md) - 2px);
background: transparent;
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.tabs button:hover {
border-color: rgba(246, 196, 83, 0.28);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-primary);
}
.tabs button.active {
border-color: rgba(246, 196, 83, 0.56);
background: rgba(246, 196, 83, 0.12);
color: var(--color-accent-gold);
box-shadow: 0 0 18px rgba(246, 196, 83, 0.08);
}
.tabs .module-icon-svg {
width: 16px;
height: 16px;
background: currentColor;
}
.notepad {
display: block;
width: calc(100% - 32px);
@ -809,7 +979,15 @@ textarea:focus {
font-weight: 800;
}
.checklist-complete-mode-button {
.timer-alert-mode-switch {
display: inline-flex;
width: fit-content;
align-items: center;
opacity: 0.78;
}
.checklist-complete-mode-button,
.timer-alert-mode-button {
display: inline-grid;
width: 36px;
min-width: 36px;
@ -824,23 +1002,41 @@ textarea:focus {
font-weight: 800;
}
.checklist-complete-mode-button:first-child {
.checklist-complete-mode-button:first-child,
.timer-alert-mode-button:first-child {
border-radius: var(--radius-pill) 0 0 var(--radius-pill);
}
.checklist-complete-mode-button:last-child {
.checklist-complete-mode-button:last-child,
.timer-alert-mode-button:last-child {
border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
}
.checklist-complete-mode-button + .checklist-complete-mode-button {
.checklist-complete-mode-button + .checklist-complete-mode-button,
.timer-alert-mode-button + .timer-alert-mode-button {
margin-left: -1px;
}
.checklist-complete-mode-button .ui-icon {
.checklist-complete-mode-button .ui-icon,
.timer-alert-mode-button .ui-icon {
width: 16px;
height: 16px;
}
.timer-alert-mode-button {
width: 24px;
min-width: 24px;
min-height: 26px;
border-color: rgba(165, 180, 252, 0.08);
background: rgba(5, 7, 17, 0.26);
color: var(--color-text-muted);
}
.timer-alert-mode-button .ui-icon {
width: 13px;
height: 13px;
}
.checklist-section-collapse-button {
display: inline-grid;
width: 30px;
@ -1026,7 +1222,8 @@ textarea:focus {
font-weight: 900;
}
.calculator-module {
.calculator-module,
.timer-module {
display: grid;
grid-template-columns: minmax(0, 35fr) minmax(0, 65fr);
column-gap: var(--space-6);
@ -1046,7 +1243,9 @@ textarea:focus {
}
.calculator-card,
.calculator-saved {
.tool-split-results,
.timer-control-card,
.timer-results-card {
display: grid;
width: 100%;
max-width: 100%;
@ -1061,6 +1260,316 @@ textarea:focus {
rgba(7, 10, 24, 0.3);
}
.timer-control-card,
.timer-results-card {
align-self: start;
gap: var(--space-3);
}
.timer-tabs {
width: fit-content;
grid-template-columns: repeat(2, 40px);
}
.timer-tabs button {
width: 40px;
min-width: 40px;
min-height: 36px;
padding: 0;
}
.timer-control-panel {
display: grid;
min-height: 170px;
align-content: center;
gap: var(--space-2);
padding: var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background:
radial-gradient(circle at 100% 0%, rgba(246, 196, 83, 0.08), transparent 36%),
rgba(5, 7, 17, 0.5);
}
.timer-countdown-form {
align-content: start;
}
.timer-countdown-form label,
.timer-control-panel > label {
display: grid;
gap: 7px;
}
.timer-countdown-form input,
.timer-countdown-form select,
.timer-control-panel input {
width: 100%;
}
.timer-countdown-form select {
appearance: none;
-webkit-appearance: none;
overflow: hidden;
padding-right: 34px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text-primary);
text-overflow: ellipsis;
background-image:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='%23ffffff' viewBox='-6.5 0 32 32'%3E%3Cpath d='M18.813 11.406l-7.906 9.906c-.75.906-1.906.906-2.625 0L.376 11.406c-.75-.938-.375-1.656.781-1.656h16.875c1.188 0 1.531.719.781 1.656z'/%3E%3C/svg%3E"),
linear-gradient(135deg, rgba(139, 92, 246, 0.08), rgba(31, 41, 78, 0.12)),
linear-gradient(rgba(15, 23, 42, 0.72), rgba(15, 23, 42, 0.72));
background-position:
calc(100% - 14px) 50%,
0 0,
0 0;
background-size:
12px 12px,
auto,
auto;
background-repeat: no-repeat;
}
.timer-countdown-form select option {
background: #11182f;
color: var(--color-text-primary);
}
.time-parts-input {
display: grid;
grid-template-columns: minmax(34px, 1fr) 4px minmax(34px, 1fr) 4px minmax(34px, 1fr);
align-items: center;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: rgba(15, 23, 42, 0.64);
overflow: hidden;
}
.time-parts-input:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.16);
}
.time-parts-input input {
width: auto;
min-width: 0;
min-height: 42px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
text-align: center;
box-shadow: none;
}
.time-parts-input input:focus {
border-color: transparent;
box-shadow: none;
}
.time-parts-input input:focus-visible {
outline: 0;
outline-offset: 0;
}
.time-parts-input span {
color: var(--color-text-muted);
font-weight: 900;
text-align: center;
}
.timer-control-panel > span,
.timer-control-panel label span {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 900;
}
.timer-control-panel strong {
color: var(--color-text-primary);
min-width: 0;
overflow: hidden;
font-weight: 950;
line-height: 0.95;
}
.timer-value {
display: inline-flex;
min-width: 0;
align-items: baseline;
gap: 0.16em;
font-variant-numeric: tabular-nums;
letter-spacing: 0;
white-space: nowrap;
}
.timer-value > span {
min-width: 0;
}
.timer-control-panel .timer-value.is-compact {
max-width: 100%;
}
.timer-value small {
color: var(--color-text-secondary);
font-size: inherit;
font-weight: 900;
line-height: 1;
flex: 0 0 auto;
}
.timer-value.is-compact small {
display: inline-block;
font-size: 0.5em;
transform: translateY(-0.08em);
}
.timer-value.is-compact {
font-size: clamp(1.45rem, 2.25vw, 2rem);
}
.timer-control-panel p,
.timer-results-card p {
margin: 0;
}
.timer-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.timer-action-button,
.timer-actions .danger {
display: inline-flex;
width: 40px;
min-width: 40px;
min-height: 40px;
align-items: center;
justify-content: center;
padding: 0;
}
.timer-list-item.is-expired {
opacity: 0.78;
}
.timer-entry-meta {
display: flex;
grid-column: 1 / -2;
flex-wrap: wrap;
min-width: 0;
gap: 4px 8px;
margin: 0;
padding: 1px 10px 0;
opacity: 0.68;
}
.timer-entry-summary .timer-value,
.timer-entry-value .timer-value {
color: var(--color-text-primary);
font-size: var(--font-size-md);
font-weight: 900;
}
.timer-entry-summary {
min-width: 0;
cursor: pointer;
}
.timer-entry-line {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
width: 100%;
max-width: 100%;
min-width: 0;
gap: var(--space-2);
align-items: center;
overflow: hidden;
}
.timer-entry.has-inline-reset .timer-entry-line {
grid-template-columns: max-content minmax(0, 1fr) auto;
}
.timer-entry.has-inline-controls .timer-entry-line {
grid-template-columns: max-content minmax(0, 1fr) auto;
}
.timer-entry.has-single-action {
grid-template-columns: minmax(0, 1fr) 34px;
}
.tool-split-entry-list li.is-editing > .timer-entry.has-single-action {
grid-template-columns: auto minmax(0, 1fr) 34px;
}
.timer-inline-reset {
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(165, 180, 252, 0.08);
border-radius: var(--radius-sm);
background: rgba(5, 7, 17, 0.26);
color: var(--color-text-muted);
}
.timer-inline-reset .ui-icon {
width: 14px;
height: 14px;
}
.timer-inline-controls {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.timer-entry-line > span:nth-child(2) {
min-width: 0;
overflow: hidden;
color: var(--color-text-secondary);
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.timer-lap-label {
font-weight: 700;
}
.timer-lap-label::placeholder {
color: rgba(229, 235, 255, 0.72);
}
.timer-entry-meta span {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-weight: 700;
}
.timer-list-item em {
color: var(--color-text-muted);
font-size: var(--font-size-xs);
font-style: normal;
font-weight: 700;
}
.timer-results-card {
min-height: 260px;
}
.timer-results-card.is-scrollable {
max-height: 450px;
overflow: hidden;
}
.calculator-card {
gap: 10px;
align-self: start;
@ -1084,7 +1593,7 @@ textarea:focus {
.calculator-form label span,
.calculator-result span,
.calculator-root-button {
.tool-split-root-button {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
font-weight: 700;
@ -1109,7 +1618,7 @@ textarea:focus {
text-align: right;
}
.calculator-saved {
.tool-split-results {
grid-template-rows: auto minmax(0, 1fr);
gap: var(--space-3);
overflow: visible;
@ -1124,7 +1633,7 @@ textarea:focus {
overflow: hidden;
}
.calculator-result-actions {
.tool-split-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
@ -1133,7 +1642,7 @@ textarea:focus {
min-width: 0;
}
.calculator-root-button {
.tool-split-root-button {
width: fit-content;
min-height: 34px;
padding: 0 12px;
@ -1142,8 +1651,8 @@ textarea:focus {
background: rgba(5, 7, 17, 0.24);
}
.calculator-action-button,
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value),
.tool-split-action-button,
.tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value),
.counter-actions button,
.link-item button {
display: inline-flex;
@ -1159,17 +1668,17 @@ textarea:focus {
color: var(--color-text-secondary);
}
.calculator-action-button:disabled {
.tool-split-action-button:disabled {
cursor: not-allowed;
opacity: 0.48;
}
.calculator-action-button .ui-icon {
.tool-split-action-button .ui-icon {
width: 16px;
height: 16px;
}
.calculator-scroll-toggle {
.tool-split-scroll-toggle {
display: inline-grid;
grid-template-columns: 18px 34px;
width: fit-content;
@ -1185,12 +1694,12 @@ textarea:focus {
font-weight: 800;
}
.calculator-scroll-toggle .ui-icon {
.tool-split-scroll-toggle .ui-icon {
width: 16px;
height: 16px;
}
.calculator-scroll-toggle i {
.tool-split-scroll-toggle i {
position: relative;
display: block;
width: 34px;
@ -1200,7 +1709,7 @@ textarea:focus {
background: rgba(7, 10, 24, 0.72);
}
.calculator-scroll-toggle i::before {
.tool-split-scroll-toggle i::before {
content: "";
position: absolute;
top: 50%;
@ -1216,20 +1725,20 @@ textarea:focus {
box-shadow var(--duration-fast) var(--ease-standard);
}
.calculator-root-button {
.tool-split-root-button {
margin-right: auto;
}
.calculator-root-button:hover,
.calculator-action-button:hover:not(:disabled),
.tool-split-root-button:hover,
.tool-split-action-button:hover:not(:disabled),
.checklist-qty-controls button:hover,
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value, .danger):hover,
.tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value, .danger):hover,
.counter-actions button:not(.danger):hover,
.link-item button:not(.danger):hover,
.module-scroll-button:hover,
.module-scroll-button.active,
.calculator-scroll-toggle:hover,
.calculator-scroll-toggle.active,
.tool-split-scroll-toggle:hover,
.tool-split-scroll-toggle.active,
.checklist-complete-mode-button:hover,
.checklist-complete-mode-button.active {
border-color: rgba(196, 181, 253, 0.32);
@ -1242,6 +1751,52 @@ textarea:focus {
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
.timer-alert-mode-button:hover,
.timer-alert-mode-button.active {
border-color: rgba(196, 181, 253, 0.2);
background: rgba(21, 26, 48, 0.42);
color: var(--color-text-primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.timer-alert-mode-button.active {
border-color: rgba(246, 196, 83, 0.26);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.08), rgba(139, 92, 246, 0.08)),
rgba(21, 26, 48, 0.36);
color: var(--color-accent-gold);
}
.timer-alert-mode-button:disabled,
.timer-alert-mode-button:disabled:hover,
.timer-alert-mode-button.active:disabled {
cursor: not-allowed;
border-color: rgba(165, 180, 252, 0.025);
background: rgba(5, 7, 17, 0.08);
color: rgba(148, 163, 184, 0.22);
opacity: 1;
box-shadow: none;
}
.timer-alert-mode-button:disabled .ui-icon {
opacity: 0.34;
}
.timer-alert-mode-switch.is-blocked {
opacity: 1;
}
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button:disabled:hover,
.timer-alert-mode-switch.is-blocked .timer-alert-mode-button.active:disabled {
cursor: not-allowed;
border-color: rgba(165, 180, 252, 0.035);
background: rgba(5, 7, 17, 0.1);
color: rgba(148, 163, 184, 0.32);
box-shadow: none;
opacity: 0.2;
}
.checklist-section-collapse-button:hover {
border-color: rgba(246, 196, 83, 0.58);
background:
@ -1253,8 +1808,8 @@ textarea:focus {
0 0 16px rgba(139, 92, 246, 0.08);
}
.calculator-action-button.danger,
.calculator-entry > button.danger,
.tool-split-action-button.danger,
.tool-split-entry > button.danger,
.counter-actions button.danger,
.link-item button.danger {
border-color: rgba(251, 113, 133, 0.2);
@ -1262,8 +1817,8 @@ textarea:focus {
color: var(--color-danger);
}
.calculator-action-button.danger:hover:not(:disabled),
.calculator-entry > button.danger:hover,
.tool-split-action-button.danger:hover:not(:disabled),
.tool-split-entry > button.danger:hover,
.counter-actions button.danger:hover,
.link-item button.danger:hover {
border-color: rgba(251, 113, 133, 0.56);
@ -1276,8 +1831,8 @@ textarea:focus {
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.calculator-action-button.danger:hover .ui-icon,
.calculator-entry > button.danger:hover .ui-icon,
.tool-split-action-button.danger:hover .ui-icon,
.tool-split-entry > button.danger:hover .ui-icon,
.counter-actions button.danger:hover .ui-icon,
.link-item button.danger:hover .ui-icon {
background-color: #fff;
@ -1285,7 +1840,7 @@ textarea:focus {
}
.module-scroll-button.active,
.calculator-scroll-toggle.active,
.tool-split-scroll-toggle.active,
.checklist-complete-mode-button.active {
border-color: rgba(246, 196, 83, 0.34);
background:
@ -1295,13 +1850,13 @@ textarea:focus {
}
.module-scroll-button.active i,
.calculator-scroll-toggle.active i {
.tool-split-scroll-toggle.active i {
border-color: rgba(246, 196, 83, 0.36);
background: rgba(246, 196, 83, 0.12);
}
.module-scroll-button.active i::before,
.calculator-scroll-toggle.active i::before {
.tool-split-scroll-toggle.active i::before {
left: 17px;
background: var(--color-accent-gold);
box-shadow: 0 0 10px rgba(246, 196, 83, 0.28);
@ -1311,7 +1866,7 @@ textarea:focus {
left: 15px;
}
.calculator-tree {
.tool-split-tree {
display: grid;
align-content: start;
gap: 8px;
@ -1319,12 +1874,12 @@ textarea:focus {
min-height: 0;
}
.calculator-tree.is-scrollable {
.tool-split-tree.is-scrollable {
overflow-y: auto;
padding-right: 8px;
}
.calculator-entry-list {
.tool-split-entry-list {
display: grid;
gap: 6px;
margin: 0;
@ -1332,13 +1887,13 @@ textarea:focus {
list-style: none;
}
.calculator-entry-list .calculator-entry-list {
.tool-split-entry-list .tool-split-entry-list {
position: relative;
margin-top: 6px;
padding-left: 22px;
}
.calculator-entry-list .calculator-entry-list::before {
.tool-split-entry-list .tool-split-entry-list::before {
content: "";
position: absolute;
top: -3px;
@ -1348,11 +1903,11 @@ textarea:focus {
background: linear-gradient(180deg, rgba(246, 196, 83, 0.52), rgba(139, 92, 246, 0.2));
}
.calculator-entry-list .calculator-entry-list > li {
.tool-split-entry-list .tool-split-entry-list > li {
position: relative;
}
.calculator-entry-list .calculator-entry-list > li::before {
.tool-split-entry-list .tool-split-entry-list > li::before {
content: "";
position: absolute;
top: 18px;
@ -1362,25 +1917,26 @@ textarea:focus {
background: rgba(246, 196, 83, 0.52);
}
.calculator-entry {
.tool-split-entry {
display: grid;
grid-template-columns: minmax(0, 1fr) 34px 34px;
gap: 7px;
}
.calculator-entry-list li.is-editing > .calculator-entry {
.tool-split-entry-list li.is-editing > .tool-split-entry {
grid-template-columns: auto minmax(0, 1fr) 34px 34px;
}
.calculator-entry > button {
.tool-split-entry > button {
min-width: 0;
min-height: 36px;
padding: 0 10px;
}
.calculator-entry-summary,
.calculator-entry-value {
.tool-split-entry-summary,
.tool-split-entry-value {
display: inline-flex;
box-sizing: border-box;
align-items: center;
justify-content: flex-start;
border: 1px solid rgba(165, 180, 252, 0.09);
@ -1391,15 +1947,17 @@ textarea:focus {
text-align: left;
}
.calculator-entry-summary {
.tool-split-entry-summary {
width: 100%;
min-height: 36px;
padding: 0 10px;
}
.calculator-entry-value {
.tool-split-entry-value {
width: fit-content;
}
.calculator-entry-value.is-readonly {
.tool-split-entry-value.is-readonly {
min-height: 36px;
padding: 0 10px;
background: rgba(21, 26, 48, 0.38);
@ -1407,25 +1965,26 @@ textarea:focus {
opacity: 0.72;
}
.calculator-entry-summary:hover,
.calculator-entry-value:not(.is-readonly):hover {
.tool-split-entry-summary:hover,
.tool-split-entry-value:not(.is-readonly):hover {
border-color: rgba(196, 181, 253, 0.2);
background: rgba(21, 26, 48, 0.72);
}
.calculator-entry-summary span {
.tool-split-entry-summary > span {
overflow: hidden;
color: var(--color-text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.calculator-entry-summary strong,
.calculator-entry-value strong {
.tool-split-entry-summary strong,
.tool-split-entry-value strong {
color: var(--color-text-primary);
}
.calculator-entry-label {
.tool-split-entry-label {
box-sizing: border-box;
width: 100%;
min-width: 0;
min-height: 36px;
@ -1438,26 +1997,26 @@ textarea:focus {
white-space: nowrap;
}
.calculator-entry-label:hover,
.calculator-entry-label:focus {
.tool-split-entry-label:hover,
.tool-split-entry-label:focus {
border-color: rgba(196, 181, 253, 0.22);
background: rgba(21, 26, 48, 0.58);
color: var(--color-text-primary);
}
.calculator-entry .ui-icon {
.tool-split-entry .ui-icon {
width: 16px;
height: 16px;
opacity: 0.72;
}
.calculator-entry > button:not(.calculator-entry-summary, .calculator-entry-value) {
.tool-split-entry > button:not(.tool-split-entry-summary, .tool-split-entry-value) {
min-height: 36px;
}
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-summary,
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-value,
.calculator-entry-list li.active > .calculator-entry > .calculator-entry-label {
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-summary,
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-value,
.tool-split-entry-list li.active > .tool-split-entry > .tool-split-entry-label {
border-color: var(--color-accent-gold);
background:
linear-gradient(135deg, rgba(246, 196, 83, 0.09), rgba(139, 92, 246, 0.1)),
@ -1602,7 +2161,7 @@ textarea:focus {
gap: var(--space-2);
}
.annotation-preview-actions .calculator-action-button {
.annotation-preview-actions .tool-split-action-button {
background: rgba(21, 26, 48, 0.94);
box-shadow:
0 6px 18px rgba(5, 7, 17, 0.28),