diff --git a/.gitignore b/.gitignore index 6d1f429..57b10a5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ dist.tar.gz # auto-generated build file for PWA dev-dist/sw.js +public/icons/ aio-shadcn-vite.code-workspace dev-dist .specstory/history diff --git a/CLAUDE.md b/CLAUDE.md index 5b890de..1ab8ab6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -712,8 +712,63 @@ VITE_ADMIN_PUBKEYS='["pubkey1","pubkey2"]' # Optional: Disable WebSocket if needed VITE_WEBSOCKET_ENABLED=true + +# Brand kit override (defaults to ./branding/default) +BRAND_DIR=branding/cfaun + +# Per-standalone brand override (set by build pipeline, not directly) +BRAND_APP=events ``` +## Brand kit (white-label PWA branding) + +The webapp ships a brand kit architecture so the hub + every standalone +(events, wallet, chat, market, …) can be rebranded per deployment without +forking the codebase. See `branding/README.md` for the deployer contract. + +**Single source of truth:** `branding//` holds `logo.{svg,png}` + +`brand.json`. `vite-branding.ts` reads brand.json and exposes a `@brand` +import alias. `pwa-assets.config.ts` + `@vite-pwa/assets-generator` derive +the full PWA icon set from the single logo source. + +**brand.json schema:** `{ name, shortName?, themeColor?, backgroundColor? }` +— `name` drives the manifest. `themeColor`/`backgroundColor` are optional +chrome overrides; when unset, each standalone's per-app accent applies. + +**In-app logo:** components reference `@brand/logo.png`. Active consumers: +`Login.vue`, `LoginDemo.vue`, `AppSidebar.vue`, `MobileDrawer.vue`. The +Vite alias resolves to the active brand dir at build time. + +**Generated icons:** `public/icons/` is gitignored. `brandAssetsPlugin()` +(registered first in every `vite.*.config.ts`'s plugins[]) runs the +generator once per build/dev start via `buildStart`. Outputs match the +existing filename convention (`icon-192.png`, `icon-maskable-512.png`, +…) so HTML `` hrefs and VitePWA `manifest.icons` reference +`/icons/` consistently across all 9 configs. + +**Per-standalone override:** `branding//icons//logo.{svg,png}` +is checked before the brand's primary logo. The standalone build sets +`BRAND_APP`; deployers just put files in the right place. + +**Switching brands:** +```bash +BRAND_DIR=branding/cfaun pnpm build:events +``` + +**Adding a new in-app logo consumer:** use `` +instead of `@/assets/logo.png`. The latter still works for non-brand +assets (`@/assets/bitcoin.svg`, etc.) — it's only the logo that moved. + +**NixOS deployment:** `flake.nix` exposes +`lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }`. +Server-deploy hosts call it from their `services/webapp.nix`: +`inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; }`. +Builder pins `pkgs.pnpm_10` regardless of consumer's nixpkgs (keeps +pnpmDeps hash stable downstream), uses `autoPatchelfHook` to handle +prebuilt sharp binaries, and sets `CI=true` to bypass pnpm's +interactive modules-purge prompt. Per-host migration tracked in +aiolabs/server-deploy#8. + ## Payment Rails Pattern Shared primitives for modules that mix Lightning + fiat (and, future, diff --git a/branding/README.md b/branding/README.md new file mode 100644 index 0000000..b84ba6c --- /dev/null +++ b/branding/README.md @@ -0,0 +1,137 @@ +# Brand kit + +This directory holds the **white-label brand kit** that drives the PWA's icons, manifest name/colors, and in-app `` logo across the hub and every standalone (events, wallet, chat, market, …). + +The committed `default/` is aiolabs's brand and is what unparameterized builds use. Downstream deployers add a sibling directory (e.g. `branding/cfaun/`) and point `BRAND_DIR` at it to ship a fully rebranded build with no fork required. + +## Directory layout + +``` +branding/ + README.md + default/ # aiolabs default, committed + logo.svg # preferred source (sharp at every size) + logo.png # fallback source (≥ 1024×1024 if PNG-only) + brand.json # { name, shortName?, themeColor?, backgroundColor? } + icons/ # optional per-standalone overrides + events/logo.svg + wallet/logo.png + cfaun/ # downstream deployer's brand (gitignored or in deploy repo) + logo.svg + brand.json +``` + +aiolabs's `default/` currently ships PNG-only (1024×1024). Replace with `logo.svg` when a vector source becomes available — produces sharper icons at every size and unlocks `favicon.svg`. + +## Source formats + +**SVG strongly preferred:** + +- Crisp at every output size (192 / 512 maskable / 180 apple / 48 favicon) +- Enables sharp `favicon.svg` for modern browsers +- The in-app `@brand/logo` reference can be tinted via CSS (`currentColor`, filters) + +**PNG accepted with constraints:** + +- **≥ 1024×1024** — smaller sources produce blurry icons on high-DPI Android install screens +- **Square aspect ratio** — PWA icon canvas is square +- **Transparent background** — the generator applies maskable/apple background colors itself +- PNG-source deployments lose the `favicon.svg` benefit and the recolorable in-app logo + +When both `logo.svg` and `logo.png` are present, SVG wins. + +## brand.json schema + +```jsonc +{ + "name": "AIO", // required — drives PWA manifest name + "shortName": "AIO", // optional — PWA home-screen label; defaults to `name` + "themeColor": "#1f2937", // optional — PWA chrome color override (otherwise each standalone keeps its accent) + "backgroundColor": "#fff" // optional — PWA splash background +} +``` + +`themeColor` and `backgroundColor` are *overrides*, not defaults. When unset, each standalone's own accent applies (wallet yellow `#eab308`, chat green `#16a34a`, …) — so the default brand kit preserves the per-app visual identity, and a deployer who wants unified chrome adds the override. + +## Per-standalone overrides + +Place a logo at `branding//icons//logo.{svg,png}` to override the brand's primary logo for a single standalone build. + +Resolution at build time: + +1. `branding//icons//logo.svg` +2. `branding//icons//logo.png` +3. `branding//logo.svg` +4. `branding//logo.png` +5. Build fails with a clear error pointing here. + +`` is set via `BRAND_APP` env var (the standalone build script sets this; deployers don't touch it directly). + +## How to use + +**Building with the default brand:** + +```bash +pnpm build # main shell +pnpm build:events # events standalone +# … one per standalone +``` + +**Building with a deployer's brand:** + +```bash +BRAND_DIR=branding/cfaun pnpm build:events +``` + +`BRAND_DIR` accepts relative paths (resolved from the webapp repo root) or absolute paths (used by the NixOS builder, which mounts the brand directory into the sandbox at a `/nix/store/...-branding` path). + +**Regenerating icons explicitly:** + +The Vite plugin auto-runs the generator on every build/dev start. To run it standalone: + +```bash +pnpm generate-pwa-assets +``` + +Outputs land in `public/icons/` (gitignored). + +## Build pipeline + +1. `BRAND_DIR` is resolved (defaults to `./branding/default`). +2. `vite-branding.ts` reads `brand.json` and exposes `@brand/` alias. +3. `brandAssetsPlugin()` (registered in every `vite.*.config.ts`) runs `scripts/generate-pwa-assets.mjs` once per build via `buildStart`. +4. The script stages the source logo into `public/icons/.brand-source.{svg,png}`, runs `pwa-assets-generator`, then deletes the staged source. +5. Vite copies `public/icons/` into `dist/icons/`. Manifest references `icons/.png`. HTML `` tags reference `/icons/.{ico,png}`. + +## Integration with NixOS deployment + +`flake.nix` exposes `lib.mkWebapp { pkgs, brandDir ? ./branding/default, app ? "main" }` for downstream consumers. Per-host wiring in `deploy/server-deploy/hosts//services/webapp.nix` looks like: + +```nix +{ inputs, pkgs, ... }: +{ + services.webapp.apps = { + main = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; }; + events = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "events"; }; + wallet = inputs.webapp.lib.mkWebapp { inherit pkgs; brandDir = ./../branding; app = "wallet"; }; + }; +} +``` + +`brandDir` is either a path inside this flake (`./branding/`) or an external path (e.g. `./../branding` from server-deploy). Either way Nix copies it into the build sandbox. + +Builder details: +- Uses `pkgs.pnpm_10` regardless of consumer's nixpkgs, so the pnpmDeps hash stays stable across downstream nixpkgs versions. +- `pkgs.autoPatchelfHook` + `stdenv.cc.cc.lib` patch the prebuilt `@img/sharp-libvips-linux-*` binaries. +- `CI=true` bypasses pnpm 10's interactive modules-purge prompt in the sandbox. + +The architectural payoff: brand and code become independent axes. Logo changes ship via server-deploy commits + redeploys — no webapp release, no `flake.lock` bump. + +For local sanity: + +```bash +nix build .#main # hub with aiolabs default brand +nix build .#events # events standalone with aiolabs default +# events with a custom brand (the impure way, ad-hoc): +nix build --impure --expr 'let pkgs = import {}; flake = builtins.getFlake (toString ./.); in flake.lib.mkWebapp { inherit pkgs; brandDir = /path/to/brand; app = "events"; }' +``` diff --git a/branding/default/brand.json b/branding/default/brand.json new file mode 100644 index 0000000..2fc12c4 --- /dev/null +++ b/branding/default/brand.json @@ -0,0 +1,4 @@ +{ + "name": "AIO", + "shortName": "AIO" +} diff --git a/branding/default/logo.png b/branding/default/logo.png new file mode 100644 index 0000000..becf3b5 Binary files /dev/null and b/branding/default/logo.png differ diff --git a/chat.html b/chat.html index fb83dee..1f0bdfd 100644 --- a/chat.html +++ b/chat.html @@ -6,9 +6,8 @@ - - - + + Chat — Encrypted diff --git a/events.html b/events.html index ef24349..801e609 100644 --- a/events.html +++ b/events.html @@ -6,9 +6,8 @@ - - - + + %VITE_APP_NAME% diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8f6b10e --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780749050, + "narHash": "sha256-3av0pIjlOWQ6rDbNOmpUSvbNnJkGORQKKjb4LtCZsIY=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a799d3e3886da994fa307f817a6bc705ae538eeb", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..b937be4 --- /dev/null +++ b/flake.nix @@ -0,0 +1,100 @@ +{ + description = "AIO webapp — modular Vue 3 + Vite shell with Lightning + Nostr standalones"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + let + apps = [ "main" "events" "wallet" "chat" "market" "forum" "tasks" "restaurant" "libra" ]; + + mkWebapp = { pkgs, brandDir ? ./branding/default, app ? "main" }: + let + buildScript = if app == "main" then "build" else "build:${app}"; + outDir = if app == "main" then "dist" else "dist-${app}"; + in + pkgs.stdenv.mkDerivation (finalAttrs: { + pname = "aio-webapp-${app}"; + version = "0.0.0"; + + src = ./.; + + # Pin pnpm major version (10.x) regardless of consumer's nixpkgs + # so the pnpmDeps hash stays stable for downstream callers that + # bring their own pkgs. package.json's packageManager field + # declares pnpm@10.33.0; pnpm_10 satisfies that. + pnpm = pkgs.pnpm_10; + + pnpmDeps = pkgs.fetchPnpmDeps { + inherit (finalAttrs) pname version src; + inherit (finalAttrs) pnpm; + fetcherVersion = 3; + hash = "sha256-FUN2lMHsaBTkk1tljDysYZAoQD+5MIBIEvGnRUWiF4s="; + }; + + nativeBuildInputs = [ + pkgs.nodejs + finalAttrs.pnpm + pkgs.pnpmConfigHook + pkgs.autoPatchelfHook + ]; + + # sharp's prebuilt libvips binaries (under @img/sharp-libvips-*) + # are dynamically linked; autoPatchelfHook needs the runtime libs. + buildInputs = [ + pkgs.stdenv.cc.cc.lib + ]; + + # Brand kit env knobs read by vite-branding.ts and + # pwa-assets.config.ts. brandDir is either ./branding/default + # (a path inside this flake's source) or an external path that + # nix has copied into the build sandbox. + env = { + BRAND_DIR = "${brandDir}"; + BRAND_APP = if app == "main" then "" else app; + # Avoid pnpm 10's interactive modules-purge prompt in the + # sandbox (ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY). + CI = "true"; + }; + + buildPhase = '' + runHook preBuild + pnpm run ${buildScript} + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out + cp -r ${outDir} $out/ + runHook postInstall + ''; + + meta = with pkgs.lib; { + description = "AIO webapp${if app == "main" then "" else " (${app} standalone)"}"; + homepage = "https://git.atitlan.io/aiolabs/webapp"; + license = licenses.mit; + platforms = platforms.linux; + }; + }); + in + { + # System-agnostic builder. Downstream NixOS hosts call this from + # their services/webapp.nix with their own brandDir. + lib.mkWebapp = mkWebapp; + } + // flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + # One package per standalone, all using the aiolabs default brand. + # `nix build .#` exercises the builder for sanity / CI. + appPackages = pkgs.lib.genAttrs apps (app: mkWebapp { inherit pkgs app; }); + in + { + packages = appPackages // { + default = appPackages.main; + }; + }); +} diff --git a/forum.html b/forum.html index 8646936..8d81ea2 100644 --- a/forum.html +++ b/forum.html @@ -6,9 +6,8 @@ - - - + + Forum — Discussions diff --git a/index.html b/index.html index 76e7922..6198b69 100644 --- a/index.html +++ b/index.html @@ -7,9 +7,8 @@ - - - + + %VITE_APP_NAME% Hub diff --git a/libra.html b/libra.html index 58927c4..88a9248 100644 --- a/libra.html +++ b/libra.html @@ -6,9 +6,8 @@ - - - + + Libra — Accounting diff --git a/market.html b/market.html index 3fc32d5..321435a 100644 --- a/market.html +++ b/market.html @@ -6,9 +6,8 @@ - - - + + Market — Nostr diff --git a/package.json b/package.json index 4fea5ac..439cb84 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "main": "electron/main.cjs", "scripts": { + "generate-pwa-assets": "node scripts/generate-pwa-assets.mjs", "dev": "vite --host", "build": "vue-tsc -b && vite build", "preview": "vite preview --host", @@ -92,6 +93,7 @@ "@types/node": "^22.18.1", "@types/qrcode": "^1.5.5", "@types/rollup-plugin-visualizer": "^4.2.3", + "@vite-pwa/assets-generator": "^1.0.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/tsconfig": "^0.7.0", "concurrently": "^8.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d1a8b7c..c242e32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@types/rollup-plugin-visualizer': specifier: ^4.2.3 version: 4.2.4 + '@vite-pwa/assets-generator': + specifier: ^1.0.2 + version: 1.0.2 '@vitejs/plugin-vue': specifier: ^5.2.1 version: 5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3)) @@ -188,7 +191,7 @@ importers: version: 0.8.9(rollup@4.60.4)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)) vite-plugin-pwa: specifier: ^0.21.1 - version: 0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1) + version: 0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1) vue-tsc: specifier: ^2.2.0 version: 2.2.12(typescript@5.6.3) @@ -711,6 +714,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@canvas/image-data@1.1.0': + resolution: {integrity: sha512-QdObRRjRbcXGmM1tmJ+MrHcaz1MftF2+W7YI+MsphnsCrmtyfS0d5qJbk0MeSbUeyM/jCb0hmnkXPsy026L7dA==} + '@electron-forge/cli@7.11.2': resolution: {integrity: sha512-c+C4ndLfHbxwZuCn9G8iT9wD/woLdaVkoSVjAIbj+0nJhi8UmiVsz/+Gxlj4cvhMRTzBMBxudstLU7RocMikfg==} engines: {node: '>= 16.4.0'} @@ -1283,6 +1289,9 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@quansync/fs@1.0.0': + resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==} + '@rollup/plugin-babel@6.1.0': resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} engines: {node: '>=14.0.0'} @@ -1697,6 +1706,11 @@ packages: peerDependencies: zod: ^3.24.0 + '@vite-pwa/assets-generator@1.0.2': + resolution: {integrity: sha512-MCbrb508JZHqe7bUibmZj/lyojdhLRnfkmyXnkrCM2zVrjTgL89U8UEfInpKTvPeTnxsw2hmyZxnhsdNR6yhwg==} + engines: {node: '>=16.14.0'} + hasBin: true + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -2121,6 +2135,10 @@ packages: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + cacache@16.1.3: resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} @@ -2267,6 +2285,10 @@ packages: engines: {node: ^14.13.0 || >=16.0.0} hasBin: true + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2366,6 +2388,14 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decode-bmp@0.2.1: + resolution: {integrity: sha512-NiOaGe+GN0KJqi2STf24hfMkFitDUaIoUU3eKvP/wAbLe8o6FuW5n/x7MHPR0HKvBokp6MQY/j7w8lewEeVCIA==} + engines: {node: '>=8.6.0'} + + decode-ico@0.4.1: + resolution: {integrity: sha512-69NZfbKIzux1vBOd31al3XnMnH+2mqDhEgLdpygErm4d60N+UwA5Sq5WFjmEDQzumgB9fElojGwWG0vybVfFmA==} + engines: {node: '>=8.6'} + decompress-response@6.0.0: resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} engines: {node: '>=10'} @@ -2890,6 +2920,9 @@ packages: humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + ico-endec@0.1.6: + resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -3800,6 +3833,9 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + quansync@1.0.0: + resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4020,6 +4056,9 @@ packages: resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} engines: {node: '>= 0.4'} + sharp-ico@0.1.5: + resolution: {integrity: sha512-a3jODQl82NPp1d5OYb0wY+oFaPk7AvyxipIowCHk7pBsZCWgbe0yAkU2OOXdoH0ENyANhyOQbs9xkAiRHcF02Q==} + sharp@0.33.5: resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4322,6 +4361,9 @@ packages: resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} engines: {node: '>=14.14'} + to-data-view@1.1.0: + resolution: {integrity: sha512-1eAdufMg6mwgmlojAx3QeMnzB/BTVp7Tbndi3U7ftcT2zCZadjxkkmLmd97zmaxWi+sgGcgWrokmpEoy0Dn0vQ==} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4397,6 +4439,12 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + unconfig-core@7.5.0: + resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + + unconfig@7.5.0: + resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -5456,6 +5504,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@canvas/image-data@1.1.0': {} + '@electron-forge/cli@7.11.2(encoding@0.1.13)(lightningcss@1.32.0)': dependencies: '@electron-forge/core': 7.11.2(encoding@0.1.13)(lightningcss@1.32.0) @@ -6234,6 +6284,10 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@quansync/fs@1.0.0': + dependencies: + quansync: 1.0.0 + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.0)(rollup@4.60.4)': dependencies: '@babel/core': 7.29.0 @@ -6559,6 +6613,15 @@ snapshots: transitivePeerDependencies: - vue + '@vite-pwa/assets-generator@1.0.2': + dependencies: + cac: 6.7.14 + colorette: 2.0.20 + consola: 3.4.2 + sharp: 0.33.5 + sharp-ico: 0.1.5 + unconfig: 7.5.0 + '@vitejs/plugin-vue@5.2.4(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(vue@3.5.34(typescript@5.6.3))': dependencies: vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) @@ -7030,6 +7093,8 @@ snapshots: dependencies: run-applescript: 7.1.0 + cac@6.7.14: {} + cacache@16.1.3: dependencies: '@npmcli/fs': 2.1.2 @@ -7193,6 +7258,8 @@ snapshots: tree-kill: 1.2.2 yargs: 17.7.2 + consola@3.4.2: {} + convert-source-map@2.0.0: {} copy-anything@4.0.5: @@ -7287,6 +7354,17 @@ snapshots: decamelize@1.2.0: {} + decode-bmp@0.2.1: + dependencies: + '@canvas/image-data': 1.1.0 + to-data-view: 1.1.0 + + decode-ico@0.4.1: + dependencies: + '@canvas/image-data': 1.1.0 + decode-bmp: 0.2.1 + to-data-view: 1.1.0 + decompress-response@6.0.0: dependencies: mimic-response: 3.1.0 @@ -7973,6 +8051,8 @@ snapshots: dependencies: ms: 2.1.3 + ico-endec@0.1.6: {} + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -8769,6 +8849,8 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + quansync@1.0.0: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -9058,6 +9140,12 @@ snapshots: es-errors: 1.3.0 es-object-atoms: 1.1.2 + sharp-ico@0.1.5: + dependencies: + decode-ico: 0.4.1 + ico-endec: 0.1.6 + sharp: 0.33.5 + sharp@0.33.5: dependencies: color: 4.2.3 @@ -9387,6 +9475,8 @@ snapshots: tmp@0.2.5: optional: true + to-data-view@1.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9462,6 +9552,19 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + unconfig-core@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + quansync: 1.0.0 + + unconfig@7.5.0: + dependencies: + '@quansync/fs': 1.0.0 + defu: 6.1.7 + jiti: 2.7.0 + quansync: 1.0.0 + unconfig-core: 7.5.0 + undici-types@6.21.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -9543,7 +9646,7 @@ snapshots: - rollup - supports-color - vite-plugin-pwa@0.21.2(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1): + vite-plugin-pwa@0.21.2(@vite-pwa/assets-generator@1.0.2)(vite@6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0))(workbox-build@7.4.1)(workbox-window@7.4.1): dependencies: debug: 4.4.3 pretty-bytes: 6.1.1 @@ -9551,6 +9654,8 @@ snapshots: vite: 6.4.2(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0) workbox-build: 7.4.1 workbox-window: 7.4.1 + optionalDependencies: + '@vite-pwa/assets-generator': 1.0.2 transitivePeerDependencies: - supports-color diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png deleted file mode 100644 index 1f8c550..0000000 Binary files a/public/apple-touch-icon.png and /dev/null differ diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index 9fef45b..0000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/icon-192.png b/public/icon-192.png deleted file mode 100644 index e77168a..0000000 Binary files a/public/icon-192.png and /dev/null differ diff --git a/public/icon-512.png b/public/icon-512.png deleted file mode 100644 index 550b306..0000000 Binary files a/public/icon-512.png and /dev/null differ diff --git a/public/icon-maskable-192.png b/public/icon-maskable-192.png deleted file mode 100644 index 0f2053d..0000000 Binary files a/public/icon-maskable-192.png and /dev/null differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png deleted file mode 100644 index 0926f7a..0000000 Binary files a/public/icon-maskable-512.png and /dev/null differ diff --git a/public/mask-icon.svg b/public/mask-icon.svg deleted file mode 100644 index 6bcfe91..0000000 --- a/public/mask-icon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/pwa-assets.config.ts b/pwa-assets.config.ts new file mode 100644 index 0000000..93fee71 --- /dev/null +++ b/pwa-assets.config.ts @@ -0,0 +1,54 @@ +import { defineConfig } from '@vite-pwa/assets-generator/config' +import { copyFileSync, existsSync, mkdirSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const BRAND_DIR = process.env.BRAND_DIR ?? './branding/default' +const BRAND_APP = process.env.BRAND_APP ?? '' + +const candidates: string[] = [] +if (BRAND_APP) { + candidates.push( + join(BRAND_DIR, 'icons', BRAND_APP, 'logo.svg'), + join(BRAND_DIR, 'icons', BRAND_APP, 'logo.png'), + ) +} +candidates.push( + join(BRAND_DIR, 'logo.svg'), + join(BRAND_DIR, 'logo.png'), +) + +const source = candidates.find((p) => existsSync(p)) +if (!source) { + throw new Error( + `No brand logo found. Tried:\n ${candidates.join('\n ')}\n` + + `See branding/README.md for the brand kit contract.`, + ) +} + +// The CLI emits next to the source. Stage into public/icons/ so generated +// PNGs are served at /icons/.png and a single .gitignore line covers +// the whole tree. +const stagingDir = resolve('public/icons') +mkdirSync(stagingDir, { recursive: true }) +const sourceExt = source.toLowerCase().endsWith('.svg') ? '.svg' : '.png' +const stagedSource = join(stagingDir, `.brand-source${sourceExt}`) +copyFileSync(source, stagedSource) + +export default defineConfig({ + headLinkOptions: { preset: '2023' }, + preset: { + transparent: { + sizes: [192, 512], + favicons: [[48, 'favicon.ico']], + }, + maskable: { sizes: [192, 512] }, + apple: { sizes: [180] }, + assetName: (type, size) => { + if (type === 'transparent') return `icon-${size.width}.png` + if (type === 'maskable') return `icon-maskable-${size.width}.png` + if (type === 'apple') return 'apple-touch-icon.png' + throw new Error(`Unknown asset type: ${type}`) + }, + }, + images: [stagedSource], +}) diff --git a/restaurant.html b/restaurant.html index e910921..950aa6d 100644 --- a/restaurant.html +++ b/restaurant.html @@ -6,9 +6,8 @@ - - - + + Restaurant — Order diff --git a/scripts/generate-pwa-assets.mjs b/scripts/generate-pwa-assets.mjs new file mode 100644 index 0000000..e6cfa01 --- /dev/null +++ b/scripts/generate-pwa-assets.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// Wraps pwa-assets-generator and removes the staged brand source after +// generation. pwa-assets.config.ts copies $BRAND_DIR/logo.{svg,png} +// into public/icons/.brand-source.* because the CLI emits next to the +// source. Without this cleanup the full-resolution source ships in +// dist/icons/ and is publicly served. +import { spawnSync } from 'node:child_process' +import { existsSync, rmSync } from 'node:fs' +import { resolve } from 'node:path' + +const cli = resolve('node_modules/.bin/pwa-assets-generator') +const { status } = spawnSync(cli, process.argv.slice(2), { stdio: 'inherit' }) +if (status !== 0) process.exit(status ?? 1) + +const stagingDir = resolve('public/icons') +for (const ext of ['svg', 'png']) { + const staged = resolve(stagingDir, `.brand-source.${ext}`) + if (existsSync(staged)) rmSync(staged) +} diff --git a/src/components/layout/AppShell.vue b/src/components/layout/AppShell.vue index c59b54c..20affdd 100644 --- a/src/components/layout/AppShell.vue +++ b/src/components/layout/AppShell.vue @@ -4,24 +4,23 @@ import { useRoute } from 'vue-router' import { Toaster } from '@/components/ui/sonner' import { useTheme } from '@/components/theme-provider' import BottomNav, { type BottomTab } from './BottomNav.vue' -import HubPill from './HubPill.vue' +import StandaloneMenu, { type SidebarNavItem } from './StandaloneMenu.vue' interface Props { /** App-specific tabs displayed before the constant Profile entry. */ tabs: BottomTab[] /** Active-tab matcher. Forwarded to BottomNav. */ isActive: (path: string) => boolean - /** Hide the top-right HubPill — only true when this shell is rendering - * the hub itself. Standalones leave this false (default). */ + /** Hide the top-right standalone menu — only true when this shell is + * rendering the hub itself. Standalones leave this false (default). */ hideHub?: boolean - /** Forwarded to BottomNav. Hub passes true so logged-out users can still - * reach prefs from the sheet. Standalones leave it false. */ - loggedOutOpensSheet?: boolean + /** App-specific nav items rendered at the top of the standalone menu. */ + sidebarNav?: SidebarNavItem[] } const props = withDefaults(defineProps(), { hideHub: false, - loggedOutOpensSheet: false, + sidebarNav: () => [], }) const route = useRoute() @@ -45,11 +44,13 @@ const isLoginPage = computed(() => route.path === '/login') v-if="!isLoginPage" :tabs="props.tabs" :is-active="props.isActive" - :logged-out-opens-sheet="props.loggedOutOpensSheet" /> - + - diff --git a/src/components/layout/HubPill.vue b/src/components/layout/HubPill.vue deleted file mode 100644 index dbe4f5a..0000000 --- a/src/components/layout/HubPill.vue +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/src/components/layout/MobileDrawer.vue b/src/components/layout/MobileDrawer.vue index 98b0740..5eeefd3 100644 --- a/src/components/layout/MobileDrawer.vue +++ b/src/components/layout/MobileDrawer.vue @@ -77,7 +77,7 @@ const navigateTo = (href: string) => { Logo diff --git a/src/components/layout/ProfileSheetContent.vue b/src/components/layout/ProfileSheetContent.vue index d08735a..26667b1 100644 --- a/src/components/layout/ProfileSheetContent.vue +++ b/src/components/layout/ProfileSheetContent.vue @@ -53,6 +53,9 @@ function goLogin() { + + +
+import { ref, type Component } from 'vue' +import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' +import { Menu } from 'lucide-vue-next' +import { + Sheet, + SheetContent, + SheetTrigger, +} from '@/components/ui/sheet' +import { Separator } from '@/components/ui/separator' +import ProfileSheetContent from './ProfileSheetContent.vue' + +export interface SidebarNavItem { + /** Display label. */ + name: string + /** Lucide (or any) component to render as the leading icon. */ + icon: Component + /** Optional route to navigate to on click. */ + path?: string + /** Optional click handler. Runs after navigation if both are set. */ + onClick?: () => void + /** Visual-only "active" predicate for highlight state. */ + isActive?: () => boolean +} + +interface Props { + /** App-specific nav items rendered at the top of the sheet. */ + items?: SidebarNavItem[] +} + +const props = withDefaults(defineProps(), { items: () => [] }) + +const { t } = useI18n() +const router = useRouter() +const open = ref(false) + +function handleClick(item: SidebarNavItem) { + if (item.path) router.push(item.path) + item.onClick?.() + open.value = false +} + + + diff --git a/src/components/ui/avatar/index.ts b/src/components/ui/avatar/index.ts index 5367952..0cc8926 100644 --- a/src/components/ui/avatar/index.ts +++ b/src/components/ui/avatar/index.ts @@ -5,7 +5,7 @@ export { default as AvatarFallback } from './AvatarFallback.vue' export { default as AvatarImage } from './AvatarImage.vue' export const avatarVariant = cva( - 'inline-flex items-center justify-center font-normal text-foreground select-none shrink-0 bg-secondary overflow-hidden', + 'inline-flex items-center justify-center font-normal text-secondary-foreground select-none shrink-0 bg-secondary overflow-hidden', { variants: { size: { diff --git a/src/events-app/App.vue b/src/events-app/App.vue index 2540dad..ee16fb1 100644 --- a/src/events-app/App.vue +++ b/src/events-app/App.vue @@ -3,7 +3,7 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' -import { CalendarDays, Map, Heart, Search, Plus } from 'lucide-vue-next' +import { Home, Map, Heart, Ticket, Megaphone } from 'lucide-vue-next' import AppShell from '@/components/layout/AppShell.vue' import type { BottomTab } from '@/components/layout/BottomNav.vue' import { useAuth } from '@/composables/useAuthService' @@ -23,36 +23,71 @@ const eventsStore = useEventsStore() const { isAdmin, autoApprove } = useApprovalState() // Used to merge own LNbits drafts into the events feed right after // the user creates or edits an event — otherwise the new draft only -// surfaces on the next EventsPage subscribe cycle. -const { loadOwnEvents } = useEvents() +// surfaces on the next EventsPage subscribe cycle. `onlyHosting` +// is the feed filter that backs the Hosting bottom-nav tab — tapping +// it toggles the filter on; Home tab toggles it off. +const { loadOwnEvents, onlyHosting, toggleHosting } = useEvents() + +// True for /events and its sub-routes (incl. detail pages) but +// not for the routes owned by other tabs (map/favorites). Used by +// both Home and Hosting active-state predicates so the highlight +// only shifts based on the onlyHosting flag while you're in the feed. +function inFeedRoute(): boolean { + if (route.path.startsWith('/events/map')) return false + if (route.path.startsWith('/events/favorites')) return false + return route.path === '/events' || route.path.startsWith('/events/') +} -// Settings dropped — theme/lang/currency now live in the shared profile sheet. -// Create lives in the bottom nav: when logged out, tapping it shows an -// auth-prompt toast (mirroring BookmarkButton/RSVPButton) instead of -// opening the dialog. Per-app placement deliberation tracked at #53. const tabs = computed(() => [ - { name: t('events.nav.feed'), icon: Search, path: '/events' }, - { name: t('events.nav.calendar'), icon: CalendarDays, path: '/events/calendar' }, { - name: t('events.createNew'), - icon: Plus, + name: t('events.nav.feed'), + icon: Home, + onClick: () => { + // Tapping Home clears the hosting filter so the feed always + // returns to the unfiltered view, regardless of where the + // user just came from. + if (onlyHosting.value) toggleHosting() + if (route.path !== '/events') router.push('/events') + }, + isActive: () => inFeedRoute() && !onlyHosting.value, + }, + { + name: t('events.filters.myTickets'), + icon: Ticket, + path: '/my-tickets', onClick: () => { if (!isAuthenticated.value) { - toast.info('Log in to create an event', { + toast.info(t('events.detail.loginToBuyTickets'), { action: { - label: 'Log in', + label: t('events.detail.logIn'), onClick: () => router.push('/login'), }, }) return } - // Defensively clear any lingering edit selection so the Create - // tap always opens in Create mode regardless of a prior Edit. - eventsStore.editingEvent = null - eventsStore.showCreateDialog = true + router.push('/my-tickets') }, disabled: !isAuthenticated.value, }, + { + name: t('events.filters.hosting'), + icon: Megaphone, + onClick: () => { + if (!isAuthenticated.value) { + toast.info(t('events.hosting.loginPrompt', 'Log in to manage your hosted events'), { + action: { + label: t('events.favorites.logIn'), + onClick: () => router.push('/login'), + }, + }) + return + } + if (!onlyHosting.value) toggleHosting() + if (route.path !== '/events') router.push('/events') + }, + isActive: () => inFeedRoute() && onlyHosting.value, + disabled: !isAuthenticated.value, + }, { name: t('events.nav.map'), icon: Map, path: '/events/map' }, { name: t('events.nav.favorites'), @@ -77,18 +112,8 @@ const tabs = computed(() => [ }, ]) -// Feed tab is active for the bare /events route AND all sub-paths that -// aren't owned by another tab (e.g. /events/ detail pages). +// Path-based fallback for tabs that don't carry their own `isActive`. function isActive(path: string): boolean { - if (path === '/events') { - return ( - route.path === '/events' || - (route.path.startsWith('/events/') && - !route.path.startsWith('/events/calendar') && - !route.path.startsWith('/events/map') && - !route.path.startsWith('/events/favorites')) - ) - } return route.path.startsWith(path) } diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 52fc2e0..751e146 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -22,6 +22,7 @@ const messages: LocaleMessages = { profileDescription: 'Your Nostr identity and display name.', profileLoggedOutDescription: 'Sign in or change your preferences.', login: 'Log in', + menu: 'Menu', backToHub: 'Back to hub', hub: 'Hub', theme: 'Theme', @@ -68,6 +69,8 @@ const messages: LocaleMessages = { hosting: 'Hosting', pastEvents: 'Past events', past: 'Past', + filters: 'Filters', + clearAll: 'Clear all', }, categories: { concert: 'Concert', @@ -127,7 +130,7 @@ const messages: LocaleMessages = { registered: 'Registered', }, nav: { - feed: 'Feed', + feed: 'Home', calendar: 'Calendar', map: 'Map', favorites: 'Favorites', diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 9e5407d..edc5d82 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -22,6 +22,7 @@ const messages: LocaleMessages = { profileDescription: 'Tu identidad Nostr y nombre de visualización.', profileLoggedOutDescription: 'Inicia sesión o cambia tus preferencias.', login: 'Iniciar sesión', + menu: 'Menú', backToHub: 'Volver al hub', hub: 'Hub', theme: 'Tema', @@ -68,6 +69,8 @@ const messages: LocaleMessages = { hosting: 'Organizo', pastEvents: 'Eventos pasados', past: 'Pasado', + filters: 'Filtros', + clearAll: 'Limpiar todo', }, categories: { concert: 'Concierto', diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 4c9a4f1..f42cc4b 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -22,6 +22,7 @@ const messages: LocaleMessages = { profileDescription: 'Votre identité Nostr et votre nom d\u2019affichage.', profileLoggedOutDescription: 'Connectez-vous ou modifiez vos préférences.', login: 'Se connecter', + menu: 'Menu', backToHub: 'Retour au hub', hub: 'Hub', theme: 'Thème', @@ -68,6 +69,8 @@ const messages: LocaleMessages = { hosting: 'J\'organise', pastEvents: 'Événements passés', past: 'Passé', + filters: 'Filtres', + clearAll: 'Tout effacer', }, categories: { concert: 'Concert', @@ -127,7 +130,7 @@ const messages: LocaleMessages = { registered: 'Enregistré', }, nav: { - feed: 'Fil', + feed: 'Accueil', calendar: 'Calendrier', map: 'Carte', favorites: 'Favoris', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 81ef71e..3aebdf0 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -21,6 +21,7 @@ export interface LocaleMessages { profileDescription: string profileLoggedOutDescription: string login: string + menu: string backToHub: string hub: string theme: string @@ -69,6 +70,8 @@ export interface LocaleMessages { hosting: string pastEvents: string past: string + filters: string + clearAll: string } categories: Record detail: { diff --git a/src/modules/events/components/EventCard.vue b/src/modules/events/components/EventCard.vue index 694df6a..b26976d 100644 --- a/src/modules/events/components/EventCard.vue +++ b/src/modules/events/components/EventCard.vue @@ -12,6 +12,10 @@ import type { Event } from '../types/event' const props = defineProps<{ event: Event + /** Render a compact row: no hero image, no summary, single-line + * title, tighter padding. Used by the Hosting view where the + * host already knows what their events look like. */ + compact?: boolean }>() const emit = defineEmits<{ @@ -52,42 +56,58 @@ const priceDisplay = computed(() => { return `${info.price} ${info.currency}` }) -const placeholderBg = computed(() => { - // Generate a consistent hue from the event title - const hash = props.event.title.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) - const hue = hash % 360 - return `hsl(${hue}, 40%, 85%)` -}) - const isPast = computed(() => { const a = props.event const end = a.endDate ?? a.startDate if (!end || isNaN(end.getTime())) return false return end.getTime() < Date.now() }) + +// Pending / rejected events get a washed-out look so the user +// sees at a glance the event isn't live, not just the small badge. +const isNonApproved = computed( + () => !!props.event.lnbitsStatus && props.event.lnbitsStatus !== 'approved', +) diff --git a/src/modules/events/components/EventList.vue b/src/modules/events/components/EventList.vue index 8a8ab65..cffb3c5 100644 --- a/src/modules/events/components/EventList.vue +++ b/src/modules/events/components/EventList.vue @@ -7,6 +7,10 @@ import type { Event } from '../types/event' defineProps<{ events: Event[] isLoading?: boolean + /** Render compact rows instead of full-image cards. Used by the + * Hosting view so an operator can scan their roster of events + * without the visual weight of hero images they already recognize. */ + compact?: boolean }>() const emit = defineEmits<{ @@ -39,20 +43,24 @@ const { t } = useI18n() class="flex flex-col items-center justify-center py-16 text-center" > -

