302 lines
12 KiB
JavaScript
302 lines
12 KiB
JavaScript
// Rôle : point d'entrée React, assemble données, routes, shell et overlays.
|
|
/* eslint-disable react-refresh/only-export-components -- point d'entrée Vite sans export de composant. */
|
|
import { lazy, StrictMode, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import "./styles/main.scss";
|
|
import { AppOverlays } from "./components/AppOverlays.jsx";
|
|
import { AppErrorBoundary } from "./components/AppErrorBoundary.jsx";
|
|
import { Shell } from "./components/Shell.jsx";
|
|
import { Icon } from "./components/Icon.jsx";
|
|
import { useIndexedToolboxes } from "./features/toolboxes/storage/useIndexedToolboxes.js";
|
|
import { useToolboxActions } from "./features/toolboxes/useToolboxActions.js";
|
|
import { useAppData } from "./hooks/useAppData.js";
|
|
import { GuideChecklistContext } from "./features/games/useGuideChecklist.js";
|
|
import { useGuideChecklistStorage } from "./hooks/useGuideChecklistStorage.js";
|
|
import { useGameFilters } from "./hooks/useGameFilters.js";
|
|
import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
|
|
import { RouteContent } from "./router/RouteContent.jsx";
|
|
import { useHashRoute } from "./router/hashRouter.js";
|
|
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
|
|
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
|
|
import { GlobalMemosTicker } from "./features/toolboxes/GlobalMemosTicker.jsx";
|
|
import { useMediaQuery } from "./hooks/useMediaQuery.js";
|
|
|
|
const assetRecoveryUrl = new URL(window.location.href);
|
|
if (assetRecoveryUrl.searchParams.has("_asset-recovery")) {
|
|
assetRecoveryUrl.searchParams.delete("_asset-recovery");
|
|
window.history.replaceState(
|
|
window.history.state,
|
|
"",
|
|
`${assetRecoveryUrl.pathname}${assetRecoveryUrl.search}${assetRecoveryUrl.hash}`
|
|
);
|
|
}
|
|
|
|
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
|
|
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
|
|
const SHORT_NOTIFICATION_DURATION_MS = 5000;
|
|
|
|
function getNotificationDuration(message, options = {}) {
|
|
if (options.persistent) return 0;
|
|
if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs));
|
|
const normalized = String(message || "").toLowerCase();
|
|
return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS;
|
|
}
|
|
|
|
function App() {
|
|
const route = useHashRoute();
|
|
const compactGameLayout = useMediaQuery("(max-width: 1439px)");
|
|
const reducedSidebar = useMediaQuery("(max-width: 1024px)");
|
|
const { siteContent, siteContentError, games, gamesError, mhwilds, diablo4, aniimo } = useAppData(route);
|
|
const [filters, setFilters] = useGameFilters();
|
|
const [confirmModal, setConfirmModal] = useState(null);
|
|
const [createModal, setCreateModal] = useState(null);
|
|
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
|
|
const [linkModalGameId, setLinkModalGameId] = useState("");
|
|
const [drawerGameId, setDrawerGameId] = useState("");
|
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
|
const [drawerContext, setDrawerContext] = useState({ route, compactGameLayout });
|
|
const [image, setImage] = useState(null);
|
|
const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
|
|
const [notifications, setNotifications] = useState([]);
|
|
const [storageError, setStorageError] = useState("");
|
|
const backupReminderIdRef = useRef("");
|
|
const handleStorageError = useCallback((message) => setStorageError(message), []);
|
|
const store = useIndexedToolboxes(handleStorageError);
|
|
const autoBackup = useAutoBackup(store);
|
|
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
|
|
const guideChecklistStorage = useGuideChecklistStorage(store);
|
|
const guideScrollRef = useRef(0);
|
|
|
|
const setGameToolbox = useCallback((gameId) => {
|
|
if (gameId) {
|
|
if (compactGameLayout && !drawerVisible) guideScrollRef.current = window.scrollY;
|
|
setDrawerGameId(gameId);
|
|
setDrawerVisible(true);
|
|
if (compactGameLayout) window.requestAnimationFrame(() => window.scrollTo({ top: 0, behavior: "auto" }));
|
|
return;
|
|
}
|
|
setDrawerVisible(false);
|
|
if (compactGameLayout) {
|
|
window.requestAnimationFrame(() => window.scrollTo({ top: guideScrollRef.current, behavior: "auto" }));
|
|
} else {
|
|
setDrawerGameId("");
|
|
}
|
|
}, [compactGameLayout, drawerVisible]);
|
|
|
|
if (drawerContext.route !== route || drawerContext.compactGameLayout !== compactGameLayout) {
|
|
setDrawerContext({ route, compactGameLayout });
|
|
const activeGameId = route.match(/^\/games\/([^/]+)/)?.[1] || "";
|
|
if (activeGameId !== drawerGameId) {
|
|
setDrawerGameId("");
|
|
setDrawerVisible(false);
|
|
} else if (!compactGameLayout && !drawerVisible && drawerGameId) {
|
|
setDrawerGameId("");
|
|
}
|
|
}
|
|
|
|
const notify = useCallback((message, options = {}) => {
|
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
setNotifications((current) => [
|
|
{
|
|
id,
|
|
message,
|
|
durationMs: getNotificationDuration(message, options),
|
|
actionLabel: options.actionLabel,
|
|
onAction: options.onAction
|
|
},
|
|
...current
|
|
].slice(0, 5));
|
|
return id;
|
|
}, []);
|
|
|
|
const dismissNotification = useCallback((notificationId) => {
|
|
setNotifications((current) => current.filter((notification) => notification.id !== notificationId));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
function handleNotification(event) {
|
|
if (event.detail?.message) notify(event.detail.message, event.detail);
|
|
}
|
|
|
|
window.addEventListener("sokkog:notify", handleNotification);
|
|
return () => window.removeEventListener("sokkog:notify", handleNotification);
|
|
}, [notify]);
|
|
|
|
useEffect(() => {
|
|
if (!backupNeeded) {
|
|
if (backupReminderIdRef.current) dismissNotification(backupReminderIdRef.current);
|
|
backupReminderIdRef.current = "";
|
|
return;
|
|
}
|
|
if (backupReminderIdRef.current || !siteContent?.toolboxes?.autoBackup) return;
|
|
const content = siteContent.toolboxes.autoBackup;
|
|
backupReminderIdRef.current = notify(content.reminderMessage, {
|
|
persistent: true,
|
|
actionLabel: content.reminderAction,
|
|
onAction: () => void requestBackupPermission()
|
|
});
|
|
}, [backupNeeded, dismissNotification, notify, requestBackupPermission, siteContent]);
|
|
|
|
const t = (key, { capitalize = false } = {}) => {
|
|
const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " ");
|
|
return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;
|
|
};
|
|
const getGame = (gameId) => games.find((item) => item.id === gameId);
|
|
const getToolboxGameId = (toolbox) => Object.entries(store.links).find(([, toolboxId]) => toolboxId === toolbox?.id)?.[0] || "";
|
|
const getToolboxGame = (toolbox) => getGame(getToolboxGameId(toolbox));
|
|
|
|
const actions = useToolboxActions({
|
|
store,
|
|
notify,
|
|
creationTemplate: siteContent?.toolboxes?.creationTemplate,
|
|
setCreateModal,
|
|
setConfirmModal,
|
|
setLinkModalGameId,
|
|
setDrawerGameId: setGameToolbox,
|
|
setImage
|
|
});
|
|
|
|
useToolboxAlerts({
|
|
toolboxes: store.toolboxes,
|
|
moduleData: store.moduleData,
|
|
links: store.links,
|
|
route,
|
|
drawerGameId: drawerVisible ? drawerGameId : "",
|
|
normalizeTimerData,
|
|
normalizeCalendarData,
|
|
textContent: siteContent?.toolboxes?.modules,
|
|
notify
|
|
});
|
|
|
|
if (!siteContent) {
|
|
return (
|
|
<main className="startup-error">
|
|
<section className="empty nds-empty-state">
|
|
<h1>Contenu indisponible</h1>
|
|
<p>{siteContentError || "Chargement du contenu..."}</p>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (!store.ready) {
|
|
const content = siteContent.toolboxes.storageStatus;
|
|
return (
|
|
<main className="startup-error">
|
|
<section className="empty nds-empty-state" aria-busy={!store.loadError}>
|
|
<h1>{store.loadError ? content.errorTitle : content.loadingTitle}</h1>
|
|
<p>{store.loadError ? content.errorMessage : content.loadingMessage}</p>
|
|
{store.loadError && <>
|
|
<p role="alert">{store.loadError}</p>
|
|
<button className="nds-button" data-size="sm" data-variant="primary" type="button" onClick={store.retryLoad}>
|
|
<Icon name="refresh" />{content.retryLabel}
|
|
</button>
|
|
</>}
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<GuideChecklistContext.Provider value={{ route, aniimo, mhwilds, diablo4, siteContent, toolboxes: store.toolboxes, links: store.links, moduleData: store.moduleData, guideChecklistStorage, actions }}>
|
|
<Shell
|
|
route={route}
|
|
content={siteContent}
|
|
games={games}
|
|
drawerGameId={drawerGameId}
|
|
drawerVisible={drawerVisible}
|
|
compactGameLayout={compactGameLayout}
|
|
reducedSidebar={reducedSidebar}
|
|
actions={actions}
|
|
>
|
|
<div className="route-content-view" aria-hidden={compactGameLayout && drawerVisible || undefined} inert={compactGameLayout && drawerVisible || undefined}>
|
|
<AppErrorBoundary route={route}>
|
|
<RouteContent
|
|
route={route}
|
|
siteContent={siteContent}
|
|
games={games}
|
|
gamesError={gamesError}
|
|
mhwilds={mhwilds}
|
|
diablo4={diablo4}
|
|
aniimo={aniimo}
|
|
filters={filters}
|
|
setFilters={setFilters}
|
|
t={t}
|
|
toolboxes={store.toolboxes}
|
|
links={store.links}
|
|
getGame={getGame}
|
|
getToolboxGame={getToolboxGame}
|
|
actions={actions}
|
|
storageUsage={store.storageUsage}
|
|
storageReady={store.ready}
|
|
toastPosition={store.toastPosition}
|
|
setToastPosition={store.setToastPosition}
|
|
onOpenAutoBackup={() => setAutoBackupModalOpen(true)}
|
|
getModuleData={store.getModuleData}
|
|
updateToolbox={actions.updateToolbox}
|
|
updateModuleData={store.updateModuleData}
|
|
addImageFiles={actions.addImageFiles}
|
|
pendingTutorialToolboxId={pendingTutorialToolboxId}
|
|
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
|
|
/>
|
|
</AppErrorBoundary>
|
|
</div>
|
|
{drawerGameId && (
|
|
<Suspense fallback={null}>
|
|
<ToolboxDrawer
|
|
gameId={drawerGameId}
|
|
game={getGame(drawerGameId)}
|
|
siteContent={siteContent}
|
|
toolboxes={store.toolboxes}
|
|
links={store.links}
|
|
actions={actions}
|
|
storageUsage={store.storageUsage}
|
|
storageReady={store.ready}
|
|
getModuleData={store.getModuleData}
|
|
updateToolbox={actions.updateToolbox}
|
|
updateModuleData={store.updateModuleData}
|
|
addImageFiles={actions.addImageFiles}
|
|
visible={drawerVisible}
|
|
compact={compactGameLayout}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
<GlobalMemosTicker
|
|
route={route}
|
|
toolboxes={store.toolboxes}
|
|
links={store.links}
|
|
moduleData={store.moduleData}
|
|
textContent={siteContent.toolboxes.modules.memos}
|
|
/>
|
|
<AppOverlays
|
|
storageErrorTitle={siteContent.toolboxes.storageStatus.writeErrorTitle}
|
|
actions={actions}
|
|
confirmModal={confirmModal}
|
|
setConfirmModal={setConfirmModal}
|
|
createModal={createModal}
|
|
setCreateModal={setCreateModal}
|
|
linkModalGameId={linkModalGameId}
|
|
setLinkModalGameId={setLinkModalGameId}
|
|
setDrawerGameId={setGameToolbox}
|
|
storageError={storageError}
|
|
setStorageError={setStorageError}
|
|
image={image}
|
|
setImage={setImage}
|
|
notifications={notifications}
|
|
dismissNotification={dismissNotification}
|
|
toastPosition={store.toastPosition}
|
|
getGame={getGame}
|
|
toolboxes={store.toolboxes}
|
|
links={store.links}
|
|
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
|
|
autoBackupModalOpen={autoBackupModalOpen}
|
|
setAutoBackupModalOpen={setAutoBackupModalOpen}
|
|
autoBackup={autoBackup}
|
|
autoBackupContent={siteContent.toolboxes.autoBackup}
|
|
/>
|
|
</Shell>
|
|
</GuideChecklistContext.Provider>
|
|
);
|
|
}
|
|
|
|
createRoot(document.querySelector("#app")).render(<StrictMode><App /></StrictMode>);
|