refactor(desktop): organize lib modules by domain
This commit is contained in:
1 parent
90f0594e93
commit
7e7c4944e9
700 files changed
+1292
-1264
No files matched your search
@@ -0,0 +1,155 @@
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
|
||||
interface ClipboardApi {
|
||||
readText?: () => Promise<string> | string;
|
||||
writeText?: (text: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface ClipboardNavigator {
|
||||
clipboard?: ClipboardApi;
|
||||
}
|
||||
|
||||
interface ClipboardTextarea {
|
||||
value: string;
|
||||
style: {
|
||||
position?: string;
|
||||
top?: string;
|
||||
left?: string;
|
||||
opacity?: string;
|
||||
};
|
||||
setAttribute(name: string, value: string): void;
|
||||
focus?(): void;
|
||||
select(): void;
|
||||
setSelectionRange?(start: number, end: number): void;
|
||||
}
|
||||
|
||||
interface ClipboardDocument {
|
||||
body?: {
|
||||
appendChild(node: unknown): unknown;
|
||||
removeChild(node: unknown): unknown;
|
||||
};
|
||||
createElement(tagName: "textarea"): ClipboardTextarea;
|
||||
execCommand?(command: string): boolean;
|
||||
}
|
||||
|
||||
export interface ClipboardEnvironment {
|
||||
navigator?: ClipboardNavigator;
|
||||
document?: ClipboardDocument;
|
||||
}
|
||||
|
||||
export interface ClipboardShortcutEvent {
|
||||
key: string;
|
||||
metaKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
target?: EventTarget | null;
|
||||
}
|
||||
|
||||
interface SelectionLike {
|
||||
anchorNode: Node | null;
|
||||
focusNode: Node | null;
|
||||
isCollapsed: boolean;
|
||||
}
|
||||
|
||||
interface NativeClipboardSelectionEnvironment {
|
||||
getSelection?: () => SelectionLike | null;
|
||||
}
|
||||
|
||||
const EDITABLE_CLIPBOARD_TARGET_SELECTOR = "input, textarea, [contenteditable='true'], [role='textbox']";
|
||||
const NATIVE_CLIPBOARD_REGION_SELECTOR = "[data-native-clipboard]";
|
||||
|
||||
function closestElement(target: unknown, selector: string): unknown {
|
||||
return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null;
|
||||
}
|
||||
|
||||
function selectionNodeElement(node: Node | null): Element | null {
|
||||
if (!node) return null;
|
||||
if (typeof Element !== "undefined" && node instanceof Element) return node;
|
||||
return (node as { parentElement?: Element | null }).parentElement ?? null;
|
||||
}
|
||||
|
||||
export function isPlainClipboardShortcut(event: ClipboardShortcutEvent, key: string): boolean {
|
||||
return !!(event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === key;
|
||||
}
|
||||
|
||||
export function hasNativeClipboardSelection(env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean {
|
||||
const selection = env.getSelection?.();
|
||||
if (!selection || selection.isCollapsed) return false;
|
||||
const anchorRegion = selectionNodeElement(selection.anchorNode)?.closest(NATIVE_CLIPBOARD_REGION_SELECTOR);
|
||||
const focusRegion = selectionNodeElement(selection.focusNode)?.closest(NATIVE_CLIPBOARD_REGION_SELECTOR);
|
||||
return !!anchorRegion && anchorRegion === focusRegion;
|
||||
}
|
||||
|
||||
export function eventTargetAllowsNativeClipboard(event: ClipboardShortcutEvent, env: NativeClipboardSelectionEnvironment = globalThis as unknown as NativeClipboardSelectionEnvironment): boolean {
|
||||
if (closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR)) return true;
|
||||
return isPlainClipboardShortcut(event, "c") && hasNativeClipboardSelection(env);
|
||||
}
|
||||
|
||||
export function eventTargetAllowsAppClipboardShortcut(event: ClipboardShortcutEvent, key = "v"): boolean {
|
||||
if (!isPlainClipboardShortcut(event, key)) return false;
|
||||
return !closestElement(event.target, EDITABLE_CLIPBOARD_TARGET_SELECTOR);
|
||||
}
|
||||
|
||||
export async function readTextFromClipboard(env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise<string> {
|
||||
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
|
||||
try {
|
||||
const { readText } = await import("@tauri-apps/plugin-clipboard-manager");
|
||||
return await readText();
|
||||
} catch {
|
||||
// Fall through to Web Clipboard when the native plugin is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
if (env.navigator?.clipboard?.readText) {
|
||||
return await env.navigator.clipboard.readText();
|
||||
}
|
||||
|
||||
throw new Error("Clipboard API is not available");
|
||||
}
|
||||
|
||||
export async function copyToClipboard(text: string, env: ClipboardEnvironment = globalThis as unknown as ClipboardEnvironment): Promise<void> {
|
||||
if (isTauriRuntime(env as unknown as Record<string, unknown>)) {
|
||||
try {
|
||||
const { writeText } = await import("@tauri-apps/plugin-clipboard-manager");
|
||||
await writeText(text);
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to Web Clipboard / legacy copy when the native plugin is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (env.navigator?.clipboard?.writeText) {
|
||||
await env.navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the legacy copy path for non-secure web contexts.
|
||||
}
|
||||
|
||||
const document = env.document;
|
||||
if (!document?.body || !document.execCommand) {
|
||||
throw new Error("Clipboard API is not available");
|
||||
}
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.setAttribute("readonly", "");
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.top = "0";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.opacity = "0";
|
||||
|
||||
document.body.appendChild(textarea);
|
||||
try {
|
||||
textarea.focus?.();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange?.(0, text.length);
|
||||
if (!document.execCommand("copy")) {
|
||||
throw new Error("Clipboard copy failed");
|
||||
}
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function hexToRgba(color: string | undefined, alpha: number): string | undefined {
|
||||
if (!color) return undefined;
|
||||
const trimmed = color.trim();
|
||||
|
||||
// Already rgba/rgb: replace or add alpha, then return
|
||||
const rgbaMatch = trimmed.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/i);
|
||||
if (rgbaMatch) {
|
||||
return `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, ${alpha})`;
|
||||
}
|
||||
const rgbMatch = trimmed.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
|
||||
if (rgbMatch) {
|
||||
return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${alpha})`;
|
||||
}
|
||||
|
||||
const hex = trimmed.replace(/^#/, "");
|
||||
const normalized =
|
||||
hex.length === 3
|
||||
? hex
|
||||
.split("")
|
||||
.map((c) => c + c)
|
||||
.join("")
|
||||
: hex;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(normalized)) return undefined;
|
||||
|
||||
const value = Number.parseInt(normalized, 16);
|
||||
const r = (value >> 16) & 255;
|
||||
const g = (value >> 8) & 255;
|
||||
const b = value & 255;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export function expandNestedJsonStringsForCopy(value: unknown): unknown {
|
||||
if (typeof value === "string") {
|
||||
return parseNestedJsonString(value) ?? value;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(expandNestedJsonStringsForCopy);
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, expandNestedJsonStringsForCopy(item)]));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseNestedJsonString(value: string): unknown | undefined {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return undefined;
|
||||
|
||||
try {
|
||||
return expandNestedJsonStringsForCopy(JSON.parse(trimmed));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function parseSlashDelimitedRegexQuery(query: string): RegExp | null {
|
||||
if (!query.startsWith("/") || query.length < 2) return null;
|
||||
const lastSlash = query.lastIndexOf("/");
|
||||
if (lastSlash <= 0) return null;
|
||||
|
||||
const source = query.slice(1, lastSlash);
|
||||
const flags = query.slice(lastSlash + 1);
|
||||
if (!/^[dgimsuvy]*$/.test(flags)) return null;
|
||||
|
||||
try {
|
||||
return new RegExp(source, flags.includes("i") ? flags : `${flags}i`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Formats Redis TTL (seconds) as a compact string:
|
||||
* < 1 day → HH:mm:ss
|
||||
* >= 1 day → {n}d HH:mm:ss (day unit localized)
|
||||
*
|
||||
* Returns null for ttl === -1 (no expiry) or ttl <= 0.
|
||||
*/
|
||||
|
||||
type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
export function formatTtl(ttl: number, t: TranslateFn): string | null {
|
||||
if (ttl === -1) return null;
|
||||
if (ttl <= 0) return null;
|
||||
|
||||
const days = Math.floor(ttl / 86_400);
|
||||
const hours = Math.floor((ttl % 86_400) / 3_600);
|
||||
const minutes = Math.floor((ttl % 3_600) / 60);
|
||||
const seconds = ttl % 60;
|
||||
|
||||
const time = [String(hours).padStart(2, "0"), String(minutes).padStart(2, "0"), String(seconds).padStart(2, "0")].join(":");
|
||||
|
||||
if (days > 0) {
|
||||
return `${t("redis.ttlDay", { count: days })} ${time}`;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function uuid(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
function normalizeBasePath(value: string | undefined): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed || trimmed === "." || trimmed === "./" || trimmed === "/") return "";
|
||||
const withoutQuery = trimmed.split(/[?#]/, 1)[0] ?? "";
|
||||
const withLeadingSlash = withoutQuery.startsWith("/") ? withoutQuery : `/${withoutQuery}`;
|
||||
return withLeadingSlash.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function inferredRuntimeBasePath(pathname: string): string {
|
||||
const normalized = pathname.replace(/\/+$/, "");
|
||||
if (!normalized || normalized === "/login") return "";
|
||||
if (normalized.endsWith("/login")) return normalized.slice(0, -"/login".length);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function dbxWebBasePath(pathname = globalThis.location?.pathname ?? "", buildBase = import.meta.env.BASE_URL): string {
|
||||
const configured = normalizeBasePath(buildBase);
|
||||
if (configured) return configured;
|
||||
return normalizeBasePath(inferredRuntimeBasePath(pathname));
|
||||
}
|
||||
|
||||
export function webPath(path: string, basePath = dbxWebBasePath()): string {
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
const base = normalizeBasePath(basePath);
|
||||
return `${base}${normalizedPath}` || "/";
|
||||
}
|
||||
|
||||
export function apiUrl(path: string, basePath = dbxWebBasePath()): string {
|
||||
const pathWithLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
||||
const normalizedPath = pathWithLeadingSlash === "/api" || pathWithLeadingSlash.startsWith("/api/") || pathWithLeadingSlash.startsWith("/api?") ? pathWithLeadingSlash : `/api${pathWithLeadingSlash}`;
|
||||
return webPath(normalizedPath, basePath);
|
||||
}
|
||||
|
||||
type WebSocketLocation = Pick<Location, "protocol" | "host"> | undefined;
|
||||
|
||||
export function apiWebSocketUrl(path: string, basePath = dbxWebBasePath(), currentLocation: WebSocketLocation = globalThis.location): string {
|
||||
const protocol = currentLocation?.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${currentLocation?.host ?? ""}${apiUrl(path, basePath)}`;
|
||||
}
|
||||
Reference in new issue
Block a user