web_lib

Common web application libraries
git clone https://radroots.dev/git/web_lib.git
Log | Files | Refs | LICENSE

commit a8312d57b235943973b15302ead1bc4c78afa320
parent a4a8d92fcd9f1ce4ed3e5a11a1d1cf4fc8e42fc2
Author: triesap <triesap@radroots.dev>
Date:   Thu, 20 Nov 2025 13:46:56 +0000

apps-lib: add environment-based client and indexeddb keyval utilities, refactor carousel and ndk stores to shared app helpers, and expand svelte library exports and metadata

Diffstat:
Mapps-lib/.env.example | 4++--
Mapps-lib/.gitignore | 1+
Mapps-lib/package.json | 2+-
Mapps-lib/src/global.d.ts | 133-------------------------------------------------------------------------------
Mapps-lib/src/lib/components/carousel-item.svelte | 2+-
Mapps-lib/src/lib/components/carousel.svelte | 2+-
Mapps-lib/src/lib/components/fade.svelte | 4++--
Mapps-lib/src/lib/components/glyph.svelte | 2+-
Mapps-lib/src/lib/components/glyphi.svelte | 2+-
Mapps-lib/src/lib/components/image-blob.svelte | 2+-
Mapps-lib/src/lib/components/image-path.svelte | 2+-
Mapps-lib/src/lib/components/image-src.svelte | 2+-
Mapps-lib/src/lib/index.ts | 57++++++++++++++++++++++++++++++---------------------------
Mapps-lib/src/lib/stores/carousel.ts | 8++++----
Mapps-lib/src/lib/stores/ndk.ts | 18+++++-------------
Mapps-lib/src/lib/stores/theme.ts | 2+-
Aapps-lib/src/lib/utils/_env.ts | 21+++++++++++++++++++++
Mapps-lib/src/lib/utils/app/carousel.ts | 34+++++++++++++++++-----------------
Aapps-lib/src/lib/utils/app/lib.ts | 233+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapps-lib/src/lib/utils/idb/kv.ts | 55-------------------------------------------------------
Aapps-lib/src/lib/utils/keyval/idb.ts | 307+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapps-lib/src/lib/utils/keyval/lib.ts | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Dapps-lib/src/lib/utils/lib.ts | 191-------------------------------------------------------------------------------
23 files changed, 684 insertions(+), 453 deletions(-)

