Add monorepo support to project (#682)

* feat: add monorepo support

* fix: correct relatedProjects structure in vercel.json

* update github actions with monorepo support

* removed legacy dep

* moved jsr config into web package
This commit is contained in:
Dan Ditomaso
2025-06-25 21:56:55 -04:00
committed by GitHub
parent 85ee0747d1
commit 1c13343e8a
463 changed files with 7568 additions and 2196 deletions
@@ -0,0 +1,33 @@
import { useMemo } from "react";
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context";
interface BrowserSupport {
supported: BrowserFeature[];
unsupported: BrowserFeature[];
}
export function useBrowserFeatureDetection(): BrowserSupport {
const support = useMemo(() => {
const features: [BrowserFeature, boolean][] = [
["Web Bluetooth", !!navigator.bluetooth],
["Web Serial", !!navigator.serial],
[
"Secure Context",
globalThis.location.protocol === "https:" ||
globalThis.location.hostname === "localhost",
],
];
return features.reduce<BrowserSupport>(
(acc, [feature, isSupported]) => {
const list = isSupported ? acc.supported : acc.unsupported;
list.push(feature);
return acc;
},
{ supported: [], unsupported: [] },
);
}, []);
return support;
}
+52
View File
@@ -0,0 +1,52 @@
import Cookies from "js-cookie";
import { useCallback, useState } from "react";
interface CookieHookResult<T> {
value: T | undefined;
setCookie: (value: T, options?: Cookies.CookieAttributes) => void;
removeCookie: () => void;
}
function useCookie<T extends object>(
cookieName: string,
initialValue?: T,
): CookieHookResult<T> {
const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
try {
const cookie = Cookies.get(cookieName);
return cookie ? (JSON.parse(cookie) as T) : initialValue;
} catch (error) {
console.error(`Error parsing cookie ${cookieName}:`, error);
return initialValue;
}
});
const setCookie = useCallback(
(value: T, options?: Cookies.CookieAttributes) => {
try {
Cookies.set(cookieName, JSON.stringify(value), options);
setCookieValue(value);
} catch (error) {
console.error(`Error setting cookie ${cookieName}:`, error);
}
},
[cookieName],
);
const removeCookie = useCallback(() => {
try {
Cookies.remove(cookieName);
setCookieValue(undefined);
} catch (error) {
console.error(`Error removing cookie ${cookieName}:`, error);
}
}, [cookieName]);
return {
value: cookieValue,
setCookie,
removeCookie,
};
}
export default useCookie;
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseCopyToClipboardProps {
timeout?: number;
}
export function useCopyToClipboard(
{ timeout = 2000 }: UseCopyToClipboardProps = {},
) {
const [isCopied, setIsCopied] = useState<boolean>(false);
const timeoutRef = useRef<number | null>(null);
useEffect(() => {
return () => {
if (timeoutRef.current) {
globalThis.clearTimeout(timeoutRef.current);
}
};
}, []);
const copy = useCallback(
async (text: string) => {
if (!navigator?.clipboard) {
console.warn("Clipboard API not available");
setIsCopied(false);
return false;
}
if (timeoutRef.current) {
globalThis.clearTimeout(timeoutRef.current);
}
try {
await navigator.clipboard.writeText(text);
setIsCopied(true);
timeoutRef.current = globalThis.setTimeout(() => {
setIsCopied(false);
timeoutRef.current = null;
}, timeout);
return true;
} catch (error) {
console.error("Failed to copy text to clipboard:", error);
setIsCopied(false);
return false;
}
},
[timeout],
);
return { isCopied, copy };
}
@@ -0,0 +1,93 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useFavoriteNode } from "./useFavoriteNode.ts";
import { Protobuf } from "@meshtastic/core";
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
},
isFavorite: true,
} as unknown | Protobuf.Mesh.NodeInfo;
const mockUpdateFavorite = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({
updateFavorite: mockUpdateFavorite,
getNode: mockGetNode,
}),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
describe("useFavoriteNode hook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls updateFavorite and shows correct toast", () => {
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 1234, isFavorite: true });
});
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to favorites.",
});
});
it("handles removal case", () => {
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 1234, isFavorite: false });
});
expect(mockUpdateFavorite).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from favorites.",
});
});
it("falls back to 'node' if longName is missing", () => {
mockGetNode.mockReturnValueOnce({
num: 5678,
user: {},
}); // no longName
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 5678, isFavorite: true });
});
expect(mockToast).toHaveBeenCalledWith({
title: "Added Node to favorites.",
});
});
it("falls back to 'node' if getNode returns undefined", () => {
mockGetNode.mockReturnValueOnce(undefined);
const { result } = renderHook(() => useFavoriteNode());
act(() => {
result.current.updateFavorite({ nodeNum: 9999, isFavorite: false });
});
expect(mockUpdateFavorite).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,39 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useTranslation } from "react-i18next";
interface FavoriteNodeOptions {
nodeNum: number;
isFavorite: boolean;
}
export function useFavoriteNode() {
const { updateFavorite, getNode } = useDevice();
const { t } = useTranslation();
const { toast } = useToast();
const updateFavoriteCB = useCallback(
({ nodeNum, isFavorite }: FavoriteNodeOptions) => {
const node = getNode(nodeNum);
if (!node) return;
updateFavorite(nodeNum, isFavorite);
toast({
title: t("toast.favoriteNode.title", {
action: isFavorite
? t("toast.favoriteNode.action.added")
: t("toast.favoriteNode.action.removed"),
nodeName: node?.user?.longName ?? t("node"),
direction: isFavorite
? t("toast.favoriteNode.action.to")
: t("toast.favoriteNode.action.from"),
}),
});
},
[updateFavorite, getNode],
);
return { updateFavorite: updateFavoriteCB };
}
@@ -0,0 +1,96 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useIgnoreNode } from "./useIgnoreNode.ts";
import { Protobuf } from "@meshtastic/core";
const mockNode = {
num: 1234,
user: {
longName: "Test Node",
},
isIgnored: true,
} as unknown | Protobuf.Mesh.NodeInfo;
const mockUpdateIgnore = vi.fn();
const mockGetNode = vi.fn(() => mockNode);
const mockToast = vi.fn();
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: () => ({
updateIgnored: mockUpdateIgnore,
getNode: mockGetNode,
}),
}));
vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({
toast: mockToast,
}),
}));
describe("useIgnoreNode hook", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("calls updateIgnored and shows correct toast", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 1234, isIgnored: true });
});
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, true);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Added Test Node to ignore list",
});
});
it("handles removal case", () => {
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({
nodeNum: 1234,
isIgnored: false,
});
});
expect(mockUpdateIgnore).toHaveBeenCalledWith(1234, false);
expect(mockGetNode).toHaveBeenCalledWith(1234);
expect(mockToast).toHaveBeenCalledWith({
title: "Removed Test Node from ignore list",
});
});
it("falls back to 'node' if longName is missing", () => {
mockGetNode.mockReturnValueOnce({
num: 5678,
user: {},
}); // no longName
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 5678, isIgnored: true });
});
expect(mockToast).toHaveBeenCalledWith({
title: "Added node to ignore list",
});
});
it("falls back to 'node' if getNode returns undefined", () => {
mockGetNode.mockReturnValueOnce(undefined);
const { result } = renderHook(() => useIgnoreNode());
act(() => {
result.current.updateIgnored({ nodeNum: 9999, isIgnored: false });
});
expect(mockUpdateIgnore).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,40 @@
import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useTranslation } from "react-i18next";
interface IgnoreNodeOptions {
nodeNum: number;
isIgnored: boolean;
}
export function useIgnoreNode() {
const { updateIgnored, getNode } = useDevice();
const { t } = useTranslation();
const { toast } = useToast();
const updateIgnoredCB = useCallback(
({ nodeNum, isIgnored }: IgnoreNodeOptions) => {
const node = getNode(nodeNum);
if (!node) return;
updateIgnored(nodeNum, isIgnored);
toast({
title: t("toast.ignoreNode.title", {
nodeName: node?.user?.longName ?? "node",
action: isIgnored
? t("toast.ignoreNode.action.added")
: t("toast.ignoreNode.action.removed"),
direction: isIgnored
? t("toast.ignoreNode.action.to")
: t("toast.ignoreNode.action.from"),
}),
});
},
[updateIgnored, getNode],
);
return { updateIgnored: updateIgnoredCB };
}
@@ -0,0 +1,120 @@
import { Button } from "@components/UI/Button.tsx";
import { useCallback, useEffect, useRef } from "react";
import { useToast } from "@core/hooks/useToast.ts";
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useTranslation } from "react-i18next";
interface UseBackupReminderOptions {
reminderInDays?: number;
message: string;
onAccept?: () => void | Promise<void>;
enabled: boolean;
}
interface ReminderState {
expires: string;
}
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
const TOAST_DURATION = 30_000; // 30 seconds
const REMINDER_DAYS_ONE_WEEK = 7;
const REMINDER_DAYS_ONE_YEAR = 365;
const REMINDER_DAYS_FOREVER = 3650;
const STORAGE_KEY = "key_backup_reminder";
function isReminderExpired(expires?: string): boolean {
if (!expires) return true;
const expiryDate = new Date(expires);
if (isNaN(expiryDate.getTime())) return true; // Invalid date passed
const now = new Date();
return now.getTime() >= expiryDate.getTime();
}
export function useBackupReminder({
enabled,
message,
onAccept = () => {},
reminderInDays = REMINDER_DAYS_ONE_WEEK,
}: UseBackupReminderOptions) {
const { t } = useTranslation("dialog");
const { toast } = useToast();
const toastShownRef = useRef(false);
const [reminderState, setReminderState] = useLocalStorage<
ReminderState | null
>(
STORAGE_KEY,
null,
);
const setReminderExpiry = useCallback((days: number) => {
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + days);
setReminderState({ expires: expiryDate.toISOString() });
}, [setReminderState]);
useEffect(() => {
if (!enabled || toastShownRef.current) return;
if (!isReminderExpired(reminderState?.expires)) return;
toastShownRef.current = true;
const { dismiss } = toast({
title: t("pkiBackupReminder.title"),
duration: TOAST_DURATION,
delay: TOAST_APPEAR_DELAY,
description: message,
action: (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Button
type="button"
variant="outline"
className="p-1"
onClick={() => {
dismiss();
setReminderExpiry(reminderInDays);
}}
>
{t("pkiBackupReminder.remindLaterPrefix")} {reminderInDays}{" "}
{reminderInDays > 1
? t("common:unit.day.plural").toLowerCase()
: t("common:unit.day.one").toLowerCase()}
</Button>
<Button
type="button"
variant="outline"
className="p-1"
onClick={() => {
dismiss();
setReminderExpiry(REMINDER_DAYS_FOREVER);
}}
>
{t("pkiBackupReminder.remindNever")}
</Button>
</div>
<Button
type="button"
variant="default"
className="w-full"
onClick={() => {
onAccept();
dismiss();
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
}}
>
{t("pkiBackupReminder.backupNow")}
</Button>
</div>
),
});
return () => dismiss();
}, [
enabled,
message,
onAccept,
]);
}
+63
View File
@@ -0,0 +1,63 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
FALLBACK_LANGUAGE_CODE,
Lang,
LangCode,
supportedLanguages,
} from "@app/i18n-config.ts";
import useLocalStorage from "./useLocalStorage.ts";
const STORAGE_KEY = "language";
type LanguageState = {
language: LangCode;
};
function useLang() {
const { i18n } = useTranslation();
const [_, setLanguageInStorage] = useLocalStorage<LanguageState | null>(
STORAGE_KEY,
null,
);
const currentLanguage = useMemo((): Lang | undefined => {
const lang = supportedLanguages.find((l) => l.code === i18n.language);
if (lang) {
return lang;
}
return supportedLanguages.find((l) => l.code === FALLBACK_LANGUAGE_CODE);
}, [i18n.language]);
const collator = useMemo(() => {
return new Intl.Collator(i18n.language, { sensitivity: "base" });
}, [i18n.language]);
const set = useCallback(
async (lng: LangCode, persist = true) => {
if (i18n.language === lng) {
return;
}
try {
if (persist) {
setLanguageInStorage({ language: lng });
}
await i18n.changeLanguage(lng);
} catch (e) {
console.warn("Failed to change language:", e);
}
},
[i18n, setLanguageInStorage],
);
const compare = useCallback(
(a: string, b: string) => {
return collator.compare(a, b);
},
[collator],
);
return { compare, set, currentLanguage };
}
export default useLang;
@@ -0,0 +1,52 @@
import { act, renderHook } from "@testing-library/react";
import useLocalStorage from "./useLocalStorage.ts";
import { beforeEach, describe, expect, it } from "vitest";
describe("useLocalStorage", () => {
const key = "test-key";
beforeEach(() => {
localStorage.clear();
});
it("should initialize with initial value if localStorage is empty", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [value] = result.current;
expect(value).toBe("initial");
});
it("should read existing value from localStorage", () => {
localStorage.setItem(key, JSON.stringify("stored"));
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [value] = result.current;
expect(value).toBe("stored");
});
it("should update localStorage when setValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue] = result.current;
act(() => {
setValue("updated");
});
expect(localStorage.getItem(key)).toBe(JSON.stringify("updated"));
expect(result.current[0]).toBe("updated");
});
it("should remove value from localStorage when removeValue is called", () => {
const { result } = renderHook(() => useLocalStorage(key, "initial"));
const [, setValue, removeValue] = result.current;
act(() => {
setValue("to-be-removed");
});
act(() => {
removeValue();
});
expect(localStorage.getItem(key)).toBeNull();
expect(result.current[0]).toBe("initial");
});
});
@@ -0,0 +1,182 @@
// taken from https://react-hooked.vercel.app/docs/useLocalStorage/
import { useCallback, useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface WindowEventMap {
"local-storage": CustomEvent;
}
}
type UseLocalStorageOptions<T> = {
serializer?: (value: T) => string;
deserializer?: (value: string) => T;
initializeWithValue?: boolean;
};
const IS_SERVER = typeof window === "undefined";
/**
* Hook for persisting state to localStorage.
*
* @param {string} key - The key to use for localStorage.
* @param {T | (() => T)} initialValue - The initial value to use, if not found in localStorage.
* @param {UseLocalStorageOptions<T>} options - Options for the hook.
* @returns A tuple of [storedValue, setValue, removeValue].
*/
export default function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options: UseLocalStorageOptions<T> = {},
): [T, Dispatch<SetStateAction<T>>, () => void] {
const { initializeWithValue = true } = options;
const serializer = useCallback<(value: T) => string>(
(value) => {
if (options.serializer) {
return options.serializer(value);
}
return JSON.stringify(value);
},
[options],
);
const deserializer = useCallback<(value: string) => T>(
(value) => {
if (options.deserializer) {
return options.deserializer(value);
}
// Support 'undefined' as a value
if (value === "undefined") {
return undefined as unknown as T;
}
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch (error) {
console.error("Error parsing JSON:", error);
return defaultValue; // Return initialValue if parsing fails
}
return parsed as T;
},
[options, initialValue],
);
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
const initialValueToUse = initialValue instanceof Function
? initialValue()
: initialValue;
// Prevent build error "window is undefined" but keep working
if (IS_SERVER) {
return initialValueToUse;
}
try {
const raw = globalThis.localStorage.getItem(key);
return raw ? deserializer(raw) : initialValueToUse;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValueToUse;
}
}, [initialValue, key, deserializer]);
const [storedValue, setStoredValue] = useState(() => {
if (initializeWithValue) {
return readValue();
}
return initialValue instanceof Function ? initialValue() : initialValue;
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(value) => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried setting localStorage key “${key}” even though environment is not a client`,
);
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(readValue()) : value;
// Save to local storage
globalThis.localStorage.setItem(key, serializer(newValue));
// Save state
setStoredValue(newValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
},
[key, serializer, readValue],
);
const removeValue = useCallback(() => {
// Prevent build error "window is undefined" but keeps working
if (IS_SERVER) {
console.warn(
`Tried removing localStorage key “${key}” even though environment is not a client`,
);
}
const defaultValue = initialValue instanceof Function
? initialValue()
: initialValue;
// Remove the key from local storage
globalThis.localStorage.removeItem(key);
// Save state with default value
setStoredValue(defaultValue);
// We dispatch a custom event so every similar useLocalStorage hook is notified
globalThis.dispatchEvent(new StorageEvent("local-storage", { key }));
}, [key]);
useEffect(() => {
setStoredValue(readValue());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
const handleStorageChange = useCallback(
(event: StorageEvent | CustomEvent) => {
if ((event as StorageEvent).key && (event as StorageEvent).key !== key) {
return;
}
setStoredValue(readValue());
},
[key, readValue],
);
useEffect(() => {
addEventListener("storage", handleStorageChange);
// this is a custom event, triggered in writeValueToLocalStorage
addEventListener("local-storage", handleStorageChange);
return () => {
removeEventListener("storage", handleStorageChange);
removeEventListener("local-storage", handleStorageChange);
};
}, []);
return [storedValue, setValue, removeValue];
}
@@ -0,0 +1,68 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { usePasswordVisibilityToggle } from "./usePasswordVisibilityToggle.ts";
describe("usePasswordVisibilityToggle Hook", () => {
it("should initialize with visibility set to false by default", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
expect(typeof result.current.toggleVisibility).toBe("function");
});
it("should initialize with visibility set to true if initialVisible is true", () => {
const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true })
);
expect(result.current.isVisible).toBe(true);
});
it("should toggle visibility from false to true when toggleVisibility is called", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
});
it("should toggle visibility from true to false when toggleVisibility is called", () => {
const { result } = renderHook(() =>
usePasswordVisibilityToggle({ initialVisible: true })
);
expect(result.current.isVisible).toBe(true);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(false);
});
it("should toggle visibility correctly multiple times", () => {
const { result } = renderHook(() => usePasswordVisibilityToggle());
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(false);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
});
it("should return a stable toggleVisibility function reference (due to useCallback)", () => {
const { result, rerender } = renderHook(() =>
usePasswordVisibilityToggle()
);
const initialToggleFunc = result.current.toggleVisibility;
rerender();
expect(result.current.toggleVisibility).toBe(initialToggleFunc);
act(() => {
result.current.toggleVisibility();
});
expect(result.current.isVisible).toBe(true);
});
});
@@ -0,0 +1,22 @@
import { useCallback, useState } from "react";
interface UsePasswordVisibilityToggleProps {
initialVisible?: boolean;
}
/**
* Manages the state for toggling password visibility.
*
* @param {boolean} [options.initialVisible=false]
* @returns {{isVisible: boolean, toggleVisibility: () => void}}
*/
export function usePasswordVisibilityToggle(
{ initialVisible = false }: UsePasswordVisibilityToggleProps = {},
) {
const [isVisible, setIsVisible] = useState<boolean>(initialVisible);
const toggleVisibility = useCallback(() => {
setIsVisible((prev) => !prev);
}, []);
return { isVisible, toggleVisibility };
}
@@ -0,0 +1,20 @@
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
import { useCallback } from "react";
export function usePinnedItems({ storageName }: { storageName: string }) {
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(
storageName,
[],
);
const togglePinnedItem = useCallback((label: string) => {
setPinnedItems((prev) =>
prev.includes(label) ? prev.filter((g) => g !== label) : [...prev, label]
);
}, []);
return {
pinnedItems,
togglePinnedItem,
};
}
@@ -0,0 +1,149 @@
import { useCallback, useMemo, useState } from "react";
export const FLAGS_CONFIG = {
UNSET: { value: 0, i18nKey: "position.flags.unset" },
ALTITUDE: { value: 1, i18nKey: "position.flags.altitude" },
ALTITUDE_MSL: { value: 2, i18nKey: "position.flags.altitudeMsl" },
ALTITUDE_GEOIDAL_SEPARATION: {
value: 4,
i18nKey: "position.flags.altitudeGeoidalSeparation",
},
DOP: {
value: 8,
i18nKey: "position.flags.dop",
},
HDOP_VDOP: {
value: 16,
i18nKey: "position.flags.hdopVdop",
},
NUM_SATELLITES: {
value: 32,
i18nKey: "position.flags.numSatellites",
},
SEQUENCE_NUMBER: {
value: 64,
i18nKey: "position.flags.sequenceNumber",
},
TIMESTAMP: { value: 128, i18nKey: "position.flags.timestamp" },
VEHICLE_HEADING: {
value: 256,
i18nKey: "position.flags.vehicleHeading",
},
VEHICLE_SPEED: { value: 512, i18nKey: "position.flags.vehicleSpeed" },
} as const;
export type FlagName = keyof typeof FLAGS_CONFIG;
type UsePositionFlagsProps = {
decode: (value: number) => FlagName[];
encode: (flagNames: FlagName[]) => number;
hasFlag: (value: number, flagName: FlagName) => boolean;
getAllFlags: () => typeof FLAGS_CONFIG;
isValidValue: (value: number) => boolean;
flagsValue: number;
activeFlags: FlagName[];
toggleFlag: (flagName: FlagName) => void;
setFlag: (flagName: FlagName, enabled: boolean) => void;
setFlags: (value: number) => void;
clearFlags: () => void;
};
export const usePositionFlags = (initialValue = 0): UsePositionFlagsProps => {
const [flagsValue, setFlagsValue] = useState<number>(initialValue);
const FLAGS_BITMASKS = useMemo(() => {
return Object.fromEntries(
Object.entries(FLAGS_CONFIG).map(([key, conf]) => [key, conf.value]),
) as { [K in FlagName]: typeof FLAGS_CONFIG[K]["value"] };
}, []);
const utils = useMemo(() => {
const decode = (value: number): FlagName[] => {
if (value === FLAGS_CONFIG.UNSET.value) return ["UNSET"];
const activeFlags: FlagName[] = [];
for (const key in FLAGS_CONFIG) {
const flagName = key as FlagName;
const flagConfig = FLAGS_CONFIG[flagName];
if (
flagConfig.value !== 0 &&
(value & flagConfig.value) === flagConfig.value
) {
activeFlags.push(flagName);
}
}
return activeFlags;
};
const encode = (flagNames: FlagName[]): number => {
if (flagNames.includes("UNSET") && flagNames.length === 1) {
return FLAGS_CONFIG.UNSET.value;
}
return flagNames.reduce((acc, name) => {
if (name === "UNSET") return acc;
return acc | FLAGS_CONFIG[name].value;
}, 0);
};
const hasFlag = (value: number, flagName: FlagName): boolean => {
return (value & FLAGS_CONFIG[flagName].value) ===
FLAGS_CONFIG[flagName].value;
};
const getAllFlags = (): typeof FLAGS_CONFIG => {
return FLAGS_CONFIG;
};
const isValidValue = (value: number): boolean => {
const maxValue = Object.values(FLAGS_BITMASKS)
.filter((val) => val !== 0)
.reduce((acc, val) => acc | val, 0);
return Number.isInteger(value) && value >= 0 && value <= maxValue;
};
return {
decode,
encode,
hasFlag,
getAllFlags,
isValidValue,
};
}, [FLAGS_BITMASKS]);
const toggleFlag = useCallback((flagName: FlagName) => {
setFlagsValue((prev) => prev ^ FLAGS_CONFIG[flagName].value);
}, []);
const setFlag = useCallback((flagName: FlagName, enabled: boolean) => {
const currentFlagValue = FLAGS_CONFIG[flagName].value;
setFlagsValue((prev) =>
enabled ? prev | currentFlagValue : prev & ~currentFlagValue
);
}, []);
const setFlags = useCallback(
(value: number) => {
if (!utils.isValidValue(value)) {
throw new Error(`Invalid flags value: ${value}`);
}
setFlagsValue(value);
},
[utils],
);
const clearFlags = useCallback(() => {
setFlagsValue(FLAGS_CONFIG.UNSET.value);
}, []);
const activeFlags = utils.decode(flagsValue);
return {
...utils,
flagsValue,
activeFlags,
toggleFlag,
setFlag,
setFlags,
clearFlags,
};
};
+42
View File
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useState } from "react";
type Theme = "light" | "dark" | "system";
export function useTheme() {
const getSystemTheme = () =>
globalThis.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
const getStoredPreference = useCallback(
(): Theme => (localStorage.getItem("theme") as Theme) || "system",
[],
);
const [preference, setPreference] = useState<Theme>(() =>
typeof window !== "undefined" ? getStoredPreference() : "light"
);
const theme = preference === "system" ? getSystemTheme() : preference;
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
useEffect(() => {
if (preference !== "system") return;
const media = globalThis.matchMedia("(prefers-color-scheme: dark)");
const updateTheme = () => setPreference(getStoredPreference());
media.addEventListener("change", updateTheme);
return () => media.removeEventListener("change", updateTheme);
}, [preference, getStoredPreference]);
const setPreferenceValue = (newPreference: Theme) => {
localStorage.setItem("theme", newPreference);
setPreference(newPreference);
};
return { theme, preference, setPreference: setPreferenceValue };
}
@@ -0,0 +1,79 @@
import { act, renderHook } from "@testing-library/react";
import { useToast } from "@core/hooks/useToast.ts";
import { Button } from "@components/UI/Button.tsx";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("useToast", () => {
beforeEach(() => {
// Reset toast memory state before each test
// our hook uses global memory to store toasts
// @ts-expect-error - internal test reset
globalThis.memoryState = { toasts: [] };
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("should create a toast with title, description, and action", () => {
const { result } = renderHook(() => useToast());
act(() => {
result.current.toast({
title: "Backup Reminder",
description: "Don't forget to backup!",
action: <Button>Backup Now</Button>,
});
vi.runAllTimers();
});
const toast = result.current.toasts[0];
expect(result.current.toasts.length).toBe(1);
expect(toast.title).toBe("Backup Reminder");
expect(toast.description).toBe("Don't forget to backup!");
expect(toast.action).toBeTruthy();
expect(toast.open).toBe(true);
});
it("should dismiss a toast using returned dismiss function", () => {
const { result } = renderHook(() => useToast());
vi.useFakeTimers();
let toastRef: { id: string; dismiss: () => void };
act(() => {
toastRef = result.current.toast({ title: "Dismiss Me" });
vi.runAllTimers(); // Flush ADD_TOAST
});
act(() => {
toastRef.dismiss();
});
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false);
vi.useRealTimers();
});
it("should allow dismiss via hook dismiss function", () => {
const { result } = renderHook(() => useToast());
vi.useFakeTimers();
let toastRef: { id: string };
act(() => {
toastRef = result.current.toast({ title: "Manual Dismiss" });
vi.runAllTimers();
});
act(() => {
result.current.dismiss(toastRef.id);
});
const toast = result.current.toasts.find((t) => t.id === toastRef.id);
expect(toast?.open).toBe(false);
vi.useRealTimers();
});
});
+196
View File
@@ -0,0 +1,196 @@
import { type ReactNode, useSyncExternalStore } from "react";
import type { ToastActionElement, ToastProps } from "@components/UI/Toast.tsx";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: ReactNode;
description?: ReactNode;
action?: ToastActionElement;
delay?: number;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_VALUE;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
for (const toast of state.toasts) {
addToRemoveQueue(toast.id);
}
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST": {
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
for (const listener of listeners) {
listener(memoryState);
}
}
type Toast = Omit<ToasterToast, "id">;
function toast({ delay = 0, ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
setTimeout(() => {
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open: boolean) => {
if (!open) dismiss();
},
},
});
}, delay);
return {
id: id,
dismiss,
update,
};
}
const subscribe = (listener: () => void) => {
listeners.push(listener);
return function unsubscribe() {
const index = listeners.indexOf(listener);
if (index > -1) {
listeners.splice(index, 1);
}
};
};
const getState = () => {
return memoryState;
};
function useToast() {
const state = useSyncExternalStore(subscribe, getState);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { toast, useToast };