new tool timer & alert revamp
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 6s
This commit is contained in:
parent
0c7ae46b4d
commit
68005ef918
38 changed files with 2048 additions and 111 deletions
|
|
@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
25
website/src/components/Tabs.jsx
Normal file
25
website/src/components/Tabs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
website/src/components/ToastPositionSwitch.jsx
Normal file
20
website/src/components/ToastPositionSwitch.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
572
website/src/features/toolboxes/modules/TimerModule.jsx
Normal file
572
website/src/features/toolboxes/modules/TimerModule.jsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
};
|
||||
|
||||
|
|
|
|||
108
website/src/features/toolboxes/modules/timerUtils.js
Normal file
108
website/src/features/toolboxes/modules/timerUtils.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
63
website/src/hooks/useTimerAlerts.js
Normal file
63
website/src/hooks/useTimerAlerts.js
Normal 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]);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@
|
|||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
z-index: 80;
|
||||
display: flex;
|
||||
min-height: 68px;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue