app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 88488fc67623eea19c2a8b34646e28b1638b1dff
parent f1e753b56e70e4a303911ab14abb6aadbf7b5f11
Author: triesap <triesap@radroots.dev>
Date:   Sun, 21 Dec 2025 01:37:29 +0000

Standardized code directives, added media configuration, refreshed dependencies, optimized localization access, enabled dynamic titles, revamped profile photo uploads.

Diffstat:
MAGENTS.md | 48++++++++++++++++++++++++++++--------------------
Mapp/.env.example | 1+
Mapp/src/app.html | 2--
Mapp/src/lib/_env.ts | 4++++
Mapp/src/lib/utils/app/index.ts | 7++++---
Mapp/src/lib/utils/backup/export.ts | 10+++++-----
Mapp/src/lib/utils/backup/import.ts | 34+++++++++++++++++-----------------
Mapp/src/lib/utils/config.ts | 4++--
Mapp/src/routes/(app)/profile/+page.svelte | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mapp/src/routes/(cfg)/setup/+page.svelte | 2+-
Mapp/src/routes/+layout.svelte | 9+++++++++
11 files changed, 167 insertions(+), 85 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md @@ -1,30 +1,38 @@ -# Rad Roots - Agricultural Network Application - -#### Code Rules +# Rad Roots - Code Directives 1. TypeScript -- Use strict TypeScript at all times. -- Never use as any, any, unknown as, or any weakening of types. +- Use strict TypeScript. +- Do not use any, unknown casts, or weaken types. - Prefer explicit interfaces and types. -- Use enums or literal unions when appropriate. -- Avoid default exports unless legacy structure requires it. +- Use enums or literal unions when clear. +- Use named exports; avoid default exports. + +2. Naming +- Variables and functions use snake_case. +- Types, interfaces, and enums use PascalCase. +- Prefer layered prefixes to namespace meaning: domain_object_action, e.g. nostr_keys_gen(), NostrKeysGen. +- Do not enforce naming conventions in _env*.ts files. +- Constant data structures must use ALL_CAPS_SNAKE_CASE. -2. Source Code Output -- No inline comments, block comments, or JSDoc. -- Do not generate explanatory comments in any code. -- Code must stay deterministic and reproducible. -- Write source code using snake_case conventions, while type names use PascalCase. +3. Source Code +- Keep code deterministic and reproducible. +- Do not add source code comments. +- Anchor comments are allowed only when they start with @ (e.g. // @todo). +- /* */ blocks are allowed only to disable features during development and must not include descriptive text. +- <!-- --> HTML blocks are allowed only to disable features during development or compiler/lint suppression (e.g. <!-- svelte-ignore ... -->). -1. DRY and Modularity -- Do not duplicate logic within or across projects. -- Extract shared logic into the appropriate package under packages/. -- Prefer small, focused modules. -- Shared utilities must live in shared packages, not in apps. +1. Modularity +- Do not duplicate logic. +- Put shared or generalizable code in packages/. +- Apps should rely on packages/ for shared utilities. +- Treat @radroots/*-bindings as generated from .rs crates; do not edit or format their .ts outputs. If issues arise, change upstream .rs or report the error instead. -1. Functional and Architectural Cleanliness +1. Architecture - Prefer pure functions. - Prefer composition over inheritance. -- Avoid side effects unless explicitly needed. +- Avoid side effects unless required. - Avoid global mutable state. - +1. Change Policy +- Apply breaking changes when needed. +- Do not add legacy or shim fixes. diff --git a/app/.env.example b/app/.env.example @@ -1,5 +1,6 @@ VITE_PUBLIC_DEFAULT_RELAYS= VITE_PUBLIC_RADROOTS_API= +VITE_PUBLIC_RADROOTS_MEDIA= VITE_PUBLIC_KEYVAL_NAME= VITE_PUBLIC_NDK_CACHE= VITE_PUBLIC_NDK_CLIENT= diff --git a/app/src/app.html b/app/src/app.html @@ -4,8 +4,6 @@ <head> <meta charset="utf-8" /> - <title>Rad Roots</title> - <link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="icon" href="%sveltekit.assets%/favicon.ico" /> diff --git a/app/src/lib/_env.ts b/app/src/lib/_env.ts @@ -4,6 +4,9 @@ if (!DEFAULT_RELAYS || typeof DEFAULT_RELAYS !== 'string') throw new Error('Miss const RADROOTS_API = import.meta.env.VITE_PUBLIC_RADROOTS_API; if (!RADROOTS_API || typeof RADROOTS_API !== 'string') throw new Error('Missing env var: VITE_PUBLIC_RADROOTS_API'); +const RADROOTS_MEDIA = import.meta.env.VITE_PUBLIC_RADROOTS_MEDIA; +if (!RADROOTS_MEDIA || typeof RADROOTS_MEDIA !== 'string') throw new Error('Missing env var: VITE_PUBLIC_RADROOTS_MEDIA'); + 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'); @@ -37,5 +40,6 @@ export const _env = { PLATFORM_DESCRIPTION, PLATFORM_NAME, RADROOTS_API, + RADROOTS_MEDIA, RADROOTS_RELAY, } as const; diff --git a/app/src/lib/utils/app/index.ts b/app/src/lib/utils/app/index.ts @@ -17,6 +17,8 @@ import type { CallbackPromise } from "@radroots/utils"; import { reset_sql_cipher } from "./cipher"; import type { NavigationRoute } from "./routes"; +const ls_val = get_store(ls); + export const datastore = new WebDatastore( cfg_datastore_key_map, cfg_datastore_key_param_map, @@ -69,9 +71,8 @@ export const restart = async (opts?: { export const reset = async (): Promise<void> => { try { - const $ls = get_store(ls); const confirm = await notif.confirm({ - message: `${$ls(`notification.device.reset`)}. ${$ls(`common.this_action_is_irreversible`)}. ${$ls(`common.do_you_want_to_continue_q`)}` + message: `${ls_val(`notification.device.reset`)}. ${ls_val(`common.this_action_is_irreversible`)}. ${ls_val(`common.do_you_want_to_continue_q`)}` }); if (!confirm) return; await nostr_keys.reset(); @@ -79,7 +80,7 @@ export const reset = async (): Promise<void> => { await reset_sql_cipher(db.get_store_key()); await db.reinit(); goto(`/`); - app_notify.set(`${$ls(`notification.device.reset_complete`)}`); + app_notify.set(`${ls_val(`notification.device.reset_complete`)}`); } catch (e) { handle_err(e, `reset`); } diff --git a/app/src/lib/utils/backup/export.ts b/app/src/lib/utils/backup/export.ts @@ -6,7 +6,7 @@ import { throw_err } from "@radroots/utils"; import { createStore, get as idb_get, keys as idb_keys, type UseStore } from "idb-keyval"; import { app_cfg } from "../app/config"; -const $ls = get_store(ls); +const ls_val = get_store(ls); const parse_idb_entry = async (key: IDBValidKey, store: UseStore): Promise<[string, string] | undefined> => { if (typeof key !== "string") return undefined; @@ -18,7 +18,7 @@ const parse_idb_entry = async (key: IDBValidKey, store: UseStore): Promise<[stri const export_datastore_state = async (): Promise<ExportedAppState["datastore"]> => { await datastore.init(); const ds_cfg = datastore.get_config(); - if (!ds_cfg) throw_err($ls(`error.app.export.missing_datastore_config`)); + if (!ds_cfg) throw_err(ls_val(`error.app.export.missing_datastore_config`)); const ds_store = createStore(ds_cfg.database, ds_cfg.store); const ds_keys = await idb_keys(ds_store); const entries: Record<string, unknown> = {}; @@ -31,7 +31,7 @@ const export_datastore_state = async (): Promise<ExportedAppState["datastore"]> const export_nostr_keystore_state = async (): Promise<ExportedAppState["nostr_keystore"]> => { const nostr_cfg = nostr_keys.get_config(); - if (!nostr_cfg) throw_err($ls(`error.app.export.missing_nostr_keystore_config`)); + if (!nostr_cfg) throw_err(ls_val(`error.app.export.missing_nostr_keystore_config`)); const keys: ExportedAppState["nostr_keystore"]["keys"] = []; try { @@ -84,10 +84,10 @@ export const export_app_state = async (): Promise<void> => { database: tangle_db_state }; const ts = payload.exported_at.replace(/[:.]/g, "-"); - const filename_prefix = $ls(`common.radroots_app_state_filename_prefix`); + const filename_prefix = ls_val(`common.radroots_app_state_filename_prefix`); download_json(payload, `${filename_prefix}-${ts}.json`); } catch (e) { handle_err(e, `export_app_state`); - await notif.alert(`${$ls(`error.backup.export_failure`)}`); + await notif.alert(`${ls_val(`error.backup.export_failure`)}`); } }; diff --git a/app/src/lib/utils/backup/import.ts b/app/src/lib/utils/backup/import.ts @@ -9,7 +9,7 @@ import { createStore, set as idb_set } from "idb-keyval"; import { reset_sql_cipher } from "../app/cipher"; import { app_cfg } from "../app/config"; -const $ls = get_store(ls); +const ls_val = get_store(ls); export type AppImportStateResult = { pass: boolean; @@ -23,7 +23,7 @@ const assert_config_match = ( ): void => { if (current.database !== incoming.database || current.store !== incoming.store) { throw_err( - $ls(`error.configuration.import.storage_mismatch`, { + ls_val(`error.configuration.import.storage_mismatch`, { label, app_database: current.database, app_store: current.store, @@ -36,15 +36,15 @@ const assert_config_match = ( export const validate_import_file = async (file: File | null): Promise<ImportableAppState> => { const parsed: any = await parse_file_json(file) - if (!parsed) throw_err($ls(`error.configuration.import.invalid_file_contents`)) + if (!parsed) throw_err(ls_val(`error.configuration.import.invalid_file_contents`)) return await validate_import_state(parsed); }; export const validate_import_state = async (state: any): Promise<ImportableAppState> => { - if (!state || typeof state !== "object") throw_err($ls(`error.configuration.import.empty_file`)); + if (!state || typeof state !== "object") throw_err(ls_val(`error.configuration.import.empty_file`)); if (state.backup_version !== app_cfg.backup.version) { throw_err( - $ls(`error.configuration.import.unsupported_backup_version`, { + ls_val(`error.configuration.import.unsupported_backup_version`, { backup_version: state.backup_version, expected_version: app_cfg.backup.version, }) @@ -52,18 +52,18 @@ export const validate_import_state = async (state: any): Promise<ImportableAppSt } const backup_format = state?.versions?.backup_format ?? state?.versions?.dump_format; if (!state.versions || !state.versions.app || !state.versions.tangle_sql || !backup_format) { - throw_err($ls(`error.configuration.import.missing_version_metadata`)); + throw_err(ls_val(`error.configuration.import.missing_version_metadata`)); } const database = state.database ?? state.tangle_db; const backup = database?.backup ?? database?.dump; if (!state.datastore || !state.nostr_keystore || !database) { - throw_err($ls(`error.configuration.import.missing_required_sections`)); + throw_err(ls_val(`error.configuration.import.missing_required_sections`)); } if (!backup || backup.format_version !== backup_format) { - throw_err($ls(`error.configuration.import.database_format_mismatch`)); + throw_err(ls_val(`error.configuration.import.database_format_mismatch`)); } if (backup.tangle_sql_version !== state.versions.tangle_sql) { - throw_err($ls(`error.configuration.import.tangle_sql_version_mismatch`)); + throw_err(ls_val(`error.configuration.import.tangle_sql_version_mismatch`)); } return { ...state, @@ -75,7 +75,7 @@ export const validate_import_state = async (state: any): Promise<ImportableAppSt const restore_datastore_state = async (state: ImportableAppState["datastore"]): Promise<void> => { const ds_cfg = datastore.get_config(); - assert_config_match(ds_cfg, state.config, $ls(`common.datastore`)); + assert_config_match(ds_cfg, state.config, ls_val(`common.datastore`)); const reset_res = await datastore.reset(); if (reset_res && "err" in reset_res) throw_err(reset_res.err); const store = createStore(ds_cfg.database, ds_cfg.store); @@ -89,7 +89,7 @@ const restore_nostr_keystore_state = async ( state: ImportableAppState["nostr_keystore"] ): Promise<void> => { const nostr_cfg = nostr_keys.get_config(); - assert_config_match(nostr_cfg, state.config, $ls(`common.nostr_keystore`)); + assert_config_match(nostr_cfg, state.config, ls_val(`common.nostr_keystore`)); const reset_res = await nostr_keys.reset(); if (reset_res && "err" in reset_res) throw_err(reset_res.err); for (const key of state.keys) { @@ -102,7 +102,7 @@ const restore_tangle_db_state = async (state: ImportableAppState["database"]): P const current_store_key = db.get_store_key(); if (current_store_key !== state.store_key) { throw_err( - $ls(`error.configuration.import.tangle_store_key_mismatch`, { + ls_val(`error.configuration.import.tangle_store_key_mismatch`, { app_store_key: current_store_key, backup_store_key: state.store_key, }) @@ -115,13 +115,13 @@ const restore_tangle_db_state = async (state: ImportableAppState["database"]): P export const import_app_state = async (payload: ImportableAppState): Promise<AppImportStateResult | IError<string>> => { try { - if (typeof window === "undefined") return err_msg($ls(`error.client.undefined_window`)); + if (typeof window === "undefined") return err_msg(ls_val(`error.client.undefined_window`)); const import_state = await validate_import_state(payload); - assert_config_match(datastore.get_config(), import_state.datastore.config, $ls(`common.datastore`)); - assert_config_match(nostr_keys.get_config(), import_state.nostr_keystore.config, $ls(`common.nostr_keystore`)); + assert_config_match(datastore.get_config(), import_state.datastore.config, ls_val(`common.datastore`)); + assert_config_match(nostr_keys.get_config(), import_state.nostr_keystore.config, ls_val(`common.nostr_keystore`)); const current_store_key = db.get_store_key(); if (current_store_key !== import_state.database.store_key) { - const message = $ls(`error.configuration.import.tangle_store_key_mismatch`, { + const message = ls_val(`error.configuration.import.tangle_store_key_mismatch`, { app_store_key: current_store_key, backup_store_key: import_state.database.store_key, }); @@ -135,7 +135,7 @@ export const import_app_state = async (payload: ImportableAppState): Promise<App return { pass: true } } catch (e) { handle_err(e, `import_app_state`); - return { pass: false, message: $ls(`error.configuration.import.failure`) } + return { pass: false, message: ls_val(`error.configuration.import.failure`) } } }; diff --git a/app/src/lib/utils/config.ts b/app/src/lib/utils/config.ts @@ -1,4 +1,4 @@ -import { _envLib } from "@radroots/apps-lib"; +import { _env_lib } from "@radroots/apps-lib"; import type { AppConfigRole } from "@radroots/apps-lib-pwa/types/app"; import { root_symbol } from "@radroots/utils"; import type { NostrEventTagClient } from "@radroots/utils-nostr"; @@ -32,7 +32,7 @@ export const cfg_delay = { }; export const cfg_nostr = { - relay_url: _envLib.RADROOTS_RELAY, + relay_url: _env_lib.RADROOTS_RELAY, relay_pubkey: radroots_nostr_pubkey, relay_polling_count_max: 10, }; diff --git a/app/src/routes/(app)/profile/+page.svelte b/app/src/routes/(app)/profile/+page.svelte @@ -4,7 +4,7 @@ import { ndk_user, parse_file_path } from "@radroots/apps-lib"; import { Profile } from "@radroots/apps-lib-pwa"; import type { IViewProfileData } from "@radroots/apps-lib-pwa/types/views/profile"; - import { handle_err, throw_err, type FilePath } from "@radroots/utils"; + import { handle_err, throw_err } from "@radroots/utils"; import { onMount } from "svelte"; let data: IViewProfileData | undefined = $state(undefined); @@ -14,13 +14,19 @@ onMount(async () => { try { + // await init(); + await db.init(); data = await load_data(); } catch (e) { handle_err(e, `on_mount`); - await route(`/`); + // await route(`/`); } }); + const init = async (): Promise<void> => { + await db.init(); + }; + const load_data = async (): Promise<IViewProfileData | undefined> => { const nostr_profile = await db.nostr_profile_find_one({ on: { @@ -28,7 +34,6 @@ }, }); if ("err" in nostr_profile) throw_err(nostr_profile); - //await nostr_sync.metadata({ metadata: tb_nostr_profile.result }); // no await return { profile: nostr_profile.result }; }; @@ -47,7 +52,7 @@ public_key: $ndk_user?.pubkey, }, }); - if ("err" in tb_nostrprofile) throw_err(tb_nostrprofile); //@todo + if ("err" in tb_nostrprofile) throw_err(tb_nostrprofile); // @todo /*await nostr_sync.metadata({ metadata: tb_nostrprofile.result, }); // no await */ @@ -61,7 +66,7 @@ return void (await route(`/`)); const nostr_key = await nostr_keys.read($ndk_user.pubkey); if ("err" in nostr_key) throw_err(nostr_key); - if (photo_path) { + /*if (photo_path) { const confirm = await notif.confirm({ message: is_photo_existing ? `${$ls( @@ -76,50 +81,106 @@ : `${$ls(`common.continue`)}`, }); if (!confirm) return void (await route(`/`)); - } + }*/ + loading_photo_upload = true; - let upload_file_path: FilePath | undefined = undefined; - parse_file_path(photo_path); + const parsed_photo_path = parse_file_path(photo_path); + if (!parsed_photo_path) throw_err("error.upload.no_photo_path"); + + const upload_path = + "blob_path" in parsed_photo_path + ? parsed_photo_path.blob_path + : parsed_photo_path.file_path; + const upload_name = + "blob_path" in parsed_photo_path + ? parsed_photo_path.blob_name + : parsed_photo_path.file_name; + const profile_photo_curr = await db.media_image_find_one({ on: { - file_path: photo_path, + file_path: upload_name, }, }); - if ("result" in profile_photo_curr) - upload_file_path = parse_file_path( - profile_photo_curr.result.file_path, - ); - else upload_file_path = parse_file_path(photo_path); - if (!upload_file_path) throw_err(`error.util.parse_file_path`); - const file_data = await fs.read_bin(upload_file_path.file_path); - if ("err" in file_data) throw_err(file_data); - const res_fetch_media_image_upload = - await radroots.media_image_upload({ - file_path: upload_file_path, - file_data, - secret_key: nostr_key.secret_key, - }); - if ("err" in res_fetch_media_image_upload) - throw_err(res_fetch_media_image_upload); - const { res_base: upload_res_base, res_path: upload_res_path } = - res_fetch_media_image_upload; - const tb_media_image_create = await db.media_image_create({ - file_path: upload_file_path.file_path, - res_base: upload_res_base, - res_path: upload_res_path, - mime_type: upload_file_path.mime_type, + if ("err" in profile_photo_curr) throw_err(profile_photo_curr); + if ( + "result" in profile_photo_curr && + profile_photo_curr.result + ) { + alert("todo, photo already added"); + return; + //upload_file_path = parse_file_path( + // profile_photo_curr.result.file_path, + //); + } + + const upload_bin = await fs.read_bin(upload_path); + if ("err" in upload_bin) throw_err(upload_bin); + + const media_upload = await radroots.media_image_upload({ + file_data: upload_bin, + secret_key: nostr_key.secret_key, }); - if ("err" in tb_media_image_create) - throw_err(tb_media_image_create); + console.log( + JSON.stringify(media_upload, null, 4), + `media_upload`, + ); + if ("err" in media_upload) throw_err(media_upload); + + const media_image = await db.media_image_create({ + file_path: upload_path, + res_base: media_upload.base_url, + res_path: media_upload.hash, + mime_type: media_upload.ext, + }); + console.log( + JSON.stringify(media_image, null, 4), + `media_image`, + ); + + if ("err" in media_image) throw_err(media_image); + const tb_nostr_profile_update = await db.nostr_profile_update({ on: { public_key: $ndk_user.pubkey }, fields: { - picture: `${upload_res_base}/${upload_res_path}.${upload_file_path.mime_type}`, + picture: `${media_upload.base_url}/${media_upload.hash}.${media_upload.ext}`, }, }); + console.log( + JSON.stringify(tb_nostr_profile_update, null, 4), + `tb_nostr_profile_update`, + ); if ("err" in tb_nostr_profile_update) throw_err(tb_nostr_profile_update); await route(`/`); + + //let upload_bin: WebFilePath | undefined = undefined; + //if ("blob_path" in upload_path) + // + // else upload_bin = await fs.read_bin(upload_path.file_path); + + /* + + console.log(`photo_path `, photo_path); + + console.log(`photo_path_bin `, photo_path_bin); + + let file_path_upload: FilePath | undefined = undefined; + console.log(`upload_file_path `, upload_file_path); + + + + console.log( + JSON.stringify(profile_photo_curr, null, 4), + `profile_photo_curr`, + ); + + + else upload_file_path = parse_file_path(photo_path); + if (!upload_file_path) throw_err(`error.util.parse_file_path`); + const file_data = await fs.read_bin(upload_file_path.file_path); + if ("err" in file_data) throw_err(file_data); + + */ } catch (e) { handle_err(e, `on_handle_back`); } finally { diff --git a/app/src/routes/(cfg)/setup/+page.svelte b/app/src/routes/(cfg)/setup/+page.svelte @@ -75,7 +75,7 @@ let profile_name_val = $state(``); let profile_name_valid = $state(false); - let profile_name_nip05 = $state(false); + let profile_name_nip05 = $state(true); let profile_name_loading = $state(false); let is_eula_scrolled = $state(false); diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { dev, version as kit_version } from "$app/environment"; + import { page } from "$app/state"; import { db, geoc } from "$lib/utils/app"; import { app_cfg } from "$lib/utils/app/config"; import { @@ -24,6 +25,7 @@ import { app_lo } from "@radroots/apps-lib-pwa/stores/app"; import { cfg_app } from "@radroots/apps-lib-pwa/utils/app"; import { parse_theme_key, parse_theme_mode } from "@radroots/themes"; + import { str_cap_words } from "@radroots/utils"; import "css-paint-polyfill"; import { onMount } from "svelte"; import "../app.css"; @@ -88,9 +90,16 @@ await db.init(); await geoc.connect(); }); + + const format_title = (title: string): string => { + return str_cap_words(title.replaceAll(`/`, ` `)); + }; + + const head_title = $derived(format_title(page.url.pathname)); </script> <svelte:head> + <title>{`${head_title || "Home"} | Rad Roots`}</title> {#each head_meta_tags as meta_tag (meta_tag.name)} <meta name={meta_tag.name} content={meta_tag.content} /> {/each}