diff --git a/apps-lib/.env.example b/apps-lib/.env.example @@ -1,4 +1,4 @@ -VITE_PUBLIC_IDB_NAME= +VITE_PUBLIC_KEYVAL_NAME= VITE_PUBLIC_NDK_CACHE_NAME= VITE_PUBLIC_NDK_CLIENT_NAME= -VITE_PUBLIC_RADROOTS_MARKET_RELAY_URL= +VITE_PUBLIC_RADROOTS_RELAY= diff --git a/apps-lib/.gitignore b/apps-lib/.gitignore @@ -1,6 +1,7 @@ node_modules dist .turbo +.svelte-kit # Logs logs/ diff --git a/apps-lib/package.json b/apps-lib/package.json @@ -1,6 +1,6 @@ { "name": "@radroots/apps-lib", - "version": "0.0.0", + "version": "0.0.1", "private": true, "license": "GPLv3", "scripts": { diff --git a/apps-lib/src/global.d.ts b/apps-lib/src/global.d.ts @@ -1,136 +1,3 @@ declare module "$app/environment"; declare module "$app/navigation"; declare module "$app/stores"; - -declare class Keyva { - /** - * An IDBKeyRange that has no upper or lower bounding. - */ - static readonly unbound: IDBKeyRange; - /** - * Returns an IDBKeyRange that matches all keys that start - * with the specified string prefix. - */ - static prefix(prefix: string): IDBKeyRange; - /** - * @returns An array of strings that contain the names of all - * Keyva-created IndexedDB databases. - */ - static each(): Promise<string[]>; - /** - * Deletes Keyva-created IndexedDB databases with the - * specified names. - * - * @param names The names of the databases to delete. - * If no names are provided, all Keyva IndexedDB databases - * are deleted. - */ - static delete(...names: string[]): Promise<void>; - /** Stores the prefix that is added to every IndexedDB database created by Keyva. */ - private static readonly kvPrefix; - /** - * Creates a new IndexedDB-backed database - */ - constructor(options?: Keyva.IConstructorOptions); - private readonly indexes; - private readonly name; - /** - * Get a value by its key. - * @param key The key of the value to get. - */ - get<T = any>(key: Keyva.Key): Promise<T>; - /** - * Get a series of values from the keys specified. - * @param keys The key of the value to get. - */ - get<T = any>(keys: Keyva.Key[]): Promise<T[]>; - /** - * Gets all keys and values from the Keyva database. - * @param key The key of the value to get. - */ - each<T = any>(): Promise<[Keyva.Key, T][]>; - /** - * Gets a series of keys and values that match the specified - * set of options. - */ - each<T = any>(options: Keyva.IQuery): Promise<[Keyva.Key, T][]>; - /** - * Gets a series of keys only that match the specified set of options. - */ - each(options: Keyva.IQuery, only: "keys"): Promise<Keyva.Key[]>; - /** - * Gets a series of values only that match the specified set of options. - */ - each<T = any>(options: Keyva.IQuery, only: "values"): Promise<T[]>; - /** - * Set a value with a key. - */ - set(key: Keyva.Key, value: any): Promise<void>; - /** - * Set multiple values at once. This is faster than calling set() multiple times. - * It's also atomic – if one of the pairs can't be added, none will be added. - * @param entries Array of entries, where each entry is an array of `[key, value]`. - */ - set(entries: [Keyva.Key, any][]): Promise<void>; - /** - * Deletes all objects from this Keyva database - * (but keeps the Keyva database itself is kept). - */ - delete(): Promise<void>; - /** - * Delete a single object from the store with the specified key. - */ - delete(range: IDBKeyRange): Promise<void>; - /** - * Delete a single object from the store with the specified key. - */ - delete(key: Keyva.Key): Promise<void>; - /** - * Delete a series of objects from the store at once, with the specified keys. - */ - delete(keys: Keyva.Key[]): Promise<void>; - /** */ - private getStore; - /** */ - private getDatabase; - private database; - /** - * Works around a Safari 14 bug. - * - * Safari has a bug where IDB requests can hang while the browser is - * starting up. https://bugs.webkit.org/show_bug.cgi?id=226547 - * The only solution is to keep nudging it until it's awake. - */ - private maybeFixSafari; - /** */ - private static asPromise; -} -declare namespace Keyva { - /** */ - interface IConstructorOptions { - /** - * Defines the name of the IndexedDB database as it is stored in the browser. - * Note that the name is prefixed with the Keyva database prefix constant. - */ - name?: string | number; - /** - * Defines the name or names of the index or indexes to define on the database. - */ - indexes?: string | string[]; - } - /** */ - interface IQuery { - /** - * A standard IDBKeyRange to use for the query. Worth noting that the methods - * in the static Keyva.* namespace contain utility functions to ease the creation - * of IDBKeyRange objects. - */ - range?: IDBKeyRange; - /** The name of the index to use for the query. */ - index?: string; - /** A number which indicates the maximum number of objects to return from a query. */ - limit?: number; - } - /** */ - type Key = string | number | Date | BufferSource; -} diff --git a/apps-lib/src/lib/components/carousel-item.svelte b/apps-lib/src/lib/components/carousel-item.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { IBasisOpt, IClOpt } from "$lib/types/ui"; - import { fmt_cl } from "$lib/utils/lib"; + import { fmt_cl } from "$lib/utils/app/lib"; import type { Snippet } from "svelte"; let { diff --git a/apps-lib/src/lib/components/carousel.svelte b/apps-lib/src/lib/components/carousel.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { casl_i } from "$lib/stores/carousel"; import type { IBasisOpt, IClOpt } from "$lib/types/ui"; - import { fmt_cl } from "$lib/utils/lib"; + import { fmt_cl } from "$lib/utils/app/lib"; import type { Snippet } from "svelte"; let { diff --git a/apps-lib/src/lib/components/fade.svelte b/apps-lib/src/lib/components/fade.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { fmt_cl } from "$lib/utils/lib"; + import { fmt_cl } from "$lib/utils/app/lib"; import type { Snippet } from "svelte"; import { cubicIn, cubicOut } from "svelte/easing"; import { fade, type FadeParams } from "svelte/transition"; @@ -28,7 +28,7 @@ easing: cubicOut, ...basis?.out, }} - class={`flex w-full overflow-hidden ${fmt_cl(basis?.classes)}`} + class={`flex ${fmt_cl(basis?.classes)}`} > {@render children()} </div> diff --git a/apps-lib/src/lib/components/glyph.svelte b/apps-lib/src/lib/components/glyph.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { glyph_style_map } from "$lib/styles/glyphs"; import type { IGlyph } from "$lib/types/components"; - import { fmt_cl } from "$lib/utils/lib"; + import { fmt_cl } from "$lib/utils/app/lib"; let { basis }: { basis: IGlyph } = $props(); diff --git a/apps-lib/src/lib/components/glyphi.svelte b/apps-lib/src/lib/components/glyphi.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { IGlyphI } from "$lib/types/components"; - import { fmt_cl } from "$lib/utils/lib"; + import { fmt_cl } from "$lib/utils/app/lib"; const styles = { xs: `text-[16px]`, diff --git a/apps-lib/src/lib/components/image-blob.svelte b/apps-lib/src/lib/components/image-blob.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { IImageBlob } from "$lib/types/ui"; - import { fmt_cl, to_arr_buf } from "$lib/utils/lib"; + import { fmt_cl, to_arr_buf } from "$lib/utils/app/lib"; let { basis }: { basis: IImageBlob } = $props(); diff --git a/apps-lib/src/lib/components/image-path.svelte b/apps-lib/src/lib/components/image-path.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { IImagePath } from "$lib/types/ui"; - import { get_context } from "$lib/utils/lib"; + import { get_context } from "$lib/utils/app/lib"; import ImageBlob from "./image-blob.svelte"; import ImageSrc from "./image-src.svelte"; diff --git a/apps-lib/src/lib/components/image-src.svelte b/apps-lib/src/lib/components/image-src.svelte @@ -1,6 +1,6 @@ <script lang="ts"> import type { IImageSource } from "$lib/types/ui"; - import { fmt_cl } from "$lib/utils/lib"; + import { fmt_cl } from "$lib/utils/app/lib"; let { basis }: { basis: IImageSource } = $props(); diff --git a/apps-lib/src/lib/index.ts b/apps-lib/src/lib/index.ts @@ -1,27 +1,30 @@ -export * from "./stores/app.js" -export * from "./stores/carousel.js" -export * from "./stores/ndk.js" -export * from "./stores/theme.js" -export * from "./styles/glyphs.js" -export * from "./types/components.js" -export * from "./types/lib.js" -export * from "./types/ndk.js" -export * from "./types/ui.js" -export * from "./utils/app/carousel.js" -export * from "./utils/browser.js" -export * from "./utils/fetch/lib.js" -export * from "./utils/geo.js" -export * from "./utils/i18n.js" -export * from "./utils/idb/kv.js" -export * from "./utils/lib.js" -export * from "./utils/nostr/lib.js" -export { default as Carousel } from "./components/carousel.svelte" -export { default as CarouselItem } from "./components/carousel-item.svelte" -export { default as Fade } from "./components/fade.svelte" -export { default as Flex } from "./components/flex.svelte" -export { default as Glyph } from "./components/glyph.svelte" -export { default as Glyphi } from "./components/glyphi.svelte" -export { default as ImageBlob } from "./components/image-blob.svelte" -export { default as ImagePath } from "./components/image-path.svelte" -export { default as ImageSrc } from "./components/image-src.svelte" -export { default as Input } from "./components/input.svelte" +export * from "./index.js"; +export * from "./stores/app.js"; +export * from "./stores/carousel.js"; +export * from "./stores/ndk.js"; +export * from "./stores/theme.js"; +export * from "./styles/glyphs.js"; +export * from "./types/components.js"; +export * from "./types/lib.js"; +export * from "./types/ndk.js"; +export * from "./types/ui.js"; +export * from "./utils/_env.js"; +export * from "./utils/app/carousel.js"; +export * from "./utils/app/lib.js"; +export * from "./utils/browser.js"; +export * from "./utils/fetch/lib.js"; +export * from "./utils/geo.js"; +export * from "./utils/i18n.js"; +export * from "./utils/keyval/idb.js"; +export * from "./utils/keyval/lib.js"; +export * from "./utils/nostr/lib.js"; +export { default as CarouselItem } from "./components/carousel-item.svelte"; +export { default as Carousel } from "./components/carousel.svelte"; +export { default as Fade } from "./components/fade.svelte"; +export { default as Flex } from "./components/flex.svelte"; +export { default as Glyph } from "./components/glyph.svelte"; +export { default as Glyphi } from "./components/glyphi.svelte"; +export { default as ImageBlob } from "./components/image-blob.svelte"; +export { default as ImagePath } from "./components/image-path.svelte"; +export { default as ImageSrc } from "./components/image-src.svelte"; +export { default as Input } from "./components/input.svelte"; diff --git a/apps-lib/src/lib/stores/carousel.ts b/apps-lib/src/lib/stores/carousel.ts @@ -1,11 +1,11 @@ -import { get_store } from "$lib/utils/lib"; +import { get_store } from "$lib/utils/app/lib"; import { writable } from "svelte/store"; -export const carousel_active = writable<boolean>(false); +export const casl_active = writable<boolean>(false); export const casl_i = writable<number>(0); export const casl_imax = writable<number>(0); -const fn_carousel_num = (num_i: number, num_min: number) => { +const create_carousel_num = (num_i: number, num_min: number) => { const store = writable<number>(num_i); return { subscribe: store.subscribe, @@ -17,7 +17,7 @@ const fn_carousel_num = (num_i: number, num_min: number) => { } }; } -export const carousel_num = fn_carousel_num(1, 1); +export const casl_num = create_carousel_num(1, 1); export const casl_inc = async (opts?: 'noflow'): Promise<void> => { const $casl_i = get_store(casl_i); diff --git a/apps-lib/src/lib/stores/ndk.ts b/apps-lib/src/lib/stores/ndk.ts @@ -1,26 +1,18 @@ +import { _envLib } from "$lib/utils/_env"; import { type NDKCacheAdapter, type NDKUser } from "@nostr-dev-kit/ndk"; import NDKCacheAdapterDexie from "@nostr-dev-kit/ndk-cache-dexie"; import NDKSvelte from "@nostr-dev-kit/ndk-svelte"; import { writable } from "svelte/store"; -const NDK_CACHE_NAME = import.meta.env.VITE_PUBLIC_NDK_CACHE_NAME; -if (!NDK_CACHE_NAME) throw new Error('Error: VITE_PUBLIC_NDK_CACHE_NAME is required'); - -const NDK_CLIENT_NAME = import.meta.env.VITE_PUBLIC_NDK_CLIENT_NAME; -if (!NDK_CLIENT_NAME) throw new Error('Error: VITE_PUBLIC_NDK_CLIENT_NAME is required'); - -const RADROOTS_MARKET_RELAY_URL = import.meta.env.VITE_PUBLIC_RADROOTS_MARKET_RELAY_URL -if (!RADROOTS_MARKET_RELAY_URL) throw new Error('Error: VITE_PUBLIC_RADROOTS_MARKET_RELAY_URL is required'); - let cache_adapter: NDKCacheAdapter | undefined; -if (typeof window !== `undefined`) cache_adapter = new NDKCacheAdapterDexie({ dbName: NDK_CACHE_NAME }); +if (typeof window !== `undefined`) cache_adapter = new NDKCacheAdapterDexie({ dbName: _envLib.NDK_CACHE_NAME }); let cache_adapter_global: NDKCacheAdapter | undefined; -if (typeof window !== `undefined`) cache_adapter_global = new NDKCacheAdapterDexie({ dbName: `${NDK_CACHE_NAME}-global` }); +if (typeof window !== `undefined`) cache_adapter_global = new NDKCacheAdapterDexie({ dbName: `${_envLib.NDK_CACHE_NAME}-global` }); -const _ndk = new NDKSvelte({ cacheAdapter: cache_adapter, clientName: NDK_CLIENT_NAME, explicitRelayUrls: [RADROOTS_MARKET_RELAY_URL], autoConnectUserRelays: true, autoFetchUserMutelist: true }); +const _ndk = new NDKSvelte({ cacheAdapter: cache_adapter, clientName: _envLib.NDK_CLIENT_NAME, explicitRelayUrls: [_envLib.RADROOTS_RELAY], autoConnectUserRelays: true, autoFetchUserMutelist: true }); export const ndk = writable<NDKSvelte>(_ndk); export const ndk_user = writable<NDKUser>(); -const _ndk_global = new NDKSvelte({ cacheAdapter: cache_adapter_global, clientName: NDK_CLIENT_NAME, autoConnectUserRelays: true, autoFetchUserMutelist: true }); +const _ndk_global = new NDKSvelte({ cacheAdapter: cache_adapter_global, clientName: _envLib.NDK_CLIENT_NAME, autoConnectUserRelays: true, autoFetchUserMutelist: true }); export const ndk_global = writable<NDKSvelte>(_ndk_global); diff --git a/apps-lib/src/lib/stores/theme.ts b/apps-lib/src/lib/stores/theme.ts @@ -1,4 +1,4 @@ -import { get_store } from "$lib/utils/lib"; +import { get_store } from "$lib/utils/app/lib"; import type { ThemeKey, ThemeMode } from "@radroots/themes"; import { type CallbackPromiseGeneric } from "@radroots/utils"; import { writable } from "svelte/store"; diff --git a/apps-lib/src/lib/utils/_env.ts b/apps-lib/src/lib/utils/_env.ts @@ -0,0 +1,21 @@ +const KEYVAL_NAME = import.meta.env.VITE_PUBLIC_KEYVAL_NAME; +if (!KEYVAL_NAME || typeof KEYVAL_NAME !== 'string') throw new Error('Missing env var: VITE_PUBLIC_KEYVAL_NAME'); + +const NDK_CACHE_NAME = import.meta.env.VITE_PUBLIC_NDK_CACHE_NAME; +if (!NDK_CACHE_NAME || typeof NDK_CACHE_NAME !== 'string') throw new Error('Missing env var: VITE_PUBLIC_NDK_CACHE_NAME'); + +const NDK_CLIENT_NAME = import.meta.env.VITE_PUBLIC_NDK_CLIENT_NAME; +if (!NDK_CLIENT_NAME || typeof NDK_CLIENT_NAME !== 'string') throw new Error('Missing env var: VITE_PUBLIC_NDK_CLIENT_NAME'); + +const RADROOTS_RELAY = import.meta.env.VITE_PUBLIC_RADROOTS_RELAY; +if (!RADROOTS_RELAY || typeof RADROOTS_RELAY !== 'string') throw new Error('Missing env var: VITE_PUBLIC_RADROOTS_RELAY'); + +const PROD = import.meta.env.MODE === 'production'; + +export const _envLib = { + PROD, + KEYVAL_NAME, + NDK_CACHE_NAME, + NDK_CLIENT_NAME, + RADROOTS_RELAY, +} as const; diff --git a/apps-lib/src/lib/utils/app/carousel.ts b/apps-lib/src/lib/utils/app/carousel.ts @@ -1,7 +1,7 @@ -import { carousel_active, carousel_num, casl_i, casl_imax } from "$lib/stores/carousel"; +import { casl_active, casl_i, casl_imax, casl_num } from "$lib/stores/carousel"; import { exe_iter } from "@radroots/utils"; -import { get_store } from "../lib"; +import { get_store } from "./lib"; const CAROUSEL_DELAY_MS = 150; @@ -22,9 +22,9 @@ const get_slide_item = <T extends string>(view: T): Element | undefined => { const carousel_dec_handler = async <T extends string>( view: T, ): Promise<void> => { - const $carousel_active = get_store(carousel_active); - if ($carousel_active) return; - carousel_active.set(true); + const $casl_active = get_store(casl_active); + if ($casl_active) return; + casl_active.set(true); const slide_item = get_slide_item<T>(view); const slide_container = get_slide_container<T>(view); if (slide_container && slide_item) { @@ -33,15 +33,15 @@ const carousel_dec_handler = async <T extends string>( const $casl_i = get_store(casl_i); casl_i.set(Math.max($casl_i - 1, 0)); } - carousel_active.set(false); + casl_active.set(false); }; const carousel_inc_handler = async <T extends string>( view: T, ): Promise<void> => { - const $carousel_active = get_store(carousel_active); - if ($carousel_active) return; - carousel_active.set(true); + const $casl_active = get_store(casl_active); + if ($casl_active) return; + casl_active.set(true); const slide_item = get_slide_item<T>(view); const slide_container = get_slide_container<T>(view); if (slide_container && slide_item) { @@ -53,16 +53,16 @@ const carousel_inc_handler = async <T extends string>( Math.min($casl_i + 1, $casl_imax), ); } - carousel_active.set(false); + casl_active.set(false); }; export const carousel_inc = async <T extends string>( view: T, duration: number = CAROUSEL_DELAY_MS ): Promise<void> => { - const $carousel_num = get_store(carousel_num); - carousel_num.set(1); - await exe_iter(async () => carousel_inc_handler(view), $carousel_num, duration); + const $casl_num = get_store(casl_num); + casl_num.set(1); + await exe_iter(async () => carousel_inc_handler(view), $casl_num, duration); }; @@ -70,14 +70,14 @@ export const carousel_dec = async <T extends string>( view: T, duration: number = CAROUSEL_DELAY_MS ): Promise<void> => { - const $carousel_num = get_store(carousel_num); - carousel_num.set(1); - await exe_iter(async () => carousel_dec_handler(view), $carousel_num, duration); + const $casl_num = get_store(casl_num); + casl_num.set(1); + await exe_iter(async () => carousel_dec_handler(view), $casl_num, duration); }; export const carousel_init = async <T extends string>(view: T, num_max: number): Promise<void> => { await carousel_dec(view); casl_i.set(0); casl_imax.set(num_max); - carousel_num.set(1); + casl_num.set(1); }; diff --git a/apps-lib/src/lib/utils/app/lib.ts b/apps-lib/src/lib/utils/app/lib.ts @@ -0,0 +1,233 @@ +import { browser } from '$app/environment'; +import { goto } from '$app/navigation'; +import { win_h, win_w } from '$lib/stores/app'; +import type { CallbackRoute, NavigationParamTuple, NavigationRouteParamKey, NavigationRouteParamTuple, } from '$lib/types/lib'; +import type { ThemeLayer, ThemeMode } from '@radroots/themes'; +import type { FilePath } from '@radroots/utils'; +import { getContext, setContext } from "svelte"; +import { get } from "svelte/store"; + +export const symbols = { + bullet: '•', + dash: `—`, + up: `↑`, + down: `↓`, + percent: `%` +}; + +export const get_store = get; + +export const get_context = <M extends Record<string, any>, K extends keyof M>(key: K): M[K] => + getContext(key as string) as M[K]; + +export const set_context = <M extends Record<string, any>, K extends keyof M>(key: K, value: M[K]): void => + setContext(key as string, value); + +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export const trim_slashes = (path: string): string => + path.replace(/^\/+|\/+$/g, ''); + +export const normalize_path = (path: string): string => + path + .replace(/-/g, '_') + .replace(/\//g, '-') + .replace(/-+/g, '-'); + +export const sanitize_path = (id: string): string => + id.replace(/[^A-Za-z0-9_-]+/g, ''); + +export const fmt_id = (raw_id?: string): string => { + if (!browser) return ''; + const pathname = window.location.pathname; + const trimmed = trim_slashes(pathname); + const prefix = normalize_path(trimmed); + const suffix = raw_id ? `-${sanitize_path(raw_id)}` : ''; + return `*${prefix}${suffix}`; +}; + +export const view_effect = <T extends string>(view: T): void => { + if (!browser) return; + for (const el of document.querySelectorAll(`[data-view]`)) { + if (el.getAttribute(`data-view`) !== view) el.classList.add(`hidden`) + else el.classList.remove(`hidden`) + } +}; + +export const el_id = (id: string): HTMLElement | undefined => { + const el = document.getElementById(id); + return el ? el : undefined; +}; + +export const build_storage_key = ( + raw_id: string, + base_prefix: string +): string => + `${fmt_id()}-${sanitize_path(raw_id)}` + .replace(new RegExp(`^\\*${normalize_path(trim_slashes(base_prefix))}-?`), '*'); + +export const get_system_theme = (): ThemeMode => { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +}; + +export const theme_set = (theme_key: string, color_mode: ThemeMode): void => { + document.documentElement.setAttribute("data-theme", `${theme_key}_${color_mode}`); +}; +export const fmt_cl = (classes?: string): string => `${classes || ``}`; + +export const handle_err = async (e: unknown, fcall: string): Promise<void> => { + try { + console.log(`[handle_err] `, e, fcall) + /*return void await catch_err(e, fcall, async (opts) => { + console.log(`handle_err e `, e) + console.log(JSON.stringify(opts, null, 4), `handle_err opts`) + });*/ + } catch (e) { + console.log(`(handle_err) `, e) + } +}; + +export const window_set = (): void => { + if (!browser) return; + win_h.set(window.innerHeight); + win_w.set(window.innerWidth); +}; + +export const parse_layer = (layer?: number, layer_default?: ThemeLayer): ThemeLayer => { + switch (layer) { + case 0: + case 1: + case 2: + return layer; + default: + return layer_default ? layer_default : 0; + }; +}; + +export const value_constrain = (regex_charset: RegExp, value: string): string => { + return value + .split(``) + .filter((char) => regex_charset.test(char)) + .join(``); +}; + + +export const encode_query_params = <T extends string>(params_list: NavigationParamTuple<T>[] = []): string => { + let query = ""; + for (const [k, v] of params_list) { + if (k && v) { + if (query) query += `&`; + query += `${k.trim()}=${encodeURIComponent(v.trim())}`; + } + } + return query ? `?${query}` : ``; +}; + +export const encode_route = <TRoute extends string, TParam extends string>(route: TRoute, params_list?: NavigationParamTuple<TParam>[]): string => { + const query = encode_query_params(params_list); + if (!query) return route; + return `${route === `/` ? `/` : route.replace(/\/+$/, ``)}${query}`; +}; + +export const debounce = <T extends (...args: any[]) => void>( + fn: T, + delay: number +): T => { + let timeout: ReturnType<typeof setTimeout>; + return ((...args: any[]) => { + clearTimeout(timeout); + timeout = setTimeout(() => fn(...args), delay); + }) as T; +}; + +export const create_router = <T extends string>() => { + const router = async (nav_route: T, params: NavigationRouteParamTuple[] = []): Promise<void> => { + try { + if (params.length) await goto(encode_route<T, NavigationRouteParamKey>(nav_route, params)); + else await goto(nav_route); + } catch (e) { + handle_err(e, `route`); + }; + }; + return router; +}; + +export const get_locale = (locales: string[]): string => { + const { language: navigator_locale } = navigator; + let locale = `en`; + if (locales.some(i => i === navigator_locale.toLowerCase())) locale = navigator.language; + else if (locales.some(i => i === navigator_locale.slice(0, 2).toLowerCase())) locale = navigator_locale.slice(0, 2); + return locale.toLowerCase(); +}; + +export const callback_route = async <T extends string>(callback_route: CallbackRoute<T>): Promise<void> => { + if (`route` in callback_route) { + if (typeof callback_route.route === `string`) return void await goto(callback_route.route); + else return void await goto( + encode_route<string, NavigationRouteParamKey>( + callback_route.route[0], + callback_route.route[1], + ), + ); + } + return void await callback_route(); +}; + +export const to_arr_buf = (u8: Uint8Array): ArrayBuffer => { + return u8.slice().buffer; +}; + +export const parse_file_path = (file_path: string): FilePath | undefined => { + const file_path_spl = file_path.split(`/`); + const file_path_file = file_path_spl[file_path_spl.length - 1] || ``; + const [file_name, mime_type] = file_path_file.split(`.`); + if (!file_name || !mime_type) return undefined; + return { + file_path, + file_name, + mime_type + }; +}; + +export const download_json = (data: any, filename: string): void => { + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}; + +export const select_file = async (): Promise<File | undefined> => { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "*/*"; + input.style.display = "none"; + const cleanup = () => { + input.remove(); + }; + input.addEventListener("change", async () => { + const file = input.files?.[0]; + cleanup(); + resolve(file ?? undefined); + }); + document.body.appendChild(input); + input.click(); + }); +}; + +export const get_file_text = async (file: File | null): Promise<string | undefined> => { + if (!file) return undefined; + const text = await file.text(); + return text; +}; + +export const parse_file_json = async (file: File | null): Promise<unknown | undefined> => { + const contents = await get_file_text(file); + if (!contents) return undefined; + const parsed: unknown = JSON.parse(contents); + return parsed; +}; diff --git a/apps-lib/src/lib/utils/idb/kv.ts b/apps-lib/src/lib/utils/idb/kv.ts @@ -1,54 +0,0 @@ -import { browser } from "$app/environment"; -import { fmt_id } from "../lib"; - -//@ts-ignore -const idb_name = import.meta.env.VITE_PUBLIC_IDB_NAME; -if (!idb_name) throw new Error('Error: VITE_PUBLIC_IDB_NAME is required'); - -export let idb: Keyva; -if (browser) idb = new Keyva({ name: idb_name }); - -export const idb_init = async (): Promise<void> => { - if (!browser) return; - const range = Keyva.prefix(`*`); - const idb_list = await idb.each({ range }, `keys`); - await Promise.all(idb_list.map((i) => idb.delete(i))); -}; - -export const idb_init_page = async (): Promise<void> => { - if (!browser) return; - const idb_pref = fmt_id(); - const range = Keyva.prefix(idb_pref); - const idb_list = await idb.each({ range }, `keys`); - await Promise.all(idb_list.map((i) => idb.delete(i))); -}; - -export const idb_sync = async (list: [string, string][]): Promise<void> => { - if (!browser) return; - for (const [key, val] of list) await idb.set(key, val); -}; - -export class IdbLib<T extends string> { - private _idb: Keyva; - - constructor(kv: Keyva) { - this._idb = kv; - } - public init = async () => { - await idb_init_page(); - } - - public save = async (key: T, value: string) => { - await this._idb.set(fmt_id(key), value); - } - - public read = async (key: T): Promise<string | undefined> => { - const result = await this._idb.get<string>(fmt_id(key)); - if (result) return result; - return undefined; - } - - public del = async (key: T) => { - await this._idb.delete(fmt_id(key)); - } -} -\ No newline at end of file diff --git a/apps-lib/src/lib/utils/keyval/idb.ts b/apps-lib/src/lib/utils/keyval/idb.ts @@ -0,0 +1,307 @@ + +export class IdbKeyval { + /** + * An IDBKeyRange that has no upper or lower bounding. + */ + static readonly unbound = IDBKeyRange.lowerBound(Number.MIN_SAFE_INTEGER); + + /** + * Returns an IDBKeyRange that matches all keys that start + * with the specified string prefix. + */ + static prefix(prefix: string) { + return IDBKeyRange.bound(prefix, prefix + "\uFFFF"); + } + + /** + * @returns An array of strings that contain the names of all + * IdbKeyval-created IndexedDB databases. + */ + static async each() { + const databases = await indexedDB.databases(); + return databases + .map(db => db.name) + .filter((s): s is string => !!s && s.startsWith(this.kv_prefix)); + } + + /** + * Deletes IdbKeyval-created IndexedDB databases with the + * specified names. + * + * @param names The names of the databases to delete. + * If no names are provided, all IdbKeyval IndexedDB databases + * are deleted. + */ + static async delete(...names: string[]) { + names = names.length ? + names.map(n => n.startsWith(this.kv_prefix) ? n : this.kv_prefix + n) : + await this.each(); + + Promise.all(names.map(n => this.as_promise(indexedDB.deleteDatabase(n)))); + } + + /** Stores the prefix that is added to every IndexedDB database created by IdbKeyval. */ + private static readonly kv_prefix = "radroots-web-keyval"; + + /** + * Creates a new IndexedDB-backed database + */ + constructor(options: IdbKeyval.IConstructorOptions = {}) { + const idx = options.indexes || []; + this.indexes = (Array.isArray(idx) ? idx : [idx]).sort(); + this.name = options.name?.toString() || IdbKeyval.kv_prefix; + } + + private readonly indexes: string[]; + private readonly name: string; + + /** + * Get a value by its key. + * @param key The key of the value to get. + */ + get<T = any>(key: IdbKeyval.Key): Promise<T>; + /** + * Get a series of values from the keys specified. + * @param keys The key of the value to get. + */ + get<T = any>(keys: IdbKeyval.Key[]): Promise<T[]>; + /** */ + async get(k: IdbKeyval.Key | IdbKeyval.Key[]) { + const store = await this.get_store("readonly"); + + return Array.isArray(k) ? + Promise.all(k.map(key => IdbKeyval.as_promise(store.get(key)))) : + IdbKeyval.as_promise(store.get(k)); + } + + /** + * Gets all keys and values from the IdbKeyval database. + * @param key The key of the value to get. + */ + each<T = any>(): Promise<[IdbKeyval.Key, T][]>; + /** + * Gets a series of keys and values that match the specified + * set of options. + */ + each<T = any>(options: IdbKeyval.IQuery): Promise<[IdbKeyval.Key, T][]>; + /** + * Gets a series of keys only that match the specified set of options. + */ + each(options: IdbKeyval.IQuery, only: "keys"): Promise<IdbKeyval.Key[]>; + /** + * Gets a series of values only that match the specified set of options. + */ + each<T = any>(options: IdbKeyval.IQuery, only: "values"): Promise<T[]>; + /** */ + async each(options: IdbKeyval.IQuery = {}, only?: "keys" | "values"): Promise<any> { + const store = await this.get_store("readonly"); + const target = options.index ? store.index(options.index) : store; + const limit = options.limit; + const range = options.range; + + if (only === "keys") + return IdbKeyval.as_promise(target.getAllKeys(range, limit)); + + if (only === "values") + return IdbKeyval.as_promise(target.getAll(range, limit)); + + let keys: IdbKeyval.Key[] = []; + let values: any[] = []; + + await Promise.allSettled([ + new Promise<void>(async r => { + const results = await IdbKeyval.as_promise(target.getAllKeys(range, limit)); + keys.push(...results as IdbKeyval.Key[]); + r(); + }), + new Promise<void>(async r => { + const results = await IdbKeyval.as_promise(target.getAll(range, limit)); + values.push(...results); + r(); + }), + ]); + + const tuples: [IdbKeyval.Key, any][] = []; + + for (let i = -1; ++i < keys.length;) + tuples.push([keys[i], values[i]]); + + return tuples; + } + + /** + * Set a value with a key. + */ + async set(key: IdbKeyval.Key, value: any): Promise<void>; + /** + * Set multiple values at once. This is faster than calling set() multiple times. + * It's also atomic – if one of the pairs can't be added, none will be added. + * @param entries Array of entries, where each entry is an array of `[key, value]`. + */ + async set(entries: [IdbKeyval.Key, any][]): Promise<void>; + async set(a: any, b?: any) { + const store = await this.get_store("readwrite"); + if (Array.isArray(a)) { + for (const entry of (a as [IdbKeyval.Key, any][])) + store.put(entry[1], entry[0]); + + return IdbKeyval.as_promise(store.transaction); + } + + store.put(b, a); + return IdbKeyval.as_promise(store.transaction); + } + + /** + * Deletes all objects from this IdbKeyval database + * (but keeps the IdbKeyval database itself is kept). + */ + async delete(): Promise<void>; + /** + * Delete a single object from the store with the specified key. + */ + async delete(range: IDBKeyRange): Promise<void>; + /** + * Delete a single object from the store with the specified key. + */ + async delete(key: IdbKeyval.Key): Promise<void>; + /** + * Delete a series of objects from the store at once, with the specified keys. + */ + async delete(keys: IdbKeyval.Key[]): Promise<void>; + async delete(arg?: IdbKeyval.Key | IdbKeyval.Key[] | IDBKeyRange) { + const store = await this.get_store("readwrite"); + arg ??= IdbKeyval.unbound; + + if (Array.isArray(arg)) { + for (const key of arg) + store.delete(key); + } + else store.delete(arg); + + return IdbKeyval.as_promise(store.transaction); + } + + /** */ + private async get_store(mode: IDBTransactionMode) { + const db = await this.get_database(); + return db.transaction(this.name, mode).objectStore(this.name); + } + + /** */ + private async get_database() { + if (!this.database) { + await this.maybe_fix_safari(); + let quit = false; + let version: number | undefined; + let indexNamesAdded: string[] = []; + let indexNamesRemoved: string[] = []; + + for (; ;) { + const request = indexedDB.open(this.name, version); + request.onupgradeneeded = () => { + const db = request.result; + const tx = request.transaction!; + + const store = tx.objectStoreNames.contains(this.name) ? + tx.objectStore(this.name) : + db.createObjectStore(this.name); + + for (const index of indexNamesAdded) + store.createIndex(index, index); + + for (const index of indexNamesRemoved) + store.deleteIndex(index); + }; + this.database = await IdbKeyval.as_promise(request); + + if (quit) + break; + + const tx = this.database.transaction(this.name, "readonly"); + const store = tx.objectStore(this.name); + const indexNames = Array.from(store.indexNames).sort(); + tx.abort(); + + indexNamesAdded = this.indexes.filter(n => !indexNames.includes(n)); + indexNamesRemoved = indexNames.filter(n => !this.indexes.includes(n)); + + if (indexNamesAdded.length + indexNamesRemoved.length === 0) + break; + + quit = true; + this.database.close(); + version = this.database.version + 1; + } + } + + return this.database; + } + private database: IDBDatabase | null = null; + + /** + * Works around a Safari 14 bug. + * + * Safari has a bug where IDB requests can hang while the browser is + * starting up. https://bugs.webkit.org/show_bug.cgi?id=226547 + * The only solution is to keep nudging it until it's awake. + */ + private async maybe_fix_safari() { + if (!/Version\/14\.\d*\s*Safari\//.test(navigator.userAgent)) + return; + + let id: any = 0; + return new Promise<void>(resolve => { + const hit = () => indexedDB.databases().finally(resolve); + id = setInterval(hit, 50); + hit(); + }) + .finally(() => clearInterval(id)); + } + + /** */ + private static as_promise<T = undefined>(request: IDBRequest<T> | IDBTransaction) { + return new Promise<T>((resolve, reject) => { + // @ts-ignore + request.oncomplete = request.onsuccess = () => resolve(request.result); + + // @ts-ignore + request.onabort = request.onerror = () => reject(request.error); + }); + } +} + +namespace IdbKeyval { + /** */ + export interface IConstructorOptions { + /** + * Defines the name of the IndexedDB database as it is stored in the browser. + * Note that the name is prefixed with the IdbKeyval database prefix constant. + */ + name?: string | number; + + /** + * Defines the name or names of the index or indexes to define on the database. + */ + indexes?: string | string[]; + } + + /** */ + export interface IQuery { + /** + * A standard IDBKeyRange to use for the query. Worth noting that the methods + * in the static IdbKeyval.* namespace contain utility functions to ease the creation + * of IDBKeyRange objects. + */ + range?: IDBKeyRange; + + /** The name of the index to use for the query. */ + index?: string; + + /** A number which indicates the maximum number of objects to return from a query. */ + limit?: number; + } + + /** */ + export type Key = string | number | Date | BufferSource; +} diff --git a/apps-lib/src/lib/utils/keyval/lib.ts b/apps-lib/src/lib/utils/keyval/lib.ts @@ -0,0 +1,52 @@ +import { browser } from "$app/environment"; +import { _envLib } from "../_env"; +import { fmt_id } from "../app/lib"; +import { IdbKeyval } from "./idb"; + +export let idb_kv: IdbKeyval; +if (browser) idb_kv = new IdbKeyval({ name: _envLib.KEYVAL_NAME }); + +export const idb_kv_init = async (): Promise<void> => { + if (!browser) return; + const range = IdbKeyval.prefix(`*`); + const idb_kv_list = await idb_kv.each({ range }, `keys`); + await Promise.all(idb_kv_list.map((i) => idb_kv.delete(i))); +}; + +export const idb_kv_init_page = async (): Promise<void> => { + if (!browser) return; + const idb_kv_pref = fmt_id(); + const range = IdbKeyval.prefix(idb_kv_pref); + const idb_kv_list = await idb_kv.each({ range }, `keys`); + await Promise.all(idb_kv_list.map((i) => idb_kv.delete(i))); +}; + +export const idb_kv_sync = async (list: [string, string][]): Promise<void> => { + if (!browser) return; + for (const [key, val] of list) await idb_kv.set(key, val); +}; + +export class IdbLib<T extends string> { + private _idb: IdbKeyval; + + constructor(kv: IdbKeyval) { + this._idb = kv; + } + public init = async () => { + await idb_kv_init_page(); + } + + public save = async (key: T, value: string) => { + await this._idb.set(fmt_id(key), value); + } + + public read = async (key: T): Promise<string | undefined> => { + const result = await this._idb.get<string>(fmt_id(key)); + if (result) return result; + return undefined; + } + + public del = async (key: T) => { + await this._idb.delete(fmt_id(key)); + } +} +\ No newline at end of file diff --git a/apps-lib/src/lib/utils/lib.ts b/apps-lib/src/lib/utils/lib.ts @@ -1,190 +0,0 @@ -import { browser } from '$app/environment'; -import { goto } from '$app/navigation'; -import { win_h, win_w } from '$lib/stores/app'; -import type { CallbackRoute, NavigationParamTuple, NavigationRouteParamKey, NavigationRouteParamTuple, } from '$lib/types/lib'; -import type { ThemeLayer, ThemeMode } from '@radroots/themes'; -import type { FilePath } from '@radroots/utils'; -import { getContext, setContext } from "svelte"; -import { get } from "svelte/store"; - -export const symbols = { - bullet: '•', - dash: `—`, - up: `↑`, - down: `↓`, - percent: `%` -}; - -export const get_store = get; - -export const get_context = <M extends Record<string, any>, K extends keyof M>(key: K): M[K] => - getContext(key as string) as M[K]; - -export const set_context = <M extends Record<string, any>, K extends keyof M>(key: K, value: M[K]): void => - setContext(key as string, value); - -export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -export const trim_slashes = (path: string): string => - path.replace(/^\/+|\/+$/g, ''); - -export const normalize_path = (path: string): string => - path - .replace(/-/g, '_') - .replace(/\//g, '-') - .replace(/-+/g, '-'); - -export const sanitize_path = (id: string): string => - id.replace(/[^A-Za-z0-9_-]+/g, ''); - -export const fmt_id = (raw_id?: string): string => { - if (!browser) return ''; - const pathname = window.location.pathname; - const trimmed = trim_slashes(pathname); - const prefix = normalize_path(trimmed); - const suffix = raw_id ? `-${sanitize_path(raw_id)}` : ''; - return `*${prefix}${suffix}`; -}; - -export const view_effect = <T extends string>(view: T): void => { - if (!browser) return; - for (const el of document.querySelectorAll(`[data-view]`)) { - if (el.getAttribute(`data-view`) !== view) el.classList.add(`hidden`) - else el.classList.remove(`hidden`) - } -}; - -export const el_id = (id: string): HTMLElement | undefined => { - const el = document.getElementById(id); - return el ? el : undefined; -}; - -export const build_storage_key = ( - raw_id: string, - base_prefix: string -): string => - `${fmt_id()}-${sanitize_path(raw_id)}` - .replace(new RegExp(`^\\*${normalize_path(trim_slashes(base_prefix))}-?`), '*'); - -export const get_system_theme = (): ThemeMode => { - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; -}; - -export const theme_set = (theme_key: string, color_mode: ThemeMode): void => { - document.documentElement.setAttribute("data-theme", `${theme_key}_${color_mode}`); -}; -export const fmt_cl = (classes?: string): string => `${classes || ``}`; - -export const handle_err = async (e: unknown, fcall: string): Promise<void> => { - try { - console.log(`[handle_err] `, e, fcall) - /*return void await catch_err(e, fcall, async (opts) => { - console.log(`handle_err e `, e) - console.log(JSON.stringify(opts, null, 4), `handle_err opts`) - });*/ - } catch (e) { - console.log(`(handle_err) `, e) - } -}; - -export const window_set = (): void => { - if (!browser) return; - win_h.set(window.innerHeight); - win_w.set(window.innerWidth); -}; - -export const parse_layer = (layer?: number, layer_default?: ThemeLayer): ThemeLayer => { - switch (layer) { - case 0: - case 1: - case 2: - return layer; - default: - return layer_default ? layer_default : 0; - }; -}; - -export const value_constrain = (regex_charset: RegExp, value: string): string => { - return value - .split(``) - .filter((char) => regex_charset.test(char)) - .join(``); -}; - - -export const encode_query_params = <T extends string>(params_list: NavigationParamTuple<T>[] = []): string => { - let query = ""; - for (const [k, v] of params_list) { - if (k && v) { - if (query) query += `&`; - query += `${k.trim()}=${encodeURIComponent(v.trim())}`; - } - } - return query ? `?${query}` : ``; -}; - -export const encode_route = <TRoute extends string, TParam extends string>(route: TRoute, params_list?: NavigationParamTuple<TParam>[]): string => { - const query = encode_query_params(params_list); - if (!query) return route; - return `${route === `/` ? `/` : route.replace(/\/+$/, ``)}${query}`; -}; - -export const debounce = <T extends (...args: any[]) => void>( - fn: T, - delay: number -): T => { - let timeout: ReturnType<typeof setTimeout>; - return ((...args: any[]) => { - clearTimeout(timeout); - timeout = setTimeout(() => fn(...args), delay); - }) as T; -}; - -export const create_router = <T extends string>() => { - const router = async (nav_route: T, params: NavigationRouteParamTuple[] = []): Promise<void> => { - try { - if (params.length) await goto(encode_route<T, NavigationRouteParamKey>(nav_route, params)); - else await goto(nav_route); - } catch (e) { - handle_err(e, `route`); - }; - }; - return router; -}; - -export const get_locale = (locales: string[]): string => { - const { language: navigator_locale } = navigator; - let locale = `en`; - if (locales.some(i => i === navigator_locale.toLowerCase())) locale = navigator.language; - else if (locales.some(i => i === navigator_locale.slice(0, 2).toLowerCase())) locale = navigator_locale.slice(0, 2); - return locale.toLowerCase(); -}; - -export const callback_route = async <T extends string>(callback_route: CallbackRoute<T>): Promise<void> => { - if (`route` in callback_route) { - if (typeof callback_route.route === `string`) return void await goto(callback_route.route); - else return void await goto( - encode_route<string, NavigationRouteParamKey>( - callback_route.route[0], - callback_route.route[1], - ), - ); - } - return void await callback_route(); -}; - -export const to_arr_buf = (u8: Uint8Array): ArrayBuffer => { - return u8.slice().buffer; -}; - -export const parse_file_path = (file_path: string): FilePath | undefined => { - const file_path_spl = file_path.split(`/`); - const file_path_file = file_path_spl[file_path_spl.length - 1] || ``; - const [file_name, mime_type] = file_path_file.split(`.`); - if (!file_name || !mime_type) return undefined; - return { - file_path, - file_name, - mime_type - }; -}; -\ No newline at end of file