Compare commits
32 commits
136a4c92fb
...
c88df44545
| Author | SHA1 | Date | |
|---|---|---|---|
| c88df44545 | |||
| eef9f64236 | |||
| 3a62c53341 | |||
| 89853641fa | |||
| 68c8ee76d8 | |||
| 767630541e | |||
| e611fb1c2e | |||
| 362b4e9525 | |||
| ea4bf98d25 | |||
| 537dd9c588 | |||
| 9a16c2c092 | |||
| 89b3dfc03d | |||
| 520bbf46a7 | |||
| 7695c89405 | |||
| 2b103cfaea | |||
| ac83a61eb1 | |||
| 6885b64ef2 | |||
| b0ee932e77 | |||
| 0ede6f70db | |||
| 14283f62e0 | |||
| 08568fc0c0 | |||
| fadf5407a5 | |||
| be427f1821 | |||
| 3dfed23b43 | |||
| 3efae30e84 | |||
| faf41cd1c0 | |||
| 4e7502b30c | |||
| ce5a1a6a56 | |||
| 88ab432629 | |||
| eebb566323 | |||
| 50a345ce4e | |||
| a8c997ca8d |
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
55
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/<dep>/` 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 `<link>` hrefs and VitePWA `manifest.icons` reference
|
||||
`/icons/<name>` consistently across all 9 configs.
|
||||
|
||||
**Per-standalone override:** `branding/<dep>/icons/<app>/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 `<img src="@brand/logo.png">`
|
||||
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,
|
||||
|
|
|
|||
137
branding/README.md
Normal file
|
|
@ -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 `<img>` 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/<dep>/icons/<app>/logo.{svg,png}` to override the brand's primary logo for a single standalone build.
|
||||
|
||||
Resolution at build time:
|
||||
|
||||
1. `branding/<dep>/icons/<app>/logo.svg`
|
||||
2. `branding/<dep>/icons/<app>/logo.png`
|
||||
3. `branding/<dep>/logo.svg`
|
||||
4. `branding/<dep>/logo.png`
|
||||
5. Build fails with a clear error pointing here.
|
||||
|
||||
`<app>` 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/<file>` 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/<name>.png`. HTML `<link>` tags reference `/icons/<name>.{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/<host>/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/<name>`) 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 <nixpkgs> {}; flake = builtins.getFlake (toString ./.); in flake.lib.mkWebapp { inherit pkgs; brandDir = /path/to/brand; app = "events"; }'
|
||||
```
|
||||
4
branding/default/brand.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "AIO",
|
||||
"shortName": "AIO"
|
||||
}
|
||||
BIN
branding/default/logo.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Chat — Encrypted</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Chat">
|
||||
<meta name="description" content="End-to-end encrypted Nostr chat">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>%VITE_APP_NAME%</title>
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||
<meta name="description" content="Discover events near you">
|
||||
|
|
|
|||
61
flake.lock
generated
Normal file
|
|
@ -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
|
||||
}
|
||||
100
flake.nix
Normal file
|
|
@ -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 .#<app>` exercises the builder for sanity / CI.
|
||||
appPackages = pkgs.lib.genAttrs apps (app: mkWebapp { inherit pkgs app; });
|
||||
in
|
||||
{
|
||||
packages = appPackages // {
|
||||
default = appPackages.main;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Forum — Discussions</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Forum">
|
||||
<meta name="description" content="Decentralized link aggregator and discussion forum on Nostr">
|
||||
|
|
|
|||
|
|
@ -7,9 +7,8 @@
|
|||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- <meta name="theme-color" content="#ffffff"> -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>%VITE_APP_NAME% Hub</title>
|
||||
<meta name="apple-mobile-web-app-title" content="%VITE_APP_NAME%">
|
||||
</head>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Libra — Accounting</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Libra">
|
||||
<meta name="description" content="Team accounting and expense management">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Market — Nostr</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Market">
|
||||
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
109
pnpm-lock.yaml
generated
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 115 KiB |
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 3L4 14h7l-2 7 9-11h-7l2-7z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 227 B |
54
pwa-assets.config.ts
Normal file
|
|
@ -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/<name>.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],
|
||||
})
|
||||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Restaurant — Order</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Restaurant">
|
||||
<meta name="description" content="Order from your local Nostr-native restaurant with Lightning payments">
|
||||
|
|
|
|||
19
scripts/generate-pwa-assets.mjs
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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<Props>(), {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HubPill v-if="!props.hideHub && !isLoginPage" />
|
||||
<StandaloneMenu
|
||||
v-if="!props.hideHub && !isLoginPage"
|
||||
:items="props.sidebarNav"
|
||||
/>
|
||||
<Toaster />
|
||||
|
||||
<!-- Default slot for shell-level overlays (dialogs, sheets, etc.) that
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const isActive = (href: string) => {
|
|||
<div class="flex h-16 shrink-0 items-center">
|
||||
<router-link to="/" class="flex items-center gap-2">
|
||||
<img
|
||||
src="@/assets/logo.png"
|
||||
src="@brand/logo.png"
|
||||
alt="Logo"
|
||||
class="h-8 w-8"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import type { Component } from 'vue'
|
||||
import ProfileSheetTrigger from './ProfileSheetTrigger.vue'
|
||||
|
||||
export interface BottomTab {
|
||||
/** Translated label shown under the icon. */
|
||||
|
|
@ -18,6 +17,11 @@ export interface BottomTab {
|
|||
/** Render the entry ghosted (opacity-reduced). Used for coming-soon and
|
||||
* for auth-required tabs when the user is logged out. */
|
||||
disabled?: boolean
|
||||
/** Per-tab active-state override for entries whose active condition
|
||||
* doesn't reduce to "current route starts with this.path" — e.g. a
|
||||
* "Hosting" tab that is active when a feed-filter ref is on. When
|
||||
* set it wins over the App-level `isActive(path)` matcher. */
|
||||
isActive?: () => boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -25,13 +29,9 @@ interface Props {
|
|||
/** Active-tab matcher. Each app has its own nesting rules so we don't try
|
||||
* to derive a one-size-fits-all default — consumer supplies the function. */
|
||||
isActive: (path: string) => boolean
|
||||
/** When true (Hub), the unauthenticated profile button still opens the
|
||||
* sheet so logged-out users can change theme/lang. When false (standalones),
|
||||
* unauth profile button routes straight to /login. */
|
||||
loggedOutOpensSheet?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { loggedOutOpensSheet: false })
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
|
|
@ -42,6 +42,11 @@ function onTabClick(tab: BottomTab) {
|
|||
}
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
|
||||
function isTabActive(tab: BottomTab): boolean {
|
||||
if (tab.isActive) return tab.isActive()
|
||||
return !!tab.path && props.isActive(tab.path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -56,12 +61,12 @@ function onTabClick(tab: BottomTab) {
|
|||
:key="tab.name"
|
||||
class="relative flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
tab.path && props.isActive(tab.path)
|
||||
isTabActive(tab)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.disabled ? 'opacity-50' : '',
|
||||
]"
|
||||
:aria-current="tab.path && props.isActive(tab.path) ? 'page' : undefined"
|
||||
:aria-current="isTabActive(tab) ? 'page' : undefined"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
|
|
@ -73,10 +78,6 @@ function onTabClick(tab: BottomTab) {
|
|||
{{ tab.badge > 99 ? '99+' : tab.badge }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Always-on Profile entry, appended on the right. Consumers don't
|
||||
pass it; the shell owns it so it's identical across every app. -->
|
||||
<ProfileSheetTrigger :logged-out-opens-sheet="props.loggedOutOpensSheet" />
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,24 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Home } from 'lucide-vue-next'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Falls back to '/' for path-mount deployments where the hub root is the
|
||||
* same origin. Set VITE_HUB_ROOT_URL to a full URL for subdomain
|
||||
* deployments where the hub lives on a sibling origin. */
|
||||
const hubRootUrl = computed(() => import.meta.env.VITE_HUB_ROOT_URL || '/')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a
|
||||
:href="hubRootUrl"
|
||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center gap-1.5 rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||
:aria-label="t('common.nav.backToHub')"
|
||||
>
|
||||
<Home class="w-3.5 h-3.5" />
|
||||
<span class="hidden sm:inline">{{ t('common.nav.hub') }}</span>
|
||||
</a>
|
||||
</template>
|
||||
|
|
@ -77,7 +77,7 @@ const navigateTo = (href: string) => {
|
|||
<SheetHeader class="px-6 py-4 border-b border-border">
|
||||
<SheetTitle class="flex items-center gap-2">
|
||||
<img
|
||||
src="@/assets/logo.png"
|
||||
src="@brand/logo.png"
|
||||
alt="Logo"
|
||||
class="h-8 w-8"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@ function goLogin() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App-specific nav items (rendered by callers like StandaloneMenu) -->
|
||||
<slot name="app-nav" />
|
||||
|
||||
<!-- Cross-app links + global preferences (always visible, auth or not) -->
|
||||
<div class="mt-4">
|
||||
<a
|
||||
|
|
|
|||
81
src/components/layout/StandaloneMenu.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<script setup lang="ts">
|
||||
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<Props>(), { 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
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sheet v-model:open="open">
|
||||
<SheetTrigger as-child>
|
||||
<button
|
||||
class="fixed top-0 right-0 z-40 m-3 inline-flex items-center justify-center rounded-full border bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/60 h-9 w-9 text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shadow-sm"
|
||||
style="margin-top: calc(env(safe-area-inset-top) + 0.75rem); margin-right: calc(env(safe-area-inset-right) + 0.75rem)"
|
||||
:aria-label="t('common.nav.menu')"
|
||||
>
|
||||
<Menu class="w-5 h-5" />
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" class="w-80 sm:w-96 overflow-y-auto">
|
||||
<ProfileSheetContent>
|
||||
<template v-if="props.items.length" #app-nav>
|
||||
<nav class="mt-4 space-y-1">
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
:class="[
|
||||
item.isActive?.()
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
'group flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
]"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 shrink-0" />
|
||||
{{ item.name }}
|
||||
</button>
|
||||
</nav>
|
||||
<Separator class="mt-4" />
|
||||
</template>
|
||||
</ProfileSheetContent>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</template>
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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<BottomTab[]>(() => [
|
||||
{ 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<BottomTab[]>(() => [
|
|||
},
|
||||
])
|
||||
|
||||
// Feed tab is active for the bare /events route AND all sub-paths that
|
||||
// aren't owned by another tab (e.g. /events/<id> 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string, string>
|
||||
detail: {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
class="overflow-hidden cursor-pointer hover:shadow-lg transition-shadow duration-200 flex flex-col"
|
||||
class="relative cursor-pointer hover:shadow-lg transition-shadow duration-200"
|
||||
@click="emit('click', event)"
|
||||
>
|
||||
<!-- Image / Placeholder -->
|
||||
<div class="relative aspect-[16/9] overflow-hidden">
|
||||
<!-- Wash-out wrapper. The pending/rejected status badge below sits
|
||||
OUTSIDE this wrapper so it stays in full color and reads
|
||||
clearly even when the card is dimmed + desaturated. -->
|
||||
<div
|
||||
class="transition-opacity duration-200"
|
||||
:class="[
|
||||
compact ? 'flex flex-row' : 'flex flex-col',
|
||||
isNonApproved ? 'opacity-50 grayscale hover:opacity-90' : '',
|
||||
]"
|
||||
>
|
||||
<!-- Compact thumbnail — small square preview on the left of the
|
||||
row when the event carries an image. `self-center` keeps it
|
||||
vertically centered against a taller content column so we
|
||||
don't get a top-anchored thumb with dead space below. -->
|
||||
<img
|
||||
v-if="compact && event.image"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-20 h-20 object-cover shrink-0 self-center ml-3 rounded-md"
|
||||
loading="lazy"
|
||||
/>
|
||||
<!-- Image with overlaid badges. Cards without an image (or in
|
||||
compact mode) skip the hero area entirely and surface their
|
||||
badges inline at the top of the content block — the solid-
|
||||
color placeholder + calendar glyph wasn't communicating
|
||||
anything the title + details don't already. -->
|
||||
<div v-if="event.image && !compact" class="relative aspect-[16/9] overflow-hidden rounded-t-lg">
|
||||
<img
|
||||
v-if="event.image"
|
||||
:src="event.image"
|
||||
:alt="event.title"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center"
|
||||
:style="{ backgroundColor: placeholderBg }"
|
||||
>
|
||||
<Calendar class="w-12 h-12 text-foreground/20" />
|
||||
</div>
|
||||
|
||||
<!-- Category badge -->
|
||||
<Badge
|
||||
|
|
@ -117,27 +137,13 @@ const isPast = computed(() => {
|
|||
{{ priceDisplay }}
|
||||
</Badge>
|
||||
|
||||
<!-- Pending/rejected overlay for the creator's own non-approved
|
||||
drafts. Only present when the event originated from a
|
||||
local LNbits event (Nostr-sourced events have no
|
||||
lnbitsStatus). -->
|
||||
<!-- Past badge — shown when the event has already ended. The
|
||||
pending/rejected status badge that used to share this slot
|
||||
is now an absolute overlay on Card root, above the wash-out,
|
||||
so we still suppress Past when isNonApproved (the status
|
||||
badge is more actionable in that case). -->
|
||||
<Badge
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
class="absolute bottom-2 left-2 text-xs capitalize"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
|
||||
<!-- Past badge — shown when the event has already ended.
|
||||
Only relevant on the feed when the "Past events" filter
|
||||
chip is toggled on (otherwise these cards aren't rendered);
|
||||
on the detail page the card view isn't used. Suppressed
|
||||
when a pending/rejected status badge is taking the same
|
||||
slot — that case is the creator's own past draft, which is
|
||||
vanishingly rare and the status hint is more actionable. -->
|
||||
<Badge
|
||||
v-if="isPast && !(event.lnbitsStatus && event.lnbitsStatus !== 'approved')"
|
||||
v-if="isPast && !isNonApproved"
|
||||
variant="outline"
|
||||
class="absolute bottom-2 left-2 text-xs gap-1 bg-background/80 backdrop-blur"
|
||||
>
|
||||
|
|
@ -146,27 +152,62 @@ const isPast = computed(() => {
|
|||
</Badge>
|
||||
</div>
|
||||
|
||||
<CardContent class="p-4 flex-1 flex flex-col gap-2">
|
||||
<!-- Title + Bookmark -->
|
||||
<CardContent
|
||||
:class="compact ? 'p-3 flex-1 flex flex-col gap-1.5' : 'p-4 flex-1 flex flex-col gap-2'"
|
||||
>
|
||||
<!-- Inline badge row (no-image variant + compact variant). Same
|
||||
badges as the image-overlay set, stacked horizontally at the
|
||||
top of the content area. The "Yours" chip is dropped in
|
||||
compact mode since every card in the hosting view is owned. -->
|
||||
<div v-if="!event.image || compact" class="flex flex-wrap items-center gap-1.5">
|
||||
<Badge v-if="categoryLabel" variant="secondary" class="text-xs">
|
||||
{{ categoryLabel }}
|
||||
</Badge>
|
||||
<Badge v-if="priceDisplay" class="text-xs">
|
||||
{{ priceDisplay }}
|
||||
</Badge>
|
||||
<Badge v-if="event.isMine && !compact" variant="outline" class="text-xs gap-1">
|
||||
<User class="w-3 h-3" />
|
||||
Yours
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="isPast && !isNonApproved"
|
||||
variant="outline"
|
||||
class="text-xs gap-1"
|
||||
>
|
||||
<History class="w-3 h-3" />
|
||||
{{ t('events.filters.past', 'Past') }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<!-- Title + Bookmark. Compact mode hides the bookmark (host's
|
||||
own event — bookmarking it would be noise) and clamps the
|
||||
title to a single line. -->
|
||||
<div class="flex items-start gap-1">
|
||||
<h3 class="font-semibold text-foreground line-clamp-2 leading-tight flex-1">
|
||||
<h3
|
||||
:class="[
|
||||
'font-semibold text-foreground leading-tight flex-1',
|
||||
compact ? 'text-sm line-clamp-1' : 'line-clamp-2',
|
||||
]"
|
||||
>
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
<BookmarkButton
|
||||
v-if="!compact"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<!-- Summary (hidden in compact mode) -->
|
||||
<p
|
||||
v-if="event.summary"
|
||||
v-if="event.summary && !compact"
|
||||
class="text-sm text-muted-foreground line-clamp-2"
|
||||
>
|
||||
{{ event.summary }}
|
||||
</p>
|
||||
|
||||
<div class="mt-auto space-y-1.5 pt-2">
|
||||
<div :class="compact ? 'space-y-1 text-xs' : 'mt-auto space-y-1.5 pt-2'">
|
||||
<!-- Date/Time -->
|
||||
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Calendar class="w-3.5 h-3.5 shrink-0" />
|
||||
|
|
@ -216,5 +257,22 @@ const isPast = computed(() => {
|
|||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
|
||||
<!-- Status badge — absolutely positioned on Card root so it sits
|
||||
ABOVE the wash-out wrapper and keeps its full color.
|
||||
Pending + rejected both lean on the destructive token so the
|
||||
non-approved state reads as "needs attention" in every theme;
|
||||
the label text differentiates the two specific states.
|
||||
Bottom-right with a slight downward spill so it anchors
|
||||
visually without competing with the category chip in the
|
||||
badge row (full cards) or the thumbnail (compact cards). -->
|
||||
<Badge
|
||||
v-if="isNonApproved"
|
||||
variant="destructive"
|
||||
class="absolute -bottom-1 right-2 z-10 text-xs capitalize shadow"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
</Card>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
<CalendarSearch class="w-16 h-16 text-muted-foreground opacity-30 mb-4" />
|
||||
<h3 class="text-lg font-medium text-foreground mb-1">
|
||||
<h3 class="text-lg font-medium text-foreground">
|
||||
{{ t('events.noEvents') }}
|
||||
</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ t('events.search.noResults') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Event grid -->
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Event grid — compact mode collapses to a single column of
|
||||
tight rows; default mode is the responsive card grid. The
|
||||
compact gap is bumped a notch so the status badge spilling
|
||||
past the card's bottom edge has room to sit between cards. -->
|
||||
<div
|
||||
v-else
|
||||
:class="compact ? 'flex flex-col gap-4' : 'grid gap-4 sm:grid-cols-2 lg:grid-cols-3'"
|
||||
>
|
||||
<EventCard
|
||||
v-for="event in events"
|
||||
:key="event.nostrEventId"
|
||||
:event="event"
|
||||
:compact="compact"
|
||||
@click="emit('select', event)"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -443,7 +443,7 @@ onUnmounted(() => {
|
|||
</template>
|
||||
<template v-else>
|
||||
<Zap class="w-4 h-4 mr-2" />
|
||||
{{ quantity > 1 ? `Get invoice for ${quantity} tickets` : 'Get invoice' }}
|
||||
{{ quantity > 1 ? `Proceed buying (${quantity} tickets)` : 'Proceed' }}
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||
const selectedCategories = ref<EventCategory[]>([])
|
||||
const selectedDate = ref<Date | undefined>(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<TemporalFilter>(DEFAULT_FILTERS.temporal)
|
||||
const selectedCategories = ref<EventCategory[]>([])
|
||||
const selectedDate = ref<Date | undefined>(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<EventFilters>(() => ({
|
||||
temporal: temporal.value,
|
||||
|
|
|
|||
|
|
@ -188,6 +188,38 @@ export function useTicketScanner(eventId: Ref<string>) {
|
|||
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<string>) {
|
|||
onDecode,
|
||||
resume,
|
||||
clearScanned,
|
||||
registerManually,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,41 +170,14 @@ function goToMyTickets() {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4 max-w-3xl">
|
||||
<!-- Top bar -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Top bar — back-link only. Edit moves into the title row as a
|
||||
prominent icon button; Scan moves into the tickets section
|
||||
where it replaces the Buy-ticket CTA for the host. -->
|
||||
<div class="flex items-center mb-4">
|
||||
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
|
||||
<ArrowLeft class="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="openScannerPage"
|
||||
aria-label="Scan tickets"
|
||||
>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
Scan
|
||||
</Button>
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="openEditDialog"
|
||||
aria-label="Edit event"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<BookmarkButton
|
||||
v-if="event"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
|
|
@ -233,104 +206,128 @@ function goToMyTickets() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title + Category -->
|
||||
<!-- Title + bookmark + captions -->
|
||||
<div>
|
||||
<div class="flex items-start gap-2 mb-2">
|
||||
<Badge v-if="categoryLabel" variant="secondary" class="shrink-0 mt-1">
|
||||
<div class="flex flex-wrap items-start gap-2 mb-2">
|
||||
<!-- "Yours" leads the row in the highlighted variant so the
|
||||
ownership signal stands out against the neutral
|
||||
category/tag chips that follow. -->
|
||||
<Badge
|
||||
v-if="event.isMine"
|
||||
variant="secondary"
|
||||
class="shrink-0"
|
||||
>
|
||||
Yours
|
||||
</Badge>
|
||||
<Badge v-if="categoryLabel" variant="outline" class="shrink-0">
|
||||
{{ categoryLabel }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="event.lnbitsStatus && event.lnbitsStatus !== 'approved'"
|
||||
:variant="event.lnbitsStatus === 'rejected' ? 'destructive' : 'secondary'"
|
||||
class="shrink-0 mt-1 capitalize"
|
||||
class="shrink-0 capitalize"
|
||||
>
|
||||
{{ event.lnbitsStatus === 'rejected' ? 'Rejected' : 'Pending review' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="event.isMine"
|
||||
variant="outline"
|
||||
class="shrink-0 mt-1"
|
||||
>
|
||||
Yours
|
||||
</Badge>
|
||||
<div v-for="tag in event.tags.slice(1)" :key="tag">
|
||||
<Badge variant="outline" class="text-xs">{{ tag }}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
{{ event.title }}
|
||||
</h1>
|
||||
<div class="flex items-center gap-1 shrink-0 mt-1">
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
variant="default"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
:aria-label="t('events.detail.editEvent', 'Edit event')"
|
||||
@click="openEditDialog"
|
||||
>
|
||||
<Pencil class="w-4 h-4" />
|
||||
</Button>
|
||||
<!-- Hosts don't need to favorite their own event — the
|
||||
"Yours" badge already marks it, and the bookmark
|
||||
affordance is meant for discovery, not management. -->
|
||||
<BookmarkButton
|
||||
v-else
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="event.summary" class="text-muted-foreground mt-2">
|
||||
{{ event.summary }}
|
||||
</p>
|
||||
|
||||
<!-- When + Where captions -->
|
||||
<div class="mt-3 space-y-1 text-sm text-muted-foreground">
|
||||
<div class="flex items-start gap-1.5">
|
||||
<Calendar class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{{ dateDisplay }}
|
||||
<span v-if="event.timezone" class="opacity-70">({{ event.timezone }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="event.location" class="flex items-start gap-1.5">
|
||||
<MapPin class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{{ event.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Info section -->
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- When -->
|
||||
<div class="bg-muted/50 rounded-lg p-4 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Calendar class="w-4 h-4" />
|
||||
{{ t('events.detail.when') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ dateDisplay }}</p>
|
||||
<p v-if="event.timezone" class="text-xs text-muted-foreground/70">
|
||||
{{ event.timezone }}
|
||||
</p>
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Where -->
|
||||
<div v-if="event.location" class="bg-muted/50 rounded-lg p-4 space-y-1">
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<MapPin class="w-4 h-4" />
|
||||
{{ t('events.detail.location') }}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">{{ event.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RSVP -->
|
||||
<!-- RSVP — hidden for the host since RSVPing to your own event
|
||||
is a noise affordance. -->
|
||||
<!-- The NIP-52 RSVP `a` tag must reference the event's actual kind
|
||||
(31922 for date-based, 31923 for time-based). Without this prop the
|
||||
button would default to time-based for every event, leaving RSVPs
|
||||
on date-based events pointing at a non-existent event coord. -->
|
||||
<RSVPButton
|
||||
v-if="!ownedLnbitsEvent"
|
||||
:pubkey="event.organizer.pubkey"
|
||||
:d-tag="event.id"
|
||||
:kind="event.type === 'date' ? NIP52_KINDS.CALENDAR_DATE_EVENT : NIP52_KINDS.CALENDAR_TIME_EVENT"
|
||||
/>
|
||||
|
||||
<!-- Host's primary CTA is to scan tickets at the door. Lives
|
||||
OUTSIDE the ticketInfo gate so it appears even when the
|
||||
event was published without AIO ticket tags — a host always
|
||||
gets to scan attempts. Stays available for past events too
|
||||
so the host can still verify attendance after the fact. -->
|
||||
<Button
|
||||
v-if="ownedLnbitsEvent"
|
||||
class="w-full gap-1.5"
|
||||
size="lg"
|
||||
@click="openScannerPage"
|
||||
>
|
||||
<ScanLine class="w-4 h-4" />
|
||||
{{ t('events.detail.scanTickets', 'Scan tickets') }}
|
||||
</Button>
|
||||
|
||||
<!-- Tickets — gated on the event carrying ticketInfo (set
|
||||
by the calendar→Event converter from the AIO custom
|
||||
tickets_* tags on the published event). Sections render
|
||||
bottom-up: availability count, then existing owned
|
||||
tickets (when count > 0) above a Purchase CTA (when
|
||||
capacity remains). -->
|
||||
<div v-if="event.ticketInfo" class="space-y-3">
|
||||
<div class="flex items-center justify-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Ticket class="w-4 h-4 shrink-0" />
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else-if="event.ticketInfo.available > 0">
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
</span>
|
||||
<span v-else class="text-destructive font-medium">
|
||||
{{ t('events.detail.soldOut') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
tickets_* tags on the published event). Skipped for the
|
||||
host entirely — they have the Scan CTA above and don't
|
||||
need a Buy CTA for their own event. -->
|
||||
<div v-if="event.ticketInfo && !ownedLnbitsEvent" class="space-y-3">
|
||||
<div
|
||||
v-if="ownedPaidCount > 0"
|
||||
class="bg-primary/10 border border-primary/30 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||
class="bg-primary/15 border border-primary/40 rounded-lg p-4 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<CheckCircle2 class="w-4 h-4 text-primary shrink-0" />
|
||||
{{ t('events.detail.ticketsOwned', { count: ownedPaidCount }, ownedPaidCount) }}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||
<Button size="sm" class="gap-1.5 shrink-0" @click="goToMyTickets">
|
||||
<Ticket class="w-4 h-4" />
|
||||
{{ t('events.detail.viewMyTickets', 'View in My Tickets') }}
|
||||
</Button>
|
||||
|
|
@ -343,10 +340,11 @@ function goToMyTickets() {
|
|||
<History class="w-4 h-4 shrink-0" />
|
||||
{{ t('events.detail.pastEvent', 'This event has already happened') }}
|
||||
</div>
|
||||
<div v-else-if="canBuyTicket">
|
||||
<div v-else-if="canBuyTicket" class="space-y-1">
|
||||
<Button
|
||||
class="w-full gap-1.5"
|
||||
size="lg"
|
||||
:variant="ownedPaidCount > 0 ? 'outline' : 'default'"
|
||||
@click="openPurchaseDialog"
|
||||
>
|
||||
<Ticket class="w-4 h-4" />
|
||||
|
|
@ -359,6 +357,14 @@ function goToMyTickets() {
|
|||
: `${event.ticketInfo.price} ${event.ticketInfo.currency}` }}
|
||||
</span>
|
||||
</Button>
|
||||
<p class="text-xs text-muted-foreground text-center">
|
||||
<span v-if="event.ticketInfo.available === undefined">
|
||||
{{ t('events.detail.unlimitedTickets', 'Unlimited tickets') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ t('events.detail.ticketsAvailable', { count: event.ticketInfo.available }) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
v-else-if="ownedPaidCount === 0"
|
||||
|
|
@ -375,6 +381,13 @@ function goToMyTickets() {
|
|||
@update:is-open="showPurchaseDialog = $event"
|
||||
/>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Organizer -->
|
||||
<div class="bg-muted/50 rounded-lg p-4">
|
||||
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
||||
|
|
@ -382,18 +395,6 @@ function goToMyTickets() {
|
|||
</p>
|
||||
<OrganizerCard :pubkey="event.organizer.pubkey" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- Description -->
|
||||
<div class="prose prose-sm max-w-none text-foreground">
|
||||
<p class="whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- External references -->
|
||||
<div v-if="event.tags.length > 0" class="space-y-2">
|
||||
<!-- References would go here in future phases -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Ticket } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useOwnedTickets } from '../composables/useOwnedTickets'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import EventCalendarView from '../components/EventCalendarView.vue'
|
||||
import type { Event } from '../types/event'
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { allEvents, subscribe } = useEvents()
|
||||
const { ownedEventIds } = useOwnedTickets()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
// Per-page toggle, intentionally not wired to the feed's
|
||||
// onlyOwnedTickets filter — narrowing the calendar shouldn't also
|
||||
// narrow the feed the user navigates back to.
|
||||
const onlyMine = ref(false)
|
||||
|
||||
const visibleEvents = computed<Event[]>(() => {
|
||||
if (!onlyMine.value) return allEvents.value
|
||||
const owned = ownedEventIds.value
|
||||
return allEvents.value.filter(a => owned.has(a.id))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
|
|
@ -19,8 +38,23 @@ function handleSelectEvent(event: Event) {
|
|||
|
||||
<template>
|
||||
<div class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<!-- Filter chip: narrows the calendar to events the user has
|
||||
paid tickets for. Hidden when logged out — nothing to own.
|
||||
Left-aligned so it doesn't collide with the fixed top-right
|
||||
hamburger menu. -->
|
||||
<div v-if="isAuthenticated" class="mb-3 flex">
|
||||
<Button
|
||||
:variant="onlyMine ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="onlyMine = !onlyMine"
|
||||
>
|
||||
<Ticket class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||
</Button>
|
||||
</div>
|
||||
<EventCalendarView
|
||||
:events="allEvents"
|
||||
:events="visibleEvents"
|
||||
@select-event="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -8,9 +8,10 @@ import {
|
|||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone, History } from 'lucide-vue-next'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SlidersHorizontal, History, CalendarDays, Plus } from 'lucide-vue-next'
|
||||
import { useEvents } from '../composables/useEvents'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useEventsStore } from '../stores/events'
|
||||
import EventSearchOverlay from '../components/EventSearchOverlay.vue'
|
||||
import TemporalFilterBar from '../components/TemporalFilterBar.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
|
|
@ -20,6 +21,7 @@ import type { Event } from '../types/event'
|
|||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const eventsStore = useEventsStore()
|
||||
|
||||
const {
|
||||
events,
|
||||
|
|
@ -29,24 +31,26 @@ const {
|
|||
selectedCategories,
|
||||
hasActiveFilters,
|
||||
selectedDate,
|
||||
onlyOwnedTickets,
|
||||
onlyHosting,
|
||||
showPast,
|
||||
onlyHosting,
|
||||
selectDate,
|
||||
setTemporal,
|
||||
toggleCategory,
|
||||
clearCategories,
|
||||
toggleOwnedTickets,
|
||||
toggleHosting,
|
||||
togglePast,
|
||||
resetFilters,
|
||||
subscribe,
|
||||
} = useEvents()
|
||||
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const filtersOpen = ref(false)
|
||||
|
||||
// Badge count on the Filters trigger so the user can see at a glance
|
||||
// that hidden toggles (past-events, categories) are currently active
|
||||
// even when the collapsible is closed.
|
||||
const filterCount = computed(
|
||||
() => selectedCategories.value.length + (showPast.value ? 1 : 0),
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
subscribe()
|
||||
})
|
||||
|
|
@ -54,61 +58,100 @@ onMounted(() => {
|
|||
function handleSelectEvent(event: Event) {
|
||||
router.push({ name: 'event-detail', params: { id: event.id } })
|
||||
}
|
||||
|
||||
// Create-activity CTA in the Hosting view. Calendar-tab → page lives
|
||||
// on /events/calendar; the icon button at the end of the date
|
||||
// strip is the only entry point now that the bottom-nav Calendar
|
||||
// tab is gone.
|
||||
function openCreate() {
|
||||
eventsStore.editingEvent = null
|
||||
eventsStore.showCreateDialog = true
|
||||
}
|
||||
|
||||
function openCalendar() {
|
||||
router.push('/events/calendar')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="container mx-auto py-6 px-4">
|
||||
<div class="container mx-auto py-4 px-4">
|
||||
<!-- Page header -->
|
||||
<div class="mb-4">
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-foreground">
|
||||
<h1 class="mb-3 text-xl sm:text-2xl font-bold text-foreground">
|
||||
{{ t('events.title') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Search with dropdown overlay -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-3">
|
||||
<EventSearchOverlay
|
||||
:events="events"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Date picker strip (p'a semana style) -->
|
||||
<div class="mb-4">
|
||||
<DatePickerStrip :selected-date="selectedDate" @select="selectDate" />
|
||||
<!-- Date picker strip + calendar shortcut. The calendar icon used
|
||||
to be a bottom-nav tab; it now lives on the right of the week
|
||||
strip so the tabs row stays focused on the primary views.
|
||||
Hidden in the Hosting view — operators don't need calendar
|
||||
navigation when they're managing their own roster. -->
|
||||
<div v-if="!onlyHosting" class="mb-3 flex items-center gap-2">
|
||||
<DatePickerStrip
|
||||
class="flex-1 min-w-0"
|
||||
:selected-date="selectedDate"
|
||||
@select="selectDate"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8 shrink-0"
|
||||
:aria-label="t('events.nav.calendar')"
|
||||
@click="openCalendar"
|
||||
>
|
||||
<CalendarDays class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Temporal filter pills -->
|
||||
<div class="mb-4">
|
||||
<!-- Filters trigger + Clear-all stay stationary in a left-aligned
|
||||
column; only the temporal pills scroll horizontally. The
|
||||
Filters icon (with a count badge when past-events or any
|
||||
categories are active) opens a collapsible that hosts the
|
||||
past-events toggle + category chips below. Hidden in the
|
||||
Hosting view — the operator's roster doesn't need them. -->
|
||||
<Collapsible v-if="!onlyHosting" v-model:open="filtersOpen" class="mb-3">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="shrink-0 flex flex-col items-center gap-0.5">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="rounded-full h-8 w-8 relative"
|
||||
:class="{ 'bg-accent text-accent-foreground': filtersOpen || filterCount > 0 }"
|
||||
:aria-label="t('events.filters.filters', 'Filters')"
|
||||
:aria-expanded="filtersOpen"
|
||||
>
|
||||
<SlidersHorizontal class="w-4 h-4" />
|
||||
<span
|
||||
v-if="filterCount > 0"
|
||||
class="absolute -top-1 -right-1 min-w-[16px] h-[16px] px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold flex items-center justify-center"
|
||||
>
|
||||
{{ filterCount }}
|
||||
</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-5 px-1 text-[10px] text-muted-foreground"
|
||||
@click="resetFilters"
|
||||
>
|
||||
{{ t('events.filters.clearAll', 'Clear all') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0 pt-0.5">
|
||||
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
|
||||
</div>
|
||||
|
||||
<!-- Role + past-events filter chips. The role chips ("My tickets",
|
||||
"Hosting") narrow the feed to events the signed-in user
|
||||
has skin in and are hidden when logged out. The "Past events"
|
||||
chip is always visible since past-browsing doesn't require an
|
||||
account. -->
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<template v-if="isAuthenticated">
|
||||
<Button
|
||||
:variant="onlyOwnedTickets ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="toggleOwnedTickets"
|
||||
>
|
||||
<Ticket class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.myTickets', 'My tickets') }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="onlyHosting ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
class="gap-1.5"
|
||||
@click="toggleHosting"
|
||||
>
|
||||
<Megaphone class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.hosting', 'Hosting') }}
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
<CollapsibleContent class="mt-3 space-y-3">
|
||||
<Button
|
||||
:variant="showPast ? 'default' : 'outline'"
|
||||
size="sm"
|
||||
|
|
@ -118,21 +161,7 @@ function handleSelectEvent(event: Event) {
|
|||
<History class="w-3.5 h-3.5" />
|
||||
{{ t('events.filters.pastEvents', 'Past events') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Category filters (collapsible) -->
|
||||
<Collapsible v-model:open="filtersOpen" class="mb-6">
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="ghost" size="sm" class="gap-1.5 text-muted-foreground">
|
||||
<SlidersHorizontal class="w-4 h-4" />
|
||||
Categories
|
||||
<span v-if="selectedCategories.length > 0" class="text-xs bg-primary text-primary-foreground rounded-full px-1.5">
|
||||
{{ selectedCategories.length }}
|
||||
</span>
|
||||
<ChevronDown class="w-3.5 h-3.5 transition-transform" :class="{ 'rotate-180': filtersOpen }" />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent class="mt-2">
|
||||
<Separator />
|
||||
<CategoryFilterBar
|
||||
:selected="selectedCategories"
|
||||
@toggle="toggleCategory"
|
||||
|
|
@ -141,23 +170,31 @@ function handleSelectEvent(event: Event) {
|
|||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
<!-- Active filters indicator -->
|
||||
<div v-if="hasActiveFilters" class="flex items-center gap-2 mb-4">
|
||||
<span class="text-xs text-muted-foreground">Filters active</span>
|
||||
<Button variant="ghost" size="sm" class="h-6 px-2 text-xs" @click="resetFilters">
|
||||
Clear all
|
||||
<!-- Create-activity CTA — shown when the Hosting bottom-nav tab is
|
||||
active. Replaces the dedicated Create entry that used to live
|
||||
in the bottom nav; lives here so it shows up exactly when the
|
||||
user is in the "events I'm running" view. -->
|
||||
<Button
|
||||
v-if="onlyHosting"
|
||||
class="w-full mb-3 gap-1.5"
|
||||
@click="openCreate"
|
||||
>
|
||||
<Plus class="w-4 h-4" />
|
||||
{{ t('events.createNew') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="error" class="mb-4 p-4 bg-destructive/10 text-destructive rounded-lg text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Event feed -->
|
||||
<!-- Event feed. The Hosting view renders compact rows so the
|
||||
operator can scan their roster without the visual weight of
|
||||
hero images they already recognize. -->
|
||||
<EventList
|
||||
:events="events"
|
||||
:is-loading="isLoading"
|
||||
:compact="onlyHosting"
|
||||
@select="handleSelectEvent"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
|
|
@ -9,15 +10,20 @@ import {
|
|||
Ticket,
|
||||
ScanLine,
|
||||
RefreshCw,
|
||||
UserCheck,
|
||||
Search,
|
||||
} from 'lucide-vue-next'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import QRScanner from '@/components/ui/qr-scanner.vue'
|
||||
import { useTicketScanner } from '../composables/useTicketScanner'
|
||||
import type { EventTicket } from '../composables/useTicketScanner'
|
||||
import { useEventDetail } from '../composables/useEventDetail'
|
||||
import { useFuzzySearch } from '@/composables/useFuzzySearch'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
|
@ -35,8 +41,14 @@ const {
|
|||
refreshStats,
|
||||
onDecode,
|
||||
resume,
|
||||
registerManually,
|
||||
} = useTicketScanner(eventId)
|
||||
|
||||
// Tracks tickets currently mid-register (manual button click), so each
|
||||
// row can render a per-row spinner without blocking the rest of the
|
||||
// list. A Set keeps add/remove O(1).
|
||||
const pendingRegister = ref<Set<string>>(new Set())
|
||||
|
||||
const scannerOpen = ref(true)
|
||||
const activeTab = ref<'scanner' | 'list'>('scanner')
|
||||
|
||||
|
|
@ -64,11 +76,55 @@ const remainingCount = computed(() => {
|
|||
return Math.max(0, soldCount.value - registeredCount.value)
|
||||
})
|
||||
|
||||
// Registered tickets only — what the "Scanned" tab shows.
|
||||
const registeredTickets = computed(
|
||||
() => eventStats.value?.tickets.filter(t => t.registered) ?? [],
|
||||
// Full ticket roster, sorted so unregistered (actionable) rows lead
|
||||
// and registered rows follow most-recent-first. Powers the Tickets
|
||||
// tab where the host can manually register attendees who can prove
|
||||
// identity but can't present a scannable QR.
|
||||
const allTickets = computed<EventTicket[]>(() => {
|
||||
const list = eventStats.value?.tickets ?? []
|
||||
return [...list].sort((a, b) => {
|
||||
if (a.registered !== b.registered) return a.registered ? 1 : -1
|
||||
if (a.registered && b.registered) {
|
||||
return (b.registeredAt ?? '').localeCompare(a.registeredAt ?? '')
|
||||
}
|
||||
return 0
|
||||
})
|
||||
})
|
||||
|
||||
const totalTicketsCount = computed(() => eventStats.value?.tickets.length ?? 0)
|
||||
const unregisteredCount = computed(
|
||||
() => allTickets.value.filter(t => !t.registered).length,
|
||||
)
|
||||
|
||||
// Fuzzy match on holder name + ticket id. When the search box is
|
||||
// empty, Fuse returns the list in its incoming order so our
|
||||
// unregistered-first sort is preserved.
|
||||
const { searchQuery, filteredItems: searchedTickets } = useFuzzySearch(
|
||||
allTickets,
|
||||
{
|
||||
fuseOptions: {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.7 },
|
||||
{ name: 'id', weight: 0.3 },
|
||||
],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
},
|
||||
matchAllWhenSearchEmpty: true,
|
||||
},
|
||||
)
|
||||
|
||||
async function handleManualRegister(ticket: EventTicket) {
|
||||
pendingRegister.value.add(ticket.id)
|
||||
const res = await registerManually(ticket.id)
|
||||
pendingRegister.value.delete(ticket.id)
|
||||
if (res.ok) {
|
||||
toast.success(`Registered ${ticket.name || ticket.id.slice(0, 8) + '…'}`)
|
||||
} else {
|
||||
toast.error(res.error || 'Failed to register')
|
||||
}
|
||||
}
|
||||
|
||||
function handleResult(qrText: string) {
|
||||
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
|
||||
// already throttles, and useTicketScanner.onDecode dedups the same
|
||||
|
|
@ -156,13 +212,21 @@ function fmtTime(iso: string) {
|
|||
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2 mb-4">
|
||||
<TabsTrigger value="scanner" class="gap-1.5">
|
||||
<!-- Icon + label wrapped in a real flex container so they
|
||||
share a gap and items-center alignment. TabsTrigger's
|
||||
internal slot lives in an inline span, so a `gap-1.5`
|
||||
on the trigger itself never reaches these two children. -->
|
||||
<TabsTrigger value="scanner">
|
||||
<span class="inline-flex items-center justify-center gap-1.5">
|
||||
<ScanLine class="w-4 h-4" />
|
||||
Scanner
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list" class="gap-1.5">
|
||||
<TabsTrigger value="list">
|
||||
<span class="inline-flex items-center justify-center gap-1.5">
|
||||
<Ticket class="w-4 h-4" />
|
||||
Scanned ({{ registeredCount }})
|
||||
Tickets ({{ totalTicketsCount }})
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -190,39 +254,83 @@ function fmtTime(iso: string) {
|
|||
<TabsContent value="list" class="mt-0">
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-sm font-medium text-foreground">
|
||||
{{ registeredCount }} ticket{{ registeredCount === 1 ? '' : 's' }} registered
|
||||
{{ registeredCount }} / {{ totalTicketsCount }} registered
|
||||
<span v-if="unregisteredCount > 0" class="text-muted-foreground font-normal">
|
||||
· {{ unregisteredCount }} to go
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<ScrollArea v-if="registeredTickets.length > 0" class="h-[60vh]">
|
||||
<!-- Fuzzy filter on holder name + ticket id (Fuse.js via
|
||||
useFuzzySearch). Empty query → all rows in their
|
||||
sort order; typing → reordered by relevance. -->
|
||||
<div v-if="allTickets.length > 0" class="relative">
|
||||
<Search class="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="Search by name or ticket id…"
|
||||
class="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Unregistered rows lead the list so the operator can act
|
||||
on the actionable ones first; tap "Register" to mark an
|
||||
attendee present without a QR (e.g. lost phone, known
|
||||
in person). Failures surface as a toast; the row reverts. -->
|
||||
<ScrollArea v-if="searchedTickets.length > 0" class="h-[60vh]">
|
||||
<ul class="space-y-1.5 pr-3">
|
||||
<li
|
||||
v-for="record in registeredTickets"
|
||||
:key="record.id"
|
||||
v-for="ticket in searchedTickets"
|
||||
:key="ticket.id"
|
||||
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge
|
||||
v-if="record.registeredAt"
|
||||
v-if="ticket.registered && ticket.registeredAt"
|
||||
variant="secondary"
|
||||
class="text-[10px] font-mono px-1.5"
|
||||
>
|
||||
{{ fmtTime(record.registeredAt) }}
|
||||
{{ fmtTime(ticket.registeredAt) }}
|
||||
</Badge>
|
||||
<span v-if="record.name" class="font-medium text-foreground">
|
||||
{{ record.name }}
|
||||
<span v-if="ticket.name" class="font-medium text-foreground">
|
||||
{{ ticket.name }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
|
||||
{{ record.id }}
|
||||
{{ ticket.id }}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
|
||||
<CheckCircle2
|
||||
v-if="ticket.registered"
|
||||
class="w-4 h-4 text-emerald-500 shrink-0"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="shrink-0 gap-1"
|
||||
:disabled="pendingRegister.has(ticket.id)"
|
||||
@click="handleManualRegister(ticket)"
|
||||
>
|
||||
<RefreshCw
|
||||
v-if="pendingRegister.has(ticket.id)"
|
||||
class="w-3.5 h-3.5 animate-spin"
|
||||
/>
|
||||
<UserCheck v-else class="w-3.5 h-3.5" />
|
||||
Register
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
<p
|
||||
v-else-if="allTickets.length === 0"
|
||||
class="text-sm text-muted-foreground text-center py-12"
|
||||
>
|
||||
No tickets sold yet.
|
||||
</p>
|
||||
<p v-else class="text-sm text-muted-foreground text-center py-12">
|
||||
No tickets scanned yet.
|
||||
No tickets match “{{ searchQuery }}”.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<!-- Logo and Title -->
|
||||
<div class="text-center space-y-6">
|
||||
<div class="flex justify-center">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="h-24 w-24 sm:h-32 sm:w-32" />
|
||||
<img src="@brand/logo.png" alt="Logo" class="h-24 w-24 sm:h-32 sm:w-32" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold tracking-tight">Virtual Realm</h1>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<!-- Welcome Section -->
|
||||
<div class="text-center space-y-2 sm:space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<img src="@/assets/logo.png" alt="Logo" class="h-34 w-34 sm:h-42 sm:w-42 lg:h-50 lg:w-50" />
|
||||
<img src="@brand/logo.png" alt="Logo" class="h-34 w-34 sm:h-42 sm:w-42 lg:h-50 lg:w-50" />
|
||||
</div>
|
||||
<div class="space-y-1 sm:space-y-3">
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight">Welcome to the Virtual Realm</h1>
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Tasks — Work Orders</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Tasks">
|
||||
<meta name="description" content="Decentralized task management on Nostr">
|
||||
|
|
|
|||
80
vite-branding.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
/**
|
||||
* Absolute path to the active brand kit. Deployers point this at their
|
||||
* own `branding/<name>/` directory (see branding/README.md).
|
||||
*
|
||||
* Defaults to the committed aiolabs default brand. Used by vite configs
|
||||
* for the `@brand` import alias and by pwa-assets.config.ts.
|
||||
*/
|
||||
export const BRAND_DIR = resolve(process.env.BRAND_DIR ?? './branding/default')
|
||||
|
||||
/** Fields parsed from brand.json. All but `name` are optional. */
|
||||
export interface Brand {
|
||||
/** Brand label — drives PWA manifest name. */
|
||||
name: string
|
||||
/** PWA install/home-screen short label. Defaults to `name`. */
|
||||
shortName?: string
|
||||
/**
|
||||
* Optional PWA chrome theme color (status bar / title bar when installed).
|
||||
* When unset, each standalone's vite config keeps its hardcoded accent.
|
||||
*/
|
||||
themeColor?: string
|
||||
/**
|
||||
* Optional PWA splash background. When unset, each standalone's vite
|
||||
* config keeps its hardcoded value.
|
||||
*/
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export const brand: Brand = JSON.parse(
|
||||
readFileSync(resolve(BRAND_DIR, 'brand.json'), 'utf-8'),
|
||||
)
|
||||
|
||||
/**
|
||||
* Spread into a vite config's `resolve.alias` map. Lets components
|
||||
* import deployer-provided assets via `@brand/<file>` instead of
|
||||
* hardcoding `@/assets/logo.png`.
|
||||
*/
|
||||
export const brandAlias = {
|
||||
'@brand': BRAND_DIR,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* PWA manifest name for a standalone. Combines the brand name with the
|
||||
* app's own label, or returns the bare brand when no label is given.
|
||||
*
|
||||
* Example: `brandManifestName('Wallet')` → "AIO Wallet" / "Cfaun Wallet".
|
||||
* Example: `brandManifestName()` → "AIO" / "Sortir".
|
||||
*/
|
||||
export function brandManifestName(appLabel?: string): string {
|
||||
return appLabel ? `${brand.name} ${appLabel}` : brand.name
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite plugin: regenerate PWA icons under public/icons/ once per build
|
||||
* / dev-server start, so vite.<app>.config.ts's includeAssets +
|
||||
* manifest.icons always have something to include. Source resolution
|
||||
* lives in pwa-assets.config.ts.
|
||||
*/
|
||||
export function brandAssetsPlugin(): Plugin {
|
||||
let generated = false
|
||||
return {
|
||||
name: 'brand-assets-generator',
|
||||
buildStart() {
|
||||
if (generated) return
|
||||
const { status } = spawnSync(
|
||||
'node',
|
||||
[resolve('scripts/generate-pwa-assets.mjs')],
|
||||
{ stdio: 'inherit' },
|
||||
)
|
||||
if (status !== 0) {
|
||||
throw new Error('pwa-assets-generator failed; see output above')
|
||||
}
|
||||
generated = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
function chatHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -45,6 +46,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
chatHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -59,20 +61,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Chat — Encrypted',
|
||||
short_name: 'Chat',
|
||||
description: 'End-to-end encrypted Nostr chat',
|
||||
theme_color: '#16a34a',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#16a34a',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -81,10 +82,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['social', 'communication'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,6 +104,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/chat-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig } from 'vite'
|
|||
import Inspect from 'vite-plugin-inspect'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
// https://vite.dev/config/
|
||||
//
|
||||
|
|
@ -12,6 +13,11 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
|||
// the entire origin and blocked Chrome from offering installs for the
|
||||
// path-mounted standalones at /libra/, /market/, etc. The hub is a
|
||||
// launcher page; users install the standalones they actually use.
|
||||
|
||||
// Brand name flows into index.html's `%VITE_APP_NAME% Hub` title via
|
||||
// Vite's HTML env-var substitution.
|
||||
process.env.VITE_APP_NAME = brand.name
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-hub',
|
||||
|
|
@ -20,6 +26,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
Inspect(),
|
||||
|
|
@ -43,6 +50,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin, brandManifestName } from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to events.html
|
||||
|
|
@ -41,12 +42,11 @@ function eventsHtmlPlugin(): Plugin {
|
|||
* VITE_BASE_PATH=/events/ → app.ariege.io/events/ (shared auth)
|
||||
* (default: /) → bouge.ariege.io (standalone subdomain)
|
||||
*
|
||||
* Set VITE_APP_NAME to brand the standalone (PWA name, HTML title, console
|
||||
* logs). cfaun overrides to "Bouge" via NixOS. Defaults to "Events".
|
||||
* Brand name resolves from brand.json under $BRAND_DIR (see
|
||||
* vite-branding.ts and aiolabs/webapp#95). Surfaced into Vite's HTML
|
||||
* env-var substitution as VITE_APP_NAME for templated titles.
|
||||
*/
|
||||
const APP_NAME = process.env.VITE_APP_NAME || 'Events'
|
||||
// Surface the resolved value back into env so Vite's HTML %VITE_APP_NAME%
|
||||
// substitution picks up the fallback when nothing was explicitly set.
|
||||
const APP_NAME = brandManifestName()
|
||||
process.env.VITE_APP_NAME = APP_NAME
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
|
|
@ -58,6 +58,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
eventsHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -75,20 +76,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: APP_NAME,
|
||||
short_name: APP_NAME,
|
||||
short_name: brand.shortName ?? APP_NAME,
|
||||
description: 'Discover events near you',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#1f2937',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -96,10 +96,10 @@ export default defineConfig(({ mode }) => ({
|
|||
id: 'aiolabs-events',
|
||||
categories: ['social', 'entertainment', 'lifestyle'],
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -118,6 +118,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
function forumHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -45,6 +46,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
forumHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -59,20 +61,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Forum — Discussions',
|
||||
short_name: 'Forum',
|
||||
description: 'Decentralized link aggregator and discussion forum on Nostr',
|
||||
theme_color: '#2563eb',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#2563eb',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -81,10 +82,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['social', 'news'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,6 +104,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/forum-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to libra.html
|
||||
|
|
@ -50,6 +51,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
libraHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -66,20 +68,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Libra — Team Accounting',
|
||||
short_name: 'Libra',
|
||||
description: 'Team accounting and expense management',
|
||||
theme_color: '#1f2937',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#1f2937',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -88,10 +89,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['finance', 'business', 'productivity'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -110,6 +111,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @rollup/plugin-alias is first-match-wins.
|
||||
// The more specific @/app.config remap must precede the @ prefix
|
||||
// alias, otherwise '@/app.config' matches '@' first and resolves
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
function marketHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -45,6 +46,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
marketHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -59,20 +61,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Market — Nostr',
|
||||
short_name: 'Market',
|
||||
description: 'Decentralized marketplace on Nostr with Lightning payments',
|
||||
theme_color: '#dc2626',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#dc2626',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -81,10 +82,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['shopping', 'business'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,6 +104,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/market-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
function restaurantHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -50,6 +51,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
restaurantHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -64,13 +66,12 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Restaurant — Order',
|
||||
|
|
@ -78,8 +79,8 @@ export default defineConfig(({ mode }) => ({
|
|||
description: 'Order from your local Nostr-native restaurant with Lightning payments',
|
||||
// Green to differentiate from market red. PDF tile is purple
|
||||
// (see ~/dev/shared/extensions/restaurant/static/image/restaurant.png).
|
||||
theme_color: '#16a34a',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#16a34a',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -88,10 +89,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['food', 'shopping'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -110,6 +111,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/restaurant-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
function tasksHtmlPlugin(): Plugin {
|
||||
return {
|
||||
|
|
@ -45,6 +46,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
tasksHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -59,20 +61,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Tasks — Work Orders',
|
||||
short_name: 'Tasks',
|
||||
description: 'Decentralized task management on Nostr',
|
||||
theme_color: '#4338ca',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#4338ca',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -81,10 +82,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['productivity', 'business'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -103,6 +104,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/tasks-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { defineConfig, type Plugin } from 'vite'
|
|||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import { brand, brandAlias, brandAssetsPlugin } from './vite-branding'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to wallet.html
|
||||
|
|
@ -49,6 +50,7 @@ export default defineConfig(({ mode }) => ({
|
|||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
brandAssetsPlugin(),
|
||||
walletHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
|
|
@ -65,20 +67,19 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
'icons/favicon.ico',
|
||||
'icons/apple-touch-icon.png',
|
||||
'icons/icon-192.png',
|
||||
'icons/icon-512.png',
|
||||
'icons/icon-maskable-192.png',
|
||||
'icons/icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Wallet — Lightning',
|
||||
short_name: 'Wallet',
|
||||
description: 'Lightning Network wallet — send, receive, and manage sats',
|
||||
theme_color: '#eab308',
|
||||
background_color: '#ffffff',
|
||||
theme_color: brand.themeColor ?? '#eab308',
|
||||
background_color: brand.backgroundColor ?? '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
|
|
@ -87,10 +88,10 @@ export default defineConfig(({ mode }) => ({
|
|||
categories: ['finance'],
|
||||
lang: 'en',
|
||||
icons: [
|
||||
{ src: 'icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{ src: 'icons/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
|
||||
{ src: 'icons/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
|
@ -109,6 +110,7 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
...brandAlias,
|
||||
// ORDER MATTERS — @/app.config must precede @ (first-match-wins).
|
||||
'@/app.config': fileURLToPath(new URL('./src/wallet-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="mask-icon" href="/mask-icon.svg" color="#FFFFFF">
|
||||
<link rel="icon" href="/icons/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" sizes="180x180">
|
||||
<title>Wallet — Lightning</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Wallet">
|
||||
<meta name="description" content="Lightning Network wallet — send, receive, and manage sats">
|
||||
|
|
|
|||