+

{{ t('events.noEvents') }}

-

- {{ t('events.search.noResults') }} -

- -
+ +
diff --git a/src/modules/events/components/PurchaseTicketDialog.vue b/src/modules/events/components/PurchaseTicketDialog.vue index b5b5d3f..6617496 100644 --- a/src/modules/events/components/PurchaseTicketDialog.vue +++ b/src/modules/events/components/PurchaseTicketDialog.vue @@ -443,7 +443,7 @@ onUnmounted(() => {
diff --git a/src/modules/events/composables/useEventFilters.ts b/src/modules/events/composables/useEventFilters.ts index 5f9379c..71ce1ff 100644 --- a/src/modules/events/composables/useEventFilters.ts +++ b/src/modules/events/composables/useEventFilters.ts @@ -8,35 +8,22 @@ import type { EventCategory } from '../types/category' import type { TemporalFilter, EventFilters } from '../types/filters' import { DEFAULT_FILTERS } from '../types/filters' +// Filter state is hoisted to module scope so every `useEvents()` / +// `useEventFilters()` call shares the same refs. The bottom-nav +// Hosting tab in events-app/App.vue and the feed view in +// EventsPage.vue both rely on this — without a shared instance, +// tapping Hosting toggled a private ref the page never saw. +const temporal = ref(DEFAULT_FILTERS.temporal) +const selectedCategories = ref([]) +const selectedDate = ref(undefined) +const onlyOwnedTickets = ref(false) +const onlyHosting = ref(false) +const showPast = ref(false) + /** * Composable for managing event filter state and applying filters reactively. */ export function useEventFilters() { - const temporal = ref(DEFAULT_FILTERS.temporal) - const selectedCategories = ref([]) - const selectedDate = ref(undefined) - /** - * When true, the feed is narrowed to events the current user - * holds at least one paid ticket for. Crossed with the - * `ownedEventIds` set from useOwnedTickets in useEvents - * (this composable stays free of ticket fetching). - */ - const onlyOwnedTickets = ref(false) - /** - * When true, the feed is narrowed to events the current user - * is hosting (organizer pubkey matches the signed-in user, or the - * row is a local LNbits draft of theirs). Reads `event.isMine` - * which `useEvents.tagOwnership()` populates. - */ - const onlyHosting = ref(false) - /** - * When false (default), events that have already ended are - * hidden from the feed. Toggling on includes them so the user can - * browse past events. The date-picker overrides this — picking a - * specific past date shows that day's events regardless, - * mirroring how it overrides the temporal pills. - */ - const showPast = ref(false) const filters = computed(() => ({ temporal: temporal.value, diff --git a/src/modules/events/composables/useTicketScanner.ts b/src/modules/events/composables/useTicketScanner.ts index e8afc09..1afe819 100644 --- a/src/modules/events/composables/useTicketScanner.ts +++ b/src/modules/events/composables/useTicketScanner.ts @@ -188,6 +188,38 @@ export function useTicketScanner(eventId: Ref) { isPaused.value = false } + /** + * Mark a ticket as registered without going through the camera — + * used when the host knows the attendee in person or accepts an + * alternate proof of identity. Same backend endpoint as a scan + * (so it also gates on event ownership and rejects unpaid / + * already-registered tickets), but skips the scanner pause + + * full-screen banner since the operator initiated the action + * from the roster directly. Refreshes stats on success. + */ + async function registerManually( + ticketId: string, + ): Promise<{ ok: boolean; error?: string }> { + const adminKey = currentUser.value?.wallets?.[0]?.adminkey + if (!adminKey) return { ok: false, error: 'No wallet admin key available' } + try { + await ticketApi.registerTicket(ticketId, adminKey) + // Mirror the session-local dedup the scan path uses so a + // subsequent QR scan of the same ticket reports "Already + // scanned" instead of round-tripping a duplicate register. + if (!scanned.value.some(r => r.ticketId === ticketId)) { + scanned.value = [ + { ticketId, name: null, registeredAt: new Date().toISOString() }, + ...scanned.value, + ] + } + await refreshStats() + return { ok: true } + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) } + } + } + function clearScanned() { scanned.value = [] lastScan.value = null @@ -210,5 +242,6 @@ export function useTicketScanner(eventId: Ref) { onDecode, resume, clearScanned, + registerManually, } } diff --git a/src/modules/events/views/EventDetailPage.vue b/src/modules/events/views/EventDetailPage.vue index 1839775..cf24c80 100644 --- a/src/modules/events/views/EventDetailPage.vue +++ b/src/modules/events/views/EventDetailPage.vue @@ -170,41 +170,14 @@ function goToMyTickets() { diff --git a/src/modules/events/views/EventsCalendarPage.vue b/src/modules/events/views/EventsCalendarPage.vue index 90d2549..3198976 100644 --- a/src/modules/events/views/EventsCalendarPage.vue +++ b/src/modules/events/views/EventsCalendarPage.vue @@ -1,12 +1,31 @@