refactor: split hooks types and utils by domain

This commit is contained in:
Tom Boullay
2026-04-30 11:49:18 +02:00
parent 9ac5844182
commit b1187b68ae
65 changed files with 83 additions and 84 deletions
+71
View File
@@ -0,0 +1,71 @@
type Listener<TPayload> = (payload: TPayload) => void;
type ListenerMap<TEvents extends Record<string, unknown>> = {
[TKey in keyof TEvents]?: Set<Listener<TEvents[TKey]>>;
};
function getListeners<
TEvents extends Record<string, unknown>,
TKey extends keyof TEvents,
>(
map: ListenerMap<TEvents>,
key: TKey,
): Set<Listener<TEvents[TKey]>> | undefined {
return map[key] as Set<Listener<TEvents[TKey]>> | undefined;
}
export class EventEmitter<TEvents extends Record<string, unknown>> {
private readonly listeners: ListenerMap<TEvents> = {};
on<TKey extends keyof TEvents>(
event: TKey,
listener: Listener<TEvents[TKey]>,
): () => void {
const existing = getListeners(this.listeners, event);
if (existing) {
existing.add(listener);
} else {
this.listeners[event] = new Set([listener]) as ListenerMap<TEvents>[TKey];
}
return () => {
this.off(event, listener);
};
}
off<TKey extends keyof TEvents>(
event: TKey,
listener: Listener<TEvents[TKey]>,
): void {
const currentListeners = getListeners(this.listeners, event);
if (!currentListeners) {
return;
}
currentListeners.delete(listener);
if (currentListeners.size === 0) {
delete this.listeners[event];
}
}
emit<TKey extends keyof TEvents>(event: TKey, payload: TEvents[TKey]): void {
const currentListeners = getListeners(this.listeners, event);
if (!currentListeners) {
return;
}
currentListeners.forEach((listener) => {
listener(payload);
});
}
clear(): void {
for (const key of Object.keys(this.listeners) as (keyof TEvents)[]) {
delete this.listeners[key];
}
}
}
+50
View File
@@ -0,0 +1,50 @@
type SizeSnapshot = {
width: number;
height: number;
pixelRatio: number;
};
type SizeListener = (snapshot: SizeSnapshot) => void;
export class Sizes {
private snapshot: SizeSnapshot;
private readonly listeners = new Set<SizeListener>();
private readonly handleResize = (): void => {
this.snapshot = Sizes.readWindow();
this.emit();
};
constructor() {
this.snapshot = Sizes.readWindow();
window.addEventListener("resize", this.handleResize);
}
subscribe(listener: SizeListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getSnapshot(): SizeSnapshot {
return this.snapshot;
}
destroy(): void {
window.removeEventListener("resize", this.handleResize);
this.listeners.clear();
}
private emit(): void {
this.listeners.forEach((listener) => listener(this.snapshot));
}
private static readWindow(): SizeSnapshot {
return {
width: window.innerWidth,
height: window.innerHeight,
pixelRatio: Math.min(window.devicePixelRatio, 2),
};
}
}
+42
View File
@@ -0,0 +1,42 @@
type TickListener = (delta: number, elapsed: number) => void;
export class Time {
private readonly listeners = new Set<TickListener>();
private animationFrameId = 0;
private lastTick = performance.now();
private elapsed = 0;
constructor() {
this.tick = this.tick.bind(this);
this.animationFrameId = window.requestAnimationFrame(this.tick);
}
subscribe(listener: TickListener): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
getElapsed(): number {
return this.elapsed;
}
destroy(): void {
window.cancelAnimationFrame(this.animationFrameId);
this.listeners.clear();
}
private tick(now: number): void {
const delta = (now - this.lastTick) / 1000;
this.lastTick = now;
this.elapsed += delta;
this.listeners.forEach((listener) => {
listener(delta, this.elapsed);
});
this.animationFrameId = window.requestAnimationFrame(this.tick);
}
}
+111
View File
@@ -0,0 +1,111 @@
import type {
LogContext,
LogEntry,
LogLevel,
LoggerConfig,
} from "@/types/logger/logger";
import { isDebugEnabled } from "@/utils/debug/isDebugEnabled";
const LEVEL_PRIORITY: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
const LEVEL_LABELS: Record<LogLevel, string> = {
debug: "DEBUG",
info: "INFO",
warn: "WARN",
error: "ERROR",
};
const LEVEL_STYLES: Record<LogLevel, string> = {
debug: "color: #94a3b8; font-weight: 600;",
info: "color: #60a5fa; font-weight: 600;",
warn: "color: #f59e0b; font-weight: 600;",
error: "color: #f87171; font-weight: 600;",
};
const SCOPE_STYLE = "color: #e5e7eb; font-weight: 600;";
const MESSAGE_STYLE = "color: inherit;";
class Logger {
private readonly config: LoggerConfig;
constructor(config: LoggerConfig) {
this.config = config;
}
debug(scope: string, message: string, context?: LogContext): void {
this.log("debug", scope, message, context);
}
info(scope: string, message: string, context?: LogContext): void {
this.log("info", scope, message, context);
}
warn(scope: string, message: string, context?: LogContext): void {
this.log("warn", scope, message, context);
}
error(scope: string, message: string, context?: LogContext): void {
this.log("error", scope, message, context);
}
private log(
level: LogLevel,
scope: string,
message: string,
context?: LogContext,
): void {
if (!this.shouldLog(level)) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
scope,
message,
...(context ? { context } : {}),
};
const formattedMessage = `%c[${LEVEL_LABELS[level]}]%c [${scope}]%c ${message}`;
const args = [
formattedMessage,
LEVEL_STYLES[level],
SCOPE_STYLE,
MESSAGE_STYLE,
entry,
] as const;
switch (level) {
case "debug":
console.debug(...args);
return;
case "info":
console.info(...args);
return;
case "warn":
console.warn(...args);
return;
case "error":
console.error(...args);
}
}
private shouldLog(level: LogLevel): boolean {
return LEVEL_PRIORITY[level] >= LEVEL_PRIORITY[this.config.minLevel];
}
}
function resolveMinLevel(): LogLevel {
if (typeof window === "undefined") {
return "info";
}
return isDebugEnabled() ? "debug" : "info";
}
export const logger = new Logger({
minLevel: resolveMinLevel(),
});