Compare commits
49 commits
f4d5594bef
...
8792a884cd
| Author | SHA1 | Date | |
|---|---|---|---|
| 8792a884cd | |||
| a0187a6604 | |||
| 121f5cc342 | |||
| 16c03d947a | |||
| 628c13c644 | |||
| 181698c057 | |||
| eb3393f1b8 | |||
| b55792ee90 | |||
| 537fe24a49 | |||
| 58bb9c67ed | |||
| 17ea0def53 | |||
| 9161e0cf68 | |||
| 367124bde2 | |||
| 5509668e6b | |||
| ba2370c71f | |||
| 73b67d2765 | |||
| ae68eb09c4 | |||
| 14b81bf3eb | |||
| 9c8383ba73 | |||
| cd84e106e8 | |||
| 3727b52da4 | |||
| e7b4ce7423 | |||
| 51aff8cc87 | |||
| 95d8b2e307 | |||
| b80ad24ae2 | |||
| 2ec9c21015 | |||
| 3ec66151a7 | |||
| 613a925e45 | |||
| d37f37a36d | |||
| 4605703e20 | |||
| 86386b08b1 | |||
| 772c57fd85 | |||
| 9a1e5e3994 | |||
| 96f691c891 | |||
| 13ad6927c6 | |||
| d8468aba56 | |||
| 9a3e3ae0ed | |||
| a694dc2135 | |||
| 0b37518ce2 | |||
| c22b5de8bc | |||
| 820b2a0e64 | |||
| c0be2ca053 | |||
| a162b0789f | |||
| 55324a0501 | |||
| 3f88ea731e | |||
| 455dc6571e | |||
| ee8f1d9ba6 | |||
| 455cfbc764 | |||
| af338016c6 |
60
.env.example
|
|
@ -42,3 +42,63 @@ VITE_MARKET_NADDR=naddr1qqjxgdp4vv6rydej943n2dny956rwwf4943xzwfc95ekyd3evenrsvrr
|
|||
# VITE_LIGHTNING_ENABLED=true
|
||||
# OBSOLETE: Not used in codebase - config.market.defaultCurrency is never consumed
|
||||
# VITE_MARKET_DEFAULT_CURRENCY=sat
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Hub → standalone navigation URLs
|
||||
#
|
||||
# Each chakra tile in the hub builds an <a href> from these env vars and
|
||||
# (when authenticated) appends ?token=<lnbits_token> so the destination
|
||||
# auto-logs in via acceptTokenFromUrl().
|
||||
#
|
||||
# Trailing slash matters under path-mode deployment:
|
||||
# ✓ https://demo.example.com/market/ asset URLs resolve correctly
|
||||
# ✗ https://demo.example.com/market relies on nginx 301 to add the
|
||||
# slash; brittle, extra round trip.
|
||||
#
|
||||
# In LOCAL DEV with `npm run dev:all` use the per-app dev ports (defined
|
||||
# in the vite configs):
|
||||
# VITE_HUB_ACTIVITIES_URL=http://localhost:5181
|
||||
# VITE_HUB_CASTLE_URL=http://localhost:5180
|
||||
# VITE_HUB_WALLET_URL=http://localhost:5182
|
||||
# VITE_HUB_CHAT_URL=http://localhost:5183
|
||||
# VITE_HUB_FORUM_URL=http://localhost:5184
|
||||
# VITE_HUB_MARKET_URL=http://localhost:5185
|
||||
# VITE_HUB_TASKS_URL=http://localhost:5186
|
||||
#
|
||||
# In PATH-MODE production (recommended for demo) — note the trailing slash:
|
||||
# VITE_HUB_ACTIVITIES_URL=https://demo.example.com/activities/
|
||||
# VITE_HUB_CASTLE_URL=https://demo.example.com/castle/
|
||||
# VITE_HUB_WALLET_URL=https://demo.example.com/wallet/
|
||||
# VITE_HUB_CHAT_URL=https://demo.example.com/chat/
|
||||
# VITE_HUB_FORUM_URL=https://demo.example.com/forum/
|
||||
# VITE_HUB_MARKET_URL=https://demo.example.com/market/
|
||||
# VITE_HUB_TASKS_URL=https://demo.example.com/tasks/
|
||||
#
|
||||
# In SUBDOMAIN-MODE production:
|
||||
# VITE_HUB_ACTIVITIES_URL=https://sortir.example.com
|
||||
# VITE_HUB_CASTLE_URL=https://castle.example.com
|
||||
# ...etc
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
VITE_HUB_ACTIVITIES_URL=
|
||||
VITE_HUB_CASTLE_URL=
|
||||
VITE_HUB_WALLET_URL=
|
||||
VITE_HUB_CHAT_URL=
|
||||
VITE_HUB_FORUM_URL=
|
||||
VITE_HUB_MARKET_URL=
|
||||
VITE_HUB_TASKS_URL=
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# VITE_BASE_PATH — build-time only, NOT per .env
|
||||
#
|
||||
# Each standalone vite config (vite.<name>.config.ts) reads VITE_BASE_PATH
|
||||
# at build time. For path-mode deployment, set it as a shell variable when
|
||||
# you build, NOT in this .env file (which is read at runtime by the
|
||||
# bundle):
|
||||
#
|
||||
# VITE_BASE_PATH=/market/ npm run build:market
|
||||
# VITE_BASE_PATH=/wallet/ npm run build:wallet
|
||||
# ...
|
||||
#
|
||||
# The default '/' (no override) is what you want for subdomain-mode and
|
||||
# for `npm run dev:all`.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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" />
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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" />
|
||||
|
|
|
|||
20
chat.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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">
|
||||
<title>Chat — Encrypted</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Chat">
|
||||
<meta name="description" content="End-to-end encrypted Nostr chat">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/chat-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
forum.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/forum-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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">
|
||||
<!-- <meta name="theme-color" content="#ffffff"> -->
|
||||
|
|
|
|||
20
market.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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">
|
||||
<title>Market — Nostr</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Market">
|
||||
<meta name="description" content="Decentralized marketplace on Nostr with Lightning payments">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/market-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,45 +1,169 @@
|
|||
# Main context
|
||||
worker_processes auto; # Automatically determine worker processes based on CPU cores
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024; # Maximum connections per worker
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
default_type application/octet-stream;
|
||||
# Trust the custom Docker network subnet
|
||||
set_real_ip_from 0.0.0.0;
|
||||
real_ip_header X-Forwarded-For;
|
||||
real_ip_recursive on;
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# PATH-MODE deployment (recommended)
|
||||
#
|
||||
# demo.<domain>.<com>/ — minimal AIO chakra hub
|
||||
# demo.<domain>.<com>/activities/ — Sortir / activities standalone
|
||||
# demo.<domain>.<com>/market/ — marketplace standalone
|
||||
# demo.<domain>.<com>/wallet/ — wallet standalone
|
||||
# demo.<domain>.<com>/chat/ — chat standalone
|
||||
# demo.<domain>.<com>/forum/ — forum standalone
|
||||
# demo.<domain>.<com>/tasks/ — tasks standalone
|
||||
# demo.<domain>.<com>/castle/ — castle (accounting) standalone
|
||||
#
|
||||
# Each standalone is built with VITE_BASE_PATH=/<name>/ so its asset URLs
|
||||
# are prefixed correctly. The hub's chakra tiles point at the canonical
|
||||
# trailing-slash path (VITE_HUB_<NAME>_URL=https://demo.<domain>/<name>/).
|
||||
#
|
||||
# Per-app no-trailing-slash → with-slash 301 redirects exist for users
|
||||
# who hand-type the URL or follow a stripped-slash link.
|
||||
#
|
||||
# All static assets (JS / CSS / images / SVGs) are MIME-typed and image
|
||||
# types get a 6-month cache-control.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 8080;
|
||||
server_name <domain>.<com>;
|
||||
server_name demo.<domain>.<com>;
|
||||
|
||||
root /app;
|
||||
# Hub at the root
|
||||
root /var/www/aio/dist;
|
||||
index index.html;
|
||||
|
||||
location = / { try_files $uri /index.html; }
|
||||
location / {
|
||||
# Default: serve from hub bundle if no /<app>/ prefix matched.
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.js$ {
|
||||
types { application/javascript js; }
|
||||
default_type application/javascript;
|
||||
# ── Activities (Sortir) ──────────────────────────────────────────
|
||||
location = /activities { return 301 /activities/$is_args$args; }
|
||||
location /activities/ {
|
||||
alias /var/www/aio/dist-activities/;
|
||||
try_files $uri $uri/ /activities.html;
|
||||
}
|
||||
|
||||
# ── Market ───────────────────────────────────────────────────────
|
||||
location = /market { return 301 /market/$is_args$args; }
|
||||
location /market/ {
|
||||
alias /var/www/aio/dist-market/;
|
||||
try_files $uri $uri/ /market.html;
|
||||
}
|
||||
|
||||
# ── Wallet ───────────────────────────────────────────────────────
|
||||
location = /wallet { return 301 /wallet/$is_args$args; }
|
||||
location /wallet/ {
|
||||
alias /var/www/aio/dist-wallet/;
|
||||
try_files $uri $uri/ /wallet.html;
|
||||
}
|
||||
|
||||
# ── Chat ─────────────────────────────────────────────────────────
|
||||
location = /chat { return 301 /chat/$is_args$args; }
|
||||
location /chat/ {
|
||||
alias /var/www/aio/dist-chat/;
|
||||
try_files $uri $uri/ /chat.html;
|
||||
}
|
||||
|
||||
# ── Forum ────────────────────────────────────────────────────────
|
||||
location = /forum { return 301 /forum/$is_args$args; }
|
||||
location /forum/ {
|
||||
alias /var/www/aio/dist-forum/;
|
||||
try_files $uri $uri/ /forum.html;
|
||||
}
|
||||
|
||||
# ── Tasks ────────────────────────────────────────────────────────
|
||||
location = /tasks { return 301 /tasks/$is_args$args; }
|
||||
location /tasks/ {
|
||||
alias /var/www/aio/dist-tasks/;
|
||||
try_files $uri $uri/ /tasks.html;
|
||||
}
|
||||
|
||||
# ── Castle (accounting) ──────────────────────────────────────────
|
||||
location = /castle { return 301 /castle/$is_args$args; }
|
||||
location /castle/ {
|
||||
alias /var/www/aio/dist-castle/;
|
||||
try_files $uri $uri/ /castle.html;
|
||||
}
|
||||
|
||||
# ── Static asset MIME / cache (applies to all bundles) ───────────
|
||||
location ~* \.js$ { types { application/javascript js; } default_type application/javascript; }
|
||||
location ~* \.css$ { types { text/css css; } default_type text/css; }
|
||||
location ~* \.(png|jpe?g|webp|ico|svg)$ { expires 6M; access_log off; }
|
||||
}
|
||||
|
||||
# Serve CSS files with the correct MIME type
|
||||
location ~* \.css$ {
|
||||
types { text/css css; }
|
||||
default_type text/css;
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Optional subdomain shortcuts → canonical path
|
||||
#
|
||||
# If you want pretty subdomain URLs that funnel into the path-mode
|
||||
# canonical, add 301 redirects per app. Example:
|
||||
#
|
||||
# events.demo.<domain>.<com> → demo.<domain>.<com>/activities/
|
||||
# market.demo.<domain>.<com> → demo.<domain>.<com>/market/
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
server {
|
||||
listen 8080;
|
||||
server_name events.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/activities/$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name market.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/market/$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name wallet.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/wallet/$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name chat.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/chat/$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name forum.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/forum/$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name tasks.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/tasks/$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 8080;
|
||||
server_name castle.demo.<domain>.<com>;
|
||||
return 301 https://demo.<domain>.<com>/castle/$request_uri;
|
||||
}
|
||||
|
||||
# Serve image files
|
||||
location ~* \.(png|jpe?g|webp|ico)$ {
|
||||
expires 6M; # Optional: Cache static assets for 6 months
|
||||
access_log off;
|
||||
}
|
||||
|
||||
}
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# SUBDOMAIN-MODE deployment (alternative — pure subdomains, no /path/)
|
||||
#
|
||||
# If you'd rather give each standalone its own subdomain and skip the
|
||||
# path-mode entirely:
|
||||
#
|
||||
# server { server_name app.<domain>; root /var/www/aio/dist; ... }
|
||||
# server { server_name market.<domain>; root /var/www/aio/dist-market; ... }
|
||||
# server { server_name sortir.<domain>; root /var/www/aio/dist-activities; ... }
|
||||
# server { server_name wallet.<domain>; root /var/www/aio/dist-wallet; ... }
|
||||
# server { server_name chat.<domain>; root /var/www/aio/dist-chat; ... }
|
||||
# server { server_name forum.<domain>; root /var/www/aio/dist-forum; ... }
|
||||
# server { server_name tasks.<domain>; root /var/www/aio/dist-tasks; ... }
|
||||
# server { server_name castle.<domain>; root /var/www/aio/dist-castle; ... }
|
||||
#
|
||||
# Each block uses `location / { try_files $uri $uri/ /<name>.html; }`.
|
||||
# In subdomain mode, build each standalone WITHOUT VITE_BASE_PATH (the
|
||||
# default `/` is correct), and set VITE_HUB_<NAME>_URL to the subdomain
|
||||
# in the hub's env (e.g. VITE_HUB_MARKET_URL=https://market.<domain>).
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
}
|
||||
|
||||
|
|
|
|||
17
package.json
|
|
@ -15,6 +15,23 @@
|
|||
"dev:castle": "vite --host --config vite.castle.config.ts",
|
||||
"build:castle": "vue-tsc -b && vite build --config vite.castle.config.ts",
|
||||
"preview:castle": "vite preview --host --config vite.castle.config.ts",
|
||||
"dev:wallet": "vite --host --config vite.wallet.config.ts",
|
||||
"build:wallet": "vue-tsc -b && vite build --config vite.wallet.config.ts",
|
||||
"preview:wallet": "vite preview --host --config vite.wallet.config.ts",
|
||||
"dev:chat": "vite --host --config vite.chat.config.ts",
|
||||
"build:chat": "vue-tsc -b && vite build --config vite.chat.config.ts",
|
||||
"preview:chat": "vite preview --host --config vite.chat.config.ts",
|
||||
"dev:market": "vite --host --config vite.market.config.ts",
|
||||
"build:market": "vue-tsc -b && vite build --config vite.market.config.ts",
|
||||
"preview:market": "vite preview --host --config vite.market.config.ts",
|
||||
"dev:tasks": "vite --host --config vite.tasks.config.ts",
|
||||
"build:tasks": "vue-tsc -b && vite build --config vite.tasks.config.ts",
|
||||
"preview:tasks": "vite preview --host --config vite.tasks.config.ts",
|
||||
"dev:forum": "vite --host --config vite.forum.config.ts",
|
||||
"build:forum": "vue-tsc -b && vite build --config vite.forum.config.ts",
|
||||
"preview:forum": "vite preview --host --config vite.forum.config.ts",
|
||||
"dev:all": "concurrently -n hub,castle,sortir,wallet,chat,forum,market,tasks -c blue,magenta,cyan,yellow,green,blue,red,gray \"npm:dev\" \"npm:dev:castle\" \"npm:dev:activities\" \"npm:dev:wallet\" \"npm:dev:chat\" \"npm:dev:forum\" \"npm:dev:market\" \"npm:dev:tasks\"",
|
||||
"build:demo": "npm run build && VITE_BASE_PATH=/sortir/ npm run build:activities && VITE_BASE_PATH=/castle/ npm run build:castle && VITE_BASE_PATH=/wallet/ npm run build:wallet && VITE_BASE_PATH=/chat/ npm run build:chat && VITE_BASE_PATH=/forum/ npm run build:forum && VITE_BASE_PATH=/market/ npm run build:market && VITE_BASE_PATH=/tasks/ npm run build:tasks",
|
||||
"electron:dev": "concurrently \"vite --host\" \"electron-forge start\"",
|
||||
"electron:build": "vue-tsc -b && vite build && electron-builder",
|
||||
"electron:package": "electron-builder",
|
||||
|
|
|
|||
64
public/chakras/ajna.svg
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.373173mm"
|
||||
height="26.373281mm"
|
||||
viewBox="0 0 26.373173 26.373281"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="ajna.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="50.671531"
|
||||
inkscape:cy="50.333398"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.435614,-75.884446)">
|
||||
<path
|
||||
d="m 68.657927,94.106814 c -2.7813,2.781265 -7.290506,2.781265 -10.071454,0 -2.7813,-2.781335 -2.7813,-7.290541 0,-10.071489 2.780948,-2.7813 7.290154,-2.7813 10.071454,0 2.7813,2.780948 2.7813,7.290154 0,10.071489 z"
|
||||
style="fill:none;stroke:#0670b3;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.25"
|
||||
id="path872" />
|
||||
<path
|
||||
d="m 69.587143,95.56682 v -0.01199 c 0.0889,-0.08156 0.1778,-0.165911 0.265289,-0.253259 1.719792,-1.720215 2.580923,-3.974677 2.579864,-6.230691 0.0011,-2.255661 -0.860072,-4.510264 -2.579864,-6.230056 -0.08749,-0.08784 -0.176389,-0.172156 -0.265289,-0.253647 v -0.01199 c 3.84422,0.0011 4.563887,3.281892 5.094817,4.341284 0.52952,1.060803 1.756481,2.154414 1.756481,2.154414 0,0 -1.226961,1.093611 -1.756481,2.154414 -0.53093,1.059603 -1.250597,4.340437 -5.094817,4.34153 z"
|
||||
style="fill:none;stroke:#0670b3;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.25"
|
||||
id="path874" />
|
||||
<path
|
||||
d="m 57.657257,82.575178 v 0.01199 c -0.08961,0.08149 -0.1778,0.165805 -0.265289,0.253294 -1.720145,1.719792 -2.580923,3.974748 -2.579864,6.230409 -0.0011,2.256014 0.859719,4.510617 2.579864,6.230409 0.08749,0.08749 0.175683,0.17212 0.265289,0.253541 v 0.01199 c -3.84422,-9.52e-4 -4.563887,-3.281786 -5.094817,-4.34153 -0.529873,-1.060803 -1.756481,-2.154414 -1.756481,-2.154414 0,0 1.226608,-1.093611 1.756481,-2.154061 0.53093,-1.060098 1.250597,-4.340578 5.094817,-4.341637 z"
|
||||
style="fill:none;stroke:#0670b3;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.25"
|
||||
id="path876" />
|
||||
<path
|
||||
d="m 72.401252,89.070876 c 0,4.848931 -3.930297,8.779651 -8.779228,8.779651 -4.848578,0 -8.779229,-3.93072 -8.779229,-8.779651 0,-4.848578 3.930651,-8.779229 8.779229,-8.779229 4.848931,0 8.779228,3.930651 8.779228,8.779229 z"
|
||||
style="fill:none;stroke:#0670b3;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.25"
|
||||
id="path878" />
|
||||
<path
|
||||
d="m 65.299129,88.382254 c 0.865717,-0.425803 1.429456,-1.041753 0.671336,-2.183342 -0.994833,-1.497895 -2.77883,0.03669 -3.463925,0.938036 -0.261055,0.343605 0.299156,0.753533 0.588081,0.453672 0.166511,-0.179917 0.340078,-0.352778 0.521405,-0.51823 0.584553,-0.6604 1.135945,-0.460023 1.654176,0.602191 -0.485423,0.296686 -1.015648,0.51682 -1.501776,0.820914 -0.229305,0.143581 -0.0695,0.446264 0.173214,0.410634 1.441803,-0.211667 2.301523,1.27 1.36137,2.354791 -0.610659,0.70485 -1.58997,0.431448 -2.2479,0.02893 -0.989189,-0.605367 -1.286581,-1.88595 -1.844323,-2.818694 -0.144286,-0.2413 -0.456847,-0.06809 -0.422275,0.1778 0.274814,1.949802 2.552348,5.21215 4.817534,3.385608 1.364192,-1.099961 0.963436,-3.153128 -0.306917,-3.652308"
|
||||
style="fill:#0670b3;fill-opacity:0.25;fill-rule:nonzero;stroke:#0670b3;stroke-width:0.0352778;stroke-opacity:0.25"
|
||||
id="path880" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
108
public/chakras/anahata.svg
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.373173mm"
|
||||
height="26.373281mm"
|
||||
viewBox="0 0 26.373173 26.373281"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="anahata.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="50.671531"
|
||||
inkscape:cy="50.333398"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.435614,-75.884446)">
|
||||
<path
|
||||
d="m 70.582859,88.910626 c 0,3.844713 -3.116439,6.961576 -6.961365,6.961576 -3.844572,0 -6.961364,-3.116863 -6.961364,-6.961576 0,-3.844573 3.116792,-6.961012 6.961364,-6.961012 3.844926,0 6.961365,3.116439 6.961365,6.961012 z"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path966" />
|
||||
<path
|
||||
d="m 72.43212,89.07114 c 0,4.016728 -2.689225,7.406146 -6.364464,8.465997 -0.776464,0.224331 -1.597378,0.34424 -2.445809,0.34424 -0.848783,0 -1.668992,-0.119909 -2.445103,-0.34424 -3.67665,-1.059851 -6.366228,-4.449269 -6.366228,-8.465997 0,-4.016728 2.689578,-7.406217 6.366228,-8.465962 0.776111,-0.224367 1.59632,-0.343958 2.445103,-0.343958 0.848431,0 1.669345,0.119591 2.445809,0.343958 3.675239,1.059745 6.364464,4.449234 6.364464,8.465962 z"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path968" />
|
||||
<path
|
||||
d="m 61.176392,80.605178 c 0.229305,-2.099028 1.259769,-2.560108 1.61925,-2.911122 0.405694,-0.395464 0.825147,-1.310923 0.825147,-1.310923 0,0 0.419453,0.915459 0.826205,1.310923 0.360892,0.351014 1.391356,0.812094 1.619603,2.911122"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path970" />
|
||||
<path
|
||||
d="m 66.068008,97.536996 c -0.229305,2.099028 -1.259769,2.560394 -1.620661,2.911124 -0.407105,0.3955 -0.826558,1.31092 -0.826558,1.31092 0,0 -0.4191,-0.91542 -0.824795,-1.31092 -0.35948,-0.35073 -1.389944,-0.812096 -1.619602,-2.911124"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path972" />
|
||||
<path
|
||||
d="m 72.087809,86.625331 c 2.099028,0.229306 2.560461,1.25977 2.911475,1.61925 0.395111,0.405695 1.310922,0.8255 1.310922,0.8255 0,0 -0.915811,0.4191 -1.310922,0.825853 -0.351014,0.360892 -0.812447,1.391356 -2.911475,1.619603"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path974" />
|
||||
<path
|
||||
d="m 55.156238,91.516948 c -2.099028,-0.229658 -2.560461,-1.259769 -2.911122,-1.620661 -0.395464,-0.407106 -1.310922,-0.826558 -1.310922,-0.826558 0,0 0.915458,-0.4191 1.310922,-0.824795 0.350661,-0.35948 0.812094,-1.389944 2.911122,-1.619603"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path976" />
|
||||
<path
|
||||
d="m 65.843642,80.543795 c 1.269294,-1.687336 2.390775,-1.557867 2.879372,-1.675695 0.550334,-0.132644 1.378656,-0.705555 1.378656,-0.705555 0,0 -0.106892,1.001536 0.04092,1.549047 0.131233,0.485775 0.78105,1.408995 -0.0949,3.32987"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path978" />
|
||||
<path
|
||||
d="m 61.400758,97.598591 c -1.269294,1.687406 -2.390775,1.557866 -2.880431,1.675024 -0.551744,0.131868 -1.379714,0.70485 -1.379714,0.70485 0,0 0.106892,-1.001148 -0.03986,-1.548342 -0.130175,-0.485069 -0.779992,-1.408218 0.0949,-3.330187"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path980" />
|
||||
<path
|
||||
d="m 69.887534,82.874598 c 1.95333,-0.801864 2.851503,-0.117828 3.33128,0.02999 0.541162,0.167217 1.545873,0.09772 1.545873,0.09772 0,0 -0.603603,0.80645 -0.756003,1.35255 -0.135467,0.484717 -0.04833,1.610431 -1.782586,2.814462"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path982" />
|
||||
<path
|
||||
d="m 57.356866,95.267647 c -1.95333,0.802287 -2.851503,0.117827 -3.331986,-0.03101 -0.541514,-0.168239 -1.546225,-0.09906 -1.546225,-0.09906 0,0 0.60325,-0.806167 0.756708,-1.351174 0.13582,-0.483693 0.04868,-1.609407 1.782234,-2.814496"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path984" />
|
||||
<path
|
||||
d="m 61.3997,80.543795 c -1.269295,-1.687336 -2.390775,-1.557867 -2.879373,-1.675695 -0.550686,-0.132644 -1.378655,-0.705555 -1.378655,-0.705555 0,0 0.106539,1.001536 -0.04092,1.549047 -0.131587,0.485775 -0.78105,1.408995 0.09454,3.32987"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path986" />
|
||||
<path
|
||||
d="m 65.842583,97.598591 c 1.269295,1.687406 2.390776,1.557866 2.880431,1.675024 0.551392,0.131868 1.379714,0.70485 1.379714,0.70485 0,0 -0.106891,-1.001148 0.03986,-1.548342 0.130175,-0.485069 0.779992,-1.408218 -0.0949,-3.330187"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path988" />
|
||||
<path
|
||||
d="m 57.355455,82.874598 c -1.952978,-0.801864 -2.85115,-0.117828 -3.330928,0.02999 -0.541161,0.167217 -1.545872,0.09772 -1.545872,0.09772 0,0 0.60325,0.80645 0.756003,1.35255 0.135466,0.484717 0.04798,1.610431 1.782233,2.814462"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path990" />
|
||||
<path
|
||||
d="m 69.886475,95.267647 c 1.953331,0.802287 2.851503,0.117827 3.331634,-0.03101 0.541867,-0.168239 1.546578,-0.09906 1.546578,-0.09906 0,0 -0.60325,-0.806167 -0.756709,-1.351174 -0.136172,-0.483693 -0.04868,-1.609407 -1.782233,-2.814496"
|
||||
style="fill:none;stroke:#87c341;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path992" />
|
||||
<path
|
||||
d="m 64.522489,89.602423 c -0.480836,0.528461 -0.853017,1.052689 -1.546578,1.328561 -0.508706,0.201436 -1.065389,0.146756 -1.488017,-0.20708 -0.303036,-0.253647 -0.572911,-0.877711 -0.286102,-1.224845 0.393347,-0.475544 1.079147,-0.756003 1.531055,-1.19133 0.37077,-0.357717 0.512939,-0.776464 0.578556,-1.264709 0.02293,3.53e-4 0.0448,0.0018 0.06773,0.0018 0.38982,0.0071 0.812447,0.04127 1.243189,0.06844 -0.06667,0.797278 -0.101247,1.636889 -0.08784,2.479675 -0.0039,0.0039 -0.0085,0.0053 -0.01199,0.0095 m 1.954036,-3.34645 c -0.237419,0.153811 -1.046339,0.03916 -1.327855,0.04374 -0.442384,0.0085 -0.884767,0.02046 -1.327503,0.03069 -0.995892,0.02328 -2.02177,0.08819 -2.995437,-0.15628 -0.21343,-0.05362 -0.370416,0.274108 -0.155222,0.367947 0.707672,0.30868 1.389945,0.432153 2.124428,0.479425 -0.491419,1.058686 -1.823861,1.1811 -2.3749,2.121605 -0.426861,0.728487 -0.03916,1.617839 0.466725,2.181578 0.987778,1.101443 2.857147,0.575381 3.702756,-0.525639 0.06526,0.835731 0.187325,1.657139 0.392641,2.424431 0.08255,0.30868 0.527403,0.232198 0.541162,-0.07331 0.08784,-1.95707 -0.119592,-4.038106 -0.269523,-6.007312 0.507648,0.01482 1.008239,-7.05e-4 1.455914,-0.106891 0.177448,-0.04269 0.286456,-0.211667 0.293864,-0.386292 l 0.0035,-0.08925 C 67.018368,86.299019 66.697339,86.113105 66.4765,86.25598"
|
||||
style="fill:#8ac641;fill-opacity:0.2;fill-rule:nonzero;stroke:#87c341;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path994" />
|
||||
<path
|
||||
d="m 64.98992,85.116148 c -0.0018,-0.0074 -0.0035,-0.01517 -0.0056,-0.02258 -0.03916,-0.1651 -0.179564,-0.286808 -0.340078,-0.3302 -0.0956,-0.04269 -0.204258,-0.05503 -0.315383,-0.02469 -0.224367,0.06174 -0.327378,0.247297 -0.352778,0.46355 -0.0014,0.01517 -0.0035,0.03034 -0.0053,0.04586 -0.02399,0.205669 0.168275,0.438503 0.364773,0.478719 0.212019,0.04339 0.3683,0.0056 0.529872,-0.140053 0.129822,-0.116769 0.163689,-0.307975 0.124531,-0.470605"
|
||||
style="fill:#8ac641;fill-opacity:0.2;fill-rule:nonzero;stroke:#87c341;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path996" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
92
public/chakras/manipura.svg
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.373173mm"
|
||||
height="26.373281mm"
|
||||
viewBox="0 0 26.373173 26.373281"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="manipura.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="51.830841"
|
||||
inkscape:cy="50.333398"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.435614,-75.884446)">
|
||||
<path
|
||||
d="m 70.583035,88.910379 c 0,3.844713 -3.116439,6.961576 -6.961011,6.961576 -3.844926,0 -6.961365,-3.116863 -6.961365,-6.961576 0,-3.844573 3.116439,-6.961012 6.961365,-6.961012 3.844572,0 6.961011,3.116439 6.961011,6.961012 z"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path998" />
|
||||
<path
|
||||
d="m 72.432296,89.070893 c 0,4.865864 -3.944761,8.810237 -8.810625,8.810237 -4.865864,0 -8.80992,-3.944373 -8.80992,-8.810237 0,-4.865512 3.944056,-8.810273 8.80992,-8.810273 4.865864,0 8.810625,3.944761 8.810625,8.810273 z"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1000" />
|
||||
<path
|
||||
d="m 60.089307,80.997926 c 0.194733,-2.436637 1.810808,-2.932642 2.354792,-3.303764 0.578908,-0.395464 1.177572,-1.310923 1.177572,-1.310923 0,0 0.597958,0.915459 1.177925,1.310923 0.545747,0.371122 2.16147,0.868186 2.355497,3.305175"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1002" />
|
||||
<path
|
||||
d="m 67.155093,97.142766 c -0.194027,2.436919 -1.80975,2.933984 -2.355497,3.305244 -0.579967,0.39536 -1.177925,1.31092 -1.177925,1.31092 0,0 -0.598311,-0.91556 -1.177572,-1.31092 -0.543984,-0.37126 -2.160059,-0.867196 -2.355145,-3.30415"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1004" />
|
||||
<path
|
||||
d="m 66.832302,80.864223 c 1.860903,-1.585031 3.354211,-0.793397 4.001558,-0.670984 0.688975,0.129823 1.759303,-0.09454 1.759303,-0.09454 0,0 -0.224719,1.070328 -0.09384,1.760008 0.123472,0.648406 0.9144,2.14242 -0.671689,4.002617"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1006" />
|
||||
<path
|
||||
d="m 60.412451,97.276575 c -1.860197,1.586124 -3.354211,0.795126 -4.002617,0.671654 -0.68968,-0.130493 -1.760008,0.09384 -1.760008,0.09384 0,0 0.224367,-1.070293 0.0949,-1.758915 -0.122767,-0.647277 -0.9144,-2.140656 0.670631,-4.001806"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1008" />
|
||||
<path
|
||||
d="m 71.694991,85.538176 c 2.436989,0.195086 2.932994,1.810808 3.304117,2.355145 0.395111,0.578908 1.310922,1.177219 1.310922,1.177219 0,0 -0.915811,0.597958 -1.310922,1.178278 -0.371123,0.545394 -0.868539,2.161081 -3.305175,2.355109"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1010" />
|
||||
<path
|
||||
d="m 55.550115,92.603786 c -2.436989,-0.194028 -2.934053,-1.809221 -3.304823,-2.354968 -0.395816,-0.58032 -1.310922,-1.178631 -1.310922,-1.178631 0,0 0.915106,-0.597605 1.310922,-1.176866 0.37077,-0.544337 0.866775,-2.160059 3.303412,-2.355145"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1012" />
|
||||
<path
|
||||
d="m 71.828694,92.281206 c 1.58503,1.861291 0.793397,3.354282 0.67063,4.001523 -0.129822,0.689046 0.0949,1.759339 0.0949,1.759339 0,0 -1.070327,-0.224755 -1.760008,-0.09384 -0.648406,0.123754 -2.142772,0.914329 -4.002617,-0.671654"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1014" />
|
||||
<path
|
||||
d="m 55.416412,85.86132 c -1.586089,-1.859844 -0.795161,-3.354211 -0.671689,-4.002617 0.130528,-0.68968 -0.09384,-1.760008 -0.09384,-1.760008 0,0 1.070328,0.224719 1.75895,0.0949 0.6477,-0.122766 2.140656,-0.9144 4.001559,0.670631"
|
||||
style="fill:none;stroke:#f0cd1e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1016" />
|
||||
<path
|
||||
d="m 67.584424,86.404245 c -1.159228,-0.384527 -2.452159,-0.199672 -3.661834,-0.188383 -1.421341,0.01341 -2.849739,-0.0099 -4.259792,0.185561 -0.290688,0.04057 -0.290688,0.544336 0,0.584906 1.21532,0.16898 2.437695,0.164747 3.662892,0.179916 0.386998,0.0049 0.788459,0.02646 1.194506,0.04657 0.158044,0.493183 0.319264,0.985309 0.484364,1.476375 -1.362075,-0.519641 -2.689578,-0.398639 -3.169356,1.330678 -0.39758,1.431572 0.832556,3.550497 2.464506,3.218251 0.221544,-0.04523 0.310797,-0.372639 0.137583,-0.520771 -0.61595,-0.527544 -1.262944,-0.761788 -1.587147,-1.586935 -0.279047,-0.7112 -0.03986,-1.951567 1.035755,-1.781528 0.542573,0.08572 1.081617,0.217311 1.587853,0.433916 0.280106,0.119945 0.718609,-0.08008 0.582084,-0.447322 -0.259645,-0.6985 -0.515761,-1.398411 -0.779992,-2.094442 0.795161,0.01764 1.5875,-0.01834 2.308578,-0.257527 0.286103,-0.09454 0.286103,-0.484364 0,-0.579262"
|
||||
style="fill:#f3d11e;fill-opacity:0.2;fill-rule:nonzero;stroke:#f0cd1e;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path1018" />
|
||||
<path
|
||||
d="m 63.977271,84.875306 c -0.01235,0.0021 -0.02434,0.0039 -0.03634,0.006 -0.271639,0.04516 -0.330553,0.420158 -0.156281,0.59443 0.17392,0.17392 0.548922,0.115006 0.594078,-0.156986 l 0.0056,-0.03598 c 0.04375,-0.262467 -0.144638,-0.45085 -0.407105,-0.407459"
|
||||
style="fill:#f3d11e;fill-opacity:0.2;fill-rule:nonzero;stroke:#f0cd1e;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path1020" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7 KiB |
76
public/chakras/muladhara.svg
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.373173mm"
|
||||
height="26.373281mm"
|
||||
viewBox="0 0 26.373173 26.373281"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="muladhara.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="60.912107"
|
||||
inkscape:cy="50.333398"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.435614,-75.884446)">
|
||||
<path
|
||||
d="M 58.003949,94.689232 H 69.240346 V 83.452977 H 58.003949 Z"
|
||||
style="fill:none;stroke:#eb3c2d;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.5"
|
||||
id="path1042" />
|
||||
<path
|
||||
d="m 68.658227,94.106902 c -2.781406,2.781265 -7.290647,2.781265 -10.071665,0 -2.7813,-2.781335 -2.7813,-7.290541 0,-10.071489 2.781018,-2.7813 7.290259,-2.7813 10.071665,0 2.7813,2.780948 2.7813,7.290154 0,10.071489 z"
|
||||
style="fill:none;stroke:#eb3c2d;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.5"
|
||||
id="path1044" />
|
||||
<path
|
||||
d="m 69.587091,95.566908 v -0.01199 c 0.08925,-0.08156 0.1778,-0.165911 0.265289,-0.253259 1.719792,-1.720215 2.580922,-3.974677 2.579864,-6.230691 0.0011,-2.255661 -0.860072,-4.510264 -2.579864,-6.230056 -0.08749,-0.08784 -0.176036,-0.172156 -0.265289,-0.253647 v -0.01199 c 3.84422,0.0011 4.563886,3.281892 5.094817,4.341284 0.529872,1.060803 1.756481,2.154414 1.756481,2.154414 0,0 -1.226609,1.093611 -1.756481,2.154414 -0.530931,1.059603 -1.250597,4.340437 -5.094817,4.34153 z"
|
||||
style="fill:none;stroke:#eb3c2d;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.5"
|
||||
id="path1046" />
|
||||
<path
|
||||
d="m 70.118022,83.106197 h -0.01199 c -0.08149,-0.08961 -0.165805,-0.1778 -0.253294,-0.265289 -1.720145,-1.720145 -3.974677,-2.580923 -6.230409,-2.579864 -2.255979,-0.0011 -4.510582,0.859719 -6.230373,2.579864 -0.08752,0.08749 -0.172121,0.175683 -0.253577,0.265289 h -0.01196 c 9.52e-4,-3.84422 3.281786,-4.563887 4.341495,-5.094817 1.060803,-0.529872 2.154414,-1.756481 2.154414,-1.756481 0,0 1.093611,1.226609 2.154167,1.756481 1.059709,0.53093 4.340472,1.250597 4.341531,5.094817 z"
|
||||
style="fill:none;stroke:#eb3c2d;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.5"
|
||||
id="path1048" />
|
||||
<path
|
||||
d="m 57.657345,82.575266 v 0.01199 c -0.08929,0.08149 -0.177764,0.165805 -0.265253,0.253294 -1.720215,1.719792 -2.580923,3.974748 -2.579829,6.230409 -0.0011,2.256014 0.859614,4.510617 2.579829,6.230409 0.08749,0.08749 0.175965,0.17212 0.265253,0.253541 v 0.01199 c -3.844149,-9.52e-4 -4.563921,-3.281786 -5.094852,-4.34153 -0.529872,-1.060803 -1.756481,-2.154414 -1.756481,-2.154414 0,0 1.226609,-1.093611 1.756481,-2.154061 0.530931,-1.060098 1.250703,-4.340578 5.094852,-4.341637 z"
|
||||
style="fill:none;stroke:#eb3c2d;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.5"
|
||||
id="path1050" />
|
||||
<path
|
||||
d="m 57.126556,95.035978 h 0.01196 c 0.08146,0.08929 0.165806,0.177765 0.253294,0.265253 1.719792,1.719792 3.974677,2.580923 6.230656,2.57997 2.255732,9.53e-4 4.510476,-0.860178 6.230268,-2.57997 0.08749,-0.08749 0.171803,-0.175965 0.253647,-0.265253 h 0.01199 c -0.0011,3.844149 -3.282033,4.563921 -4.341742,5.094852 -1.060838,0.52987 -2.154167,1.75644 -2.154167,1.75644 0,0 -1.093893,-1.22657 -2.154273,-1.75644 -1.060132,-0.530931 -4.340684,-1.250703 -4.341636,-5.094852 z"
|
||||
style="fill:none;stroke:#eb3c2d;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.5"
|
||||
id="path1052" />
|
||||
<path
|
||||
d="m 65.891144,87.468647 c -0.01517,-0.271286 -0.06809,-0.5461 -0.09028,-0.811742 -0.0049,-0.06068 -0.0074,-0.121708 -0.01129,-0.182739 0.35553,-0.04515 0.703086,-0.116063 1.029406,-0.237419 0.231986,-0.08608 0.302542,-0.494242 0,-0.542925 -0.349638,-0.05609 -0.702804,-0.06491 -1.05724,-0.0508 -0.0029,-0.1651 -0.0056,-0.329847 -0.01129,-0.494595 -0.01489,-0.439561 -0.622053,-0.419805 -0.668232,0 -0.02011,0.181681 -0.02783,0.363362 -0.03101,0.545042 -0.336656,0.0314 -0.672747,0.06844 -1.006933,0.08996 -1.051172,0.06809 -2.11127,0.03281 -3.158032,0.156634 -0.242676,0.02858 -0.350591,0.418394 -0.06311,0.46743 0.91761,0.155928 1.841465,0.145345 2.770399,0.137937 0.479002,-0.0035 0.984885,0.0071 1.488017,-0.01341 0.0031,0.04163 0.0056,0.08326 0.0091,0.124883 0.02483,0.264584 0.0441,0.555625 0.106962,0.828323 -0.517173,0.09454 -1.024185,0.348544 -1.295365,0.627944 -0.506554,0.522817 -1.007216,-0.125589 -1.696932,-0.123472 -0.676204,0.0014 -1.146104,0.220839 -1.577552,0.751064 -0.970139,1.194153 -0.01764,2.845153 1.034345,3.556353 0.260315,0.175683 0.623958,-0.177095 0.438079,-0.43815 -0.56582,-0.796925 -1.240649,-1.366661 -1.038895,-2.4638 0.334433,-1.816453 1.509501,-0.667809 2.138574,-0.565503 0.02148,0.0035 0.411621,0.122414 0.540033,0.04692 0.592702,-0.347133 1.157146,-0.869597 1.773131,-0.759178 0.0459,0.02787 0.09839,0.04269 0.149261,0.03986 0.188348,0.06562 0.381705,0.191206 0.582753,0.407106 0.919551,0.987072 -0.418359,2.264833 -1.041788,2.975328 -0.18796,0.214136 0.102235,0.509411 0.313902,0.313619 1.11058,-1.027289 1.666134,-2.112786 1.262697,-3.600097 -0.131303,-0.483659 -0.475967,-0.716139 -0.888682,-0.784578"
|
||||
style="fill:#ef3e31;fill-opacity:0.5;fill-rule:nonzero;stroke:#eb3c2d;stroke-width:0.0352778;stroke-opacity:0.5"
|
||||
id="path1054" />
|
||||
<path
|
||||
d="m 63.635024,91.761247 c -0.264301,-0.466725 -0.542255,-0.922514 -0.832203,-1.371953 0.251495,-0.02434 0.502991,-0.04904 0.75558,-0.06773 0.359798,-0.02681 0.735577,-0.105128 1.101337,-0.09172 -0.335845,0.512586 -0.699911,1.013178 -1.024714,1.531408 m 1.54298,-1.990372 c -0.971515,-0.199672 -2.037716,-0.09807 -3.024365,-0.07232 -0.264865,0.0071 -0.491842,0.319969 -0.323426,0.563738 0.478719,0.692503 0.978112,1.350081 1.513769,1.996793 -0.0834,0.201719 0.166441,0.383364 0.334716,0.228883 0.690668,-0.633942 1.195176,-1.521531 1.658056,-2.326923 0.08075,-0.141111 0.01199,-0.354894 -0.15875,-0.390172"
|
||||
style="fill:#ef3e31;fill-opacity:0.5;fill-rule:nonzero;stroke:#eb3c2d;stroke-width:0.0352778;stroke-opacity:0.5"
|
||||
id="path1056" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
132
public/chakras/sahasrara.svg
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.116741mm"
|
||||
height="29.412525mm"
|
||||
viewBox="0 0 26.116741 29.412525"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="sahasrara.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="49.077479"
|
||||
inkscape:cy="50.333398"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.56383,-74.364824)">
|
||||
<path
|
||||
d="m 70.582859,88.910538 c 0,3.844713 -3.116439,6.961576 -6.961012,6.961576 -3.844572,0 -6.961364,-3.116863 -6.961364,-6.961576 0,-3.844573 3.116792,-6.961012 6.961364,-6.961012 3.844573,0 6.961012,3.116439 6.961012,6.961012 z"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path882" />
|
||||
<path
|
||||
d="m 72.432473,89.071052 c 0,4.016728 -2.689578,7.406146 -6.364465,8.465997 -0.776463,0.224331 -1.59773,0.34424 -2.445808,0.34424 -0.849136,0 -1.668992,-0.119909 -2.445809,-0.34424 -3.676297,-1.059851 -6.365522,-4.449269 -6.365522,-8.465997 0,-4.016728 2.689225,-7.406217 6.365522,-8.465962 0.776817,-0.224367 1.596673,-0.343958 2.445809,-0.343958 0.848078,0 1.669345,0.119591 2.445808,0.343958 3.674887,1.059745 6.364465,4.449234 6.364465,8.465962 z"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path884" />
|
||||
<path
|
||||
d="m 61.176391,80.60509 c 0.229659,-2.099028 1.25977,-2.560108 1.619603,-2.911122 0.405342,-0.395464 0.825148,-1.310923 0.825148,-1.310923 0,0 0.419452,0.915459 0.825852,1.310923 0.361245,0.351014 1.391709,0.812094 1.619603,2.911122"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path886" />
|
||||
<path
|
||||
d="m 66.068008,97.536908 c -0.229305,2.099028 -1.259769,2.560392 -1.621014,2.911122 -0.4064,0.3955 -0.826205,1.31092 -0.826205,1.31092 0,0 -0.419453,-0.91542 -0.824795,-1.31092 -0.359833,-0.35073 -1.389944,-0.812094 -1.619603,-2.911122"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path888" />
|
||||
<path
|
||||
d="m 72.088162,86.625243 c 2.099028,0.229306 2.560814,1.25977 2.911122,1.61925 0.395464,0.405695 1.310922,0.8255 1.310922,0.8255 0,0 -0.915458,0.4191 -1.310922,0.825853 -0.350308,0.360892 -0.812094,1.391356 -2.911122,1.619603"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path890" />
|
||||
<path
|
||||
d="m 55.156238,91.51686 c -2.099028,-0.229658 -2.560108,-1.259769 -2.911122,-1.620661 -0.395464,-0.407106 -1.310922,-0.826558 -1.310922,-0.826558 0,0 0.915458,-0.4191 1.310922,-0.824795 0.351014,-0.35948 0.812094,-1.389944 2.911122,-1.619603"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path892" />
|
||||
<path
|
||||
d="m 65.843642,80.543707 c 1.269647,-1.687336 2.390775,-1.557867 2.879019,-1.675695 0.551039,-0.132644 1.379009,-0.705555 1.379009,-0.705555 0,0 -0.106892,1.001536 0.04092,1.549047 0.131233,0.485775 0.78105,1.408995 -0.0949,3.32987"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path894" />
|
||||
<path
|
||||
d="m 61.400758,97.598503 c -1.269294,1.687406 -2.390775,1.557866 -2.880078,1.675024 -0.551744,0.131868 -1.380067,0.70485 -1.380067,0.70485 0,0 0.106892,-1.001148 -0.03951,-1.548342 -0.130175,-0.485069 -0.780344,-1.408218 0.09454,-3.330187"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path896" />
|
||||
<path
|
||||
d="m 69.887534,82.87451 c 1.952978,-0.801864 2.85115,-0.117828 3.330928,0.02999 0.541514,0.167217 1.545872,0.09772 1.545872,0.09772 0,0 -0.60325,0.80645 -0.75565,1.35255 -0.135467,0.484717 -0.04833,1.610431 -1.782586,2.814462"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path898" />
|
||||
<path
|
||||
d="m 57.356866,95.267559 c -1.95333,0.802287 -2.851503,0.117827 -3.331986,-0.03101 -0.541514,-0.168239 -1.546225,-0.09906 -1.546225,-0.09906 0,0 0.60325,-0.806167 0.756708,-1.351174 0.13582,-0.483693 0.04868,-1.609407 1.782234,-2.814496"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path900" />
|
||||
<path
|
||||
d="m 61.3997,80.543707 c -1.269647,-1.687336 -2.391128,-1.557867 -2.879373,-1.675695 -0.550333,-0.132644 -1.378655,-0.705555 -1.378655,-0.705555 0,0 0.106891,1.001536 -0.04092,1.549047 -0.131234,0.485775 -0.78105,1.408995 0.0949,3.32987"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path902" />
|
||||
<path
|
||||
d="m 65.842231,97.598503 c 1.27,1.687406 2.391128,1.557866 2.880783,1.675024 0.551392,0.131868 1.379714,0.70485 1.379714,0.70485 0,0 -0.107244,-1.001148 0.03951,-1.548342 0.130528,-0.485069 0.779992,-1.408218 -0.09454,-3.330187"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path904" />
|
||||
<path
|
||||
d="m 57.355455,82.87451 c -1.952978,-0.801864 -2.85115,-0.117828 -3.330575,0.02999 -0.541161,0.167217 -1.546225,0.09772 -1.546225,0.09772 0,0 0.60325,0.80645 0.756003,1.35255 0.135466,0.484717 0.04798,1.610431 1.782233,2.814462"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path906" />
|
||||
<path
|
||||
d="m 69.886475,95.267559 c 1.952978,0.802287 2.851151,0.117827 3.331634,-0.03101 0.541514,-0.168239 1.546225,-0.09906 1.546225,-0.09906 0,0 -0.60325,-0.806167 -0.756356,-1.351174 -0.136172,-0.483693 -0.04868,-1.609407 -1.782233,-2.814496"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path908" />
|
||||
<path
|
||||
d="m 58.557369,80.510193 h -0.0092 c 7.05e-4,-3.240617 2.563283,-3.846689 3.391253,-4.294011 0.828675,-0.446617 1.68275,-1.480962 1.68275,-1.480962 0,0 0.854075,1.034345 1.68275,1.480962 0.82797,0.447322 3.390548,1.053394 3.391253,4.294011 h -0.0092"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path910" />
|
||||
<path
|
||||
d="m 68.687031,97.631981 h 0.0092 c -7.05e-4,3.240189 -2.563283,3.846689 -3.391253,4.293979 -0.828675,0.44661 -1.682397,1.48099 -1.682397,1.48099 0,0 -0.854428,-1.03438 -1.683103,-1.48099 -0.82797,-0.44729 -3.390548,-1.05379 -3.391253,-4.293979 h 0.0092"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path912" />
|
||||
<path
|
||||
d="m 68.71102,80.524304 -0.0046,-0.0081 c 2.8448,-1.551517 4.604455,0.407811 5.393619,0.919692 0.789164,0.513644 2.106084,0.768703 2.106084,0.768703 0,0 -0.498475,1.244953 -0.493536,2.185811 0.0032,0.878769 0.609953,3.152422 -1.626306,4.71417"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path914" />
|
||||
<path
|
||||
d="m 58.533733,97.6178 0.0046,0.0081 c -2.8448,1.551516 -4.604809,-0.407494 -5.393972,-0.920009 -0.789164,-0.513292 -2.106084,-0.768104 -2.106084,-0.768104 0,0 0.498828,-1.245199 0.493889,-2.186128 -0.0035,-0.873125 -0.602192,-3.121731 1.58115,-4.682067"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path916" />
|
||||
<path
|
||||
d="m 58.533733,80.524304 0.0046,-0.0081 c -2.8448,-1.551517 -4.604809,0.407811 -5.393972,0.919692 -0.789164,0.513644 -2.106084,0.768703 -2.106084,0.768703 0,0 0.498828,1.244953 0.493889,2.185811 -0.0035,0.873831 -0.602897,3.125258 1.586089,4.685947"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path918" />
|
||||
<path
|
||||
d="m 68.71102,97.6178 -0.0046,0.0081 c 2.8448,1.551516 4.604455,-0.407494 5.393619,-0.920009 0.789164,-0.513292 2.106084,-0.768104 2.106084,-0.768104 0,0 -0.498475,-1.245199 -0.493536,-2.186128 0.0032,-0.874219 0.60325,-3.127376 -1.589264,-4.688065"
|
||||
style="fill:none;stroke:#98549c;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path920" />
|
||||
<path
|
||||
d="m 66.337178,88.267071 c -0.274108,-0.07479 -0.543983,-0.07726 -0.801864,-0.02505 0.02364,-0.03493 0.05045,-0.06773 0.07585,-0.101247 0.23107,-0.112184 0.439561,-0.303742 0.595489,-0.60325 0.161925,-0.310798 -0.191558,-0.769056 -0.523875,-0.524228 -0.258939,0.1905 -0.470253,0.405342 -0.651933,0.639233 -0.448734,-0.04621 -1.038578,-0.651228 -1.121834,-0.946503 -0.06385,-0.226483 -0.447675,-0.190147 -0.422275,0.05715 0.07056,0.688975 0.5969,1.241778 1.18992,1.428045 -0.15487,0.276225 -0.287514,0.570794 -0.417689,0.879475 -0.155575,0.370417 0.389467,0.62477 0.640997,0.373239 0.26917,-0.269522 1.259417,-1.011767 1.341967,0.03316 0.04868,0.381353 -0.06668,0.729192 -0.34537,1.043164 -0.408869,0.307269 -1.066094,0.210256 -1.328914,-0.274108 -0.127,-0.234598 -0.430036,-0.174978 -0.538338,0.02046 -0.177095,-0.479072 -0.499887,-0.787753 -0.916164,-0.983192 0.542219,-0.421217 0.769408,-1.038931 0.201083,-1.738842 -0.327731,-0.403225 -0.923925,-0.500239 -1.387828,-0.358775 -0.440619,0.134409 -0.545042,0.722842 -0.767644,1.056217 -0.109009,0.163336 0.10548,0.371475 0.267758,0.267406 0.138289,-0.08819 0.279753,-0.174625 0.373592,-0.314678 0.171097,-0.336903 0.493536,-0.445206 0.967669,-0.325261 0.08396,0.236008 0.167922,0.472016 0.251883,0.708025 -0.212019,0.252589 -0.478719,0.411691 -0.799747,0.476602 -0.281164,0.09243 -0.329141,0.550334 0,0.617009 0.417689,0.08502 0.773995,0.176389 1.046339,0.548569 0.559153,0.764823 -0.481542,0.936978 -0.828675,0.976842 -0.732719,0.08396 -1.323975,-0.573617 -1.422047,-1.217789 -0.04163,-0.274461 -0.454025,-0.29845 -0.574322,-0.07514 -0.318206,0.592314 0.0032,1.233664 0.552802,1.573037 0.724606,0.446264 1.405114,0.573616 2.223559,0.300919 0.402167,-0.134055 0.75953,-0.501297 0.858661,-0.923572 0.197203,0.428978 0.699911,0.6604 1.223786,0.563386 0.775759,-0.144286 1.339145,-0.542925 1.55575,-1.320095 0.136525,-0.491772 0.143934,-1.649589 -0.518583,-1.830211"
|
||||
style="fill:#98549c;fill-opacity:0.2;fill-rule:nonzero;stroke:#98549c;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path922" />
|
||||
<path
|
||||
d="m 64.621972,86.221666 c -0.540455,0 -0.540455,0.837847 0,0.837847 0.540456,0 0.540456,-0.837847 0,-0.837847"
|
||||
style="fill:#98549c;fill-opacity:0.2;fill-rule:nonzero;stroke:#98549c;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path924" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
84
public/chakras/swadhisthana.svg
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.373173mm"
|
||||
height="26.373281mm"
|
||||
viewBox="0 0 26.373173 26.373281"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="swadhisthana.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="50.671531"
|
||||
inkscape:cy="50.333398"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.435614,-75.884446)">
|
||||
<path
|
||||
d="m 70.583566,88.910556 c 0,3.844713 -3.11644,6.961576 -6.961365,6.961576 -3.844925,0 -6.961364,-3.116863 -6.961364,-6.961576 0,-3.844573 3.116439,-6.961012 6.961364,-6.961012 3.844925,0 6.961365,3.116439 6.961365,6.961012 z"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1022" />
|
||||
<path
|
||||
d="m 72.432474,89.07107 c 0,4.865864 -3.944409,8.810237 -8.810273,8.810237 -4.865864,0 -8.810273,-3.944373 -8.810273,-8.810237 0,-4.865512 3.944409,-8.810273 8.810273,-8.810273 4.865864,0 8.810273,3.944761 8.810273,8.810273 z"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1024" />
|
||||
<path
|
||||
d="m 59.139454,81.494108 h -0.0081 c 7.05e-4,-2.867731 2.268714,-3.404306 3.001433,-3.800122 0.733073,-0.395464 1.489428,-1.310923 1.489428,-1.310923 0,0 0.756003,0.915459 1.489428,1.310923 0.73272,0.395816 3.000728,0.932391 3.001434,3.800122 h -0.0081"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1026" />
|
||||
<path
|
||||
d="m 68.104949,96.648102 h 0.0081 c -7.06e-4,2.867695 -2.268714,3.404308 -3.001434,3.800338 -0.733425,0.39549 -1.489428,1.31067 -1.489428,1.31067 0,0 -0.756003,-0.91518 -1.489428,-1.31067 -0.732719,-0.39603 -3.000728,-0.932643 -3.001433,-3.800338 h 0.0081"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1028" />
|
||||
<path
|
||||
d="m 68.126115,81.506808 -0.0039,-0.0074 c 2.517422,-1.373011 4.075289,0.360892 4.773789,0.814564 0.698147,0.454378 1.863725,0.67945 1.863725,0.67945 0,0 -0.440972,1.102078 -0.436739,1.935339 0.0032,0.832908 0.61842,3.080808 -1.89865,4.455231 l -0.0039,-0.0074"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1030" />
|
||||
<path
|
||||
d="m 59.118287,96.635402 0.0039,0.0074 c -2.517423,1.373081 -4.07529,-0.360892 -4.77379,-0.814529 -0.698147,-0.454343 -1.863725,-0.67938 -1.863725,-0.67938 0,0 0.440973,-1.102148 0.436739,-1.93548 -0.0032,-0.832873 -0.618419,-3.080879 1.898298,-4.455301 l 0.0042,0.0071"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1032" />
|
||||
<path
|
||||
d="m 59.118287,81.506808 0.0039,-0.0074 c -2.517423,-1.373011 -4.07529,0.360892 -4.77379,0.814564 -0.698147,0.454378 -1.863725,0.67945 -1.863725,0.67945 0,0 0.440973,1.102078 0.436739,1.935339 -0.0032,0.832908 -0.618419,3.080808 1.898298,4.455231 l 0.0042,-0.0074"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1034" />
|
||||
<path
|
||||
d="m 68.126115,96.635402 -0.0039,0.0074 c 2.517422,1.373081 4.075289,-0.360892 4.773789,-0.814529 0.698147,-0.454343 1.863725,-0.67938 1.863725,-0.67938 0,0 -0.440972,-1.102148 -0.436739,-1.93548 0.0032,-0.832873 0.61842,-3.080879 -1.89865,-4.455301 l -0.0039,0.0071"
|
||||
style="fill:none;stroke:#f5911e;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path1036" />
|
||||
<path
|
||||
d="m 63.728387,91.072378 c -0.76835,0.270934 -1.62948,-0.164041 -2.18828,-0.663575 -1.247423,-1.115836 0.346427,-2.020358 1.401586,-2.081742 0.816328,-0.04727 1.561042,0.443089 2.067983,1.045281 0.0071,0.185914 0.01729,0.372181 0.02646,0.5588 -0.454025,0.388056 -0.667103,0.915458 -1.307748,1.141236 m 2.884664,-4.812594 c -1.988255,-0.02399 -4.014611,0.01834 -5.997928,0.170744 -0.275166,0.02117 -0.270228,0.396875 0,0.424392 1.464028,0.152752 2.96792,0.199319 4.455231,0.160161 -0.05609,0.494594 -0.07514,0.990247 -0.0762,1.485547 -0.698147,-0.703086 -1.687689,-1.094317 -2.71145,-0.858308 -1.203325,0.277283 -2.325864,1.122891 -1.883481,2.473325 0.382411,1.165578 1.811867,1.806575 2.941109,1.827389 0.561975,0.01094 1.309864,-0.299861 1.770944,-0.779639 0.05786,0.712258 0.166159,1.424728 0.464256,2.064561 0.0575,0.123861 0.258233,0.08929 0.287161,-0.03736 0.219428,-0.975219 0.06632,-2.001449 0.03422,-2.995577 -0.03351,-1.070328 -0.01129,-2.145595 -0.101601,-3.212748 0.272698,-0.0127 0.547512,-0.02081 0.817739,-0.03916 0.436034,-0.02928 0.444853,-0.677686 0,-0.68333"
|
||||
style="fill:#f7931e;fill-opacity:0.2;fill-rule:nonzero;stroke:#f5911e;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path1038" />
|
||||
<path
|
||||
d="m 65.495099,84.837031 c -0.70732,0 -0.70732,1.096786 0,1.096786 0.707319,0 0.707319,-1.096786 0,-1.096786"
|
||||
style="fill:#f7931e;fill-opacity:0.2;fill-rule:nonzero;stroke:#f5911e;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path1040" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
124
public/chakras/vishuddha.svg
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="26.373173mm"
|
||||
height="26.373281mm"
|
||||
viewBox="0 0 26.373173 26.373281"
|
||||
version="1.1"
|
||||
id="svg1911"
|
||||
inkscape:version="1.2 (dc2aedaf03, 2022-05-15)"
|
||||
sodipodi:docname="vishuddha.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1913"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
showgrid="false"
|
||||
inkscape:zoom="10.35098"
|
||||
inkscape:cx="53.473198"
|
||||
inkscape:cy="52.941847"
|
||||
inkscape:window-width="2004"
|
||||
inkscape:window-height="1979"
|
||||
inkscape:window-x="8"
|
||||
inkscape:window-y="64"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1908" />
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-50.435614,-75.884446)">
|
||||
<path
|
||||
d="m 70.583212,88.910308 c 0,3.844713 -3.116439,6.961576 -6.961364,6.961576 -3.844573,0 -6.961012,-3.116863 -6.961012,-6.961576 0,-3.844573 3.116439,-6.961012 6.961012,-6.961012 3.844925,0 6.961364,3.116439 6.961364,6.961012 z"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path926" />
|
||||
<path
|
||||
d="m 72.432473,89.070822 c 0,4.016728 -2.689225,7.406146 -6.364464,8.465997 -0.776111,0.224331 -1.597378,0.34424 -2.445456,0.34424 -0.849136,0 -1.669344,-0.119909 -2.445456,-0.34424 -3.676297,-1.059851 -6.365875,-4.449269 -6.365875,-8.465997 0,-4.016728 2.689578,-7.406217 6.365875,-8.465962 0.776112,-0.224367 1.59632,-0.343958 2.445456,-0.343958 0.848078,0 1.669345,0.119591 2.445456,0.343958 3.675239,1.059745 6.364464,4.449234 6.364464,8.465962 z"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path928" />
|
||||
<path
|
||||
d="m 61.893236,80.431646 c 0.189795,-1.950861 0.89147,-2.397125 1.139825,-2.737555 0.289631,-0.395464 0.588787,-1.310923 0.588787,-1.310923 0,0 0.298802,0.915459 0.589138,1.310923 0.25012,0.34043 0.951795,0.786694 1.140178,2.737555"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path930" />
|
||||
<path
|
||||
d="m 65.351517,97.710033 c -0.188736,1.950896 -0.890058,2.397227 -1.140178,2.738047 -0.290689,0.39508 -0.589139,1.31093 -0.589139,1.31093 0,0 -0.299508,-0.91585 -0.589139,-1.31093 -0.248355,-0.34082 -0.95003,-0.787151 -1.139825,-2.738047"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path932" />
|
||||
<path
|
||||
d="m 72.261376,87.341505 c 1.950861,0.1905 2.397125,0.891822 2.737908,1.140178 0.395112,0.28963 1.310923,0.589139 1.310923,0.589139 0,0 -0.915811,0.298803 -1.310923,0.589492 -0.340783,0.249766 -0.787047,0.951088 -2.737908,1.139825"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path934" />
|
||||
<path
|
||||
d="m 54.983025,90.799786 c -1.951214,-0.188736 -2.397478,-0.889706 -2.737909,-1.139825 -0.395111,-0.290336 -1.310922,-0.589139 -1.310922,-0.589139 0,0 0.915811,-0.299509 1.310922,-0.588786 0.340431,-0.249061 0.786695,-0.950031 2.737909,-1.140531"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path936" />
|
||||
<path
|
||||
d="m 68.508526,81.739394 c 1.513769,-1.245306 2.325158,-1.065037 2.742141,-1.129948 0.484012,-0.07479 1.343378,-0.510116 1.343378,-0.510116 0,0 -0.436739,0.858308 -0.510469,1.343377 -0.06421,0.417689 0.116417,1.229078 -1.129595,2.742142"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path938" />
|
||||
<path
|
||||
d="m 58.735875,96.402568 c -1.513064,1.24587 -2.324453,1.065635 -2.741789,1.129559 -0.48507,0.07401 -1.343731,0.510434 -1.343731,0.510434 0,0 0.435681,-0.859331 0.51047,-1.343448 0.06491,-0.416596 -0.115359,-1.228232 1.129594,-2.742036"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path940" />
|
||||
<path
|
||||
d="m 70.953981,93.956971 c 1.244953,1.514157 1.064684,2.325546 1.129595,2.742142 0.07514,0.484363 0.510469,1.343448 0.510469,1.343448 0,0 -0.858308,-0.436421 -1.343378,-0.510575 -0.417688,-0.06378 -1.229077,0.116452 -2.742141,-1.12956"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path942" />
|
||||
<path
|
||||
d="m 56.290772,84.184496 c -1.246011,-1.513063 -1.065742,-2.324452 -1.129947,-2.741789 -0.07373,-0.485069 -0.510117,-1.34373 -0.510117,-1.34373 0,0 0.859367,0.436033 1.343378,0.510469 0.416631,0.06491 1.228019,-0.115358 2.741789,1.129595"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path944" />
|
||||
<path
|
||||
d="m 65.369862,80.435527 c 0.929922,-1.725436 1.749425,-1.866195 2.110316,-2.083859 0.419806,-0.252236 1.05022,-0.981075 1.05022,-0.981075 0,0 -0.07867,0.960261 0.03634,1.436864 0.09878,0.410986 0.572911,1.093611 -0.0078,2.96545"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path946" />
|
||||
<path
|
||||
d="m 61.874539,97.70647 c -0.928864,1.726106 -1.748367,1.866124 -2.110317,2.083435 -0.421216,0.252345 -1.050572,0.981185 -1.050572,0.981185 0,0 0.07796,-0.960371 -0.03598,-1.436904 -0.09772,-0.410245 -0.571853,-1.092905 0.0081,-2.965379"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path948" />
|
||||
<path
|
||||
d="m 72.257848,90.818483 c 1.725436,0.929922 1.865842,1.749425 2.083506,2.110317 0.252589,0.419876 0.981075,1.050219 0.981075,1.050219 0,0 -0.959909,-0.07899 -1.436864,0.03637 -0.410633,0.09881 -1.093611,0.57284 -2.96545,-0.0077"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path950" />
|
||||
<path
|
||||
d="m 54.986553,87.323513 c -1.726142,-0.929216 -1.865842,-1.748719 -2.083506,-2.110316 -0.252236,-0.42157 -0.981075,-1.050926 -0.981075,-1.050926 0,0 0.960614,0.07796 1.436864,-0.03598 0.409928,-0.09772 1.092905,-0.571853 2.965803,0.0078"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path952" />
|
||||
<path
|
||||
d="m 61.874539,80.435527 c -0.929922,-1.725436 -1.749425,-1.866195 -2.110317,-2.083859 -0.419805,-0.252236 -1.050572,-0.981075 -1.050572,-0.981075 0,0 0.07902,0.960261 -0.03598,1.436864 -0.09878,0.410986 -0.572911,1.093611 0.0081,2.96545"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path954" />
|
||||
<path
|
||||
d="m 65.369862,97.70647 c 0.928863,1.726106 1.748366,1.866124 2.110316,2.083435 0.421217,0.252345 1.05022,0.981185 1.05022,0.981185 0,0 -0.07761,-0.960371 0.03634,-1.436904 0.09772,-0.410245 0.571853,-1.092905 -0.0078,-2.965379"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path956" />
|
||||
<path
|
||||
d="m 54.986553,90.818483 c -1.725437,0.929922 -1.865842,1.749425 -2.083506,2.110317 -0.252589,0.419876 -0.981075,1.050219 -0.981075,1.050219 0,0 0.959908,-0.07899 1.436864,0.03637 0.410633,0.09881 1.093611,0.57284 2.965803,-0.0077"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path958" />
|
||||
<path
|
||||
d="m 72.257848,87.323513 c 1.726142,-0.929216 1.866195,-1.748719 2.083506,-2.110316 0.252236,-0.42157 0.981075,-1.050926 0.981075,-1.050926 0,0 -0.960261,0.07796 -1.436864,-0.03598 -0.410281,-0.09772 -1.092906,-0.571853 -2.96545,0.0078"
|
||||
style="fill:none;stroke:#00bee1;stroke-width:0.740833;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:none;stroke-opacity:0.2"
|
||||
id="path960" />
|
||||
<path
|
||||
d="m 66.0225,86.517416 c -0.726369,-0.09137 -1.489427,0.03775 -2.223558,0.0508 -0.860425,0.01482 -1.742017,-0.118533 -2.595033,-0.02752 -0.397581,0.04233 -0.501298,0.576792 -0.09596,0.709437 0.740481,0.242005 1.545872,0.231775 2.319867,0.254352 0.404989,0.01199 0.837847,0.02223 1.268589,0.0025 0.0095,0.2286 0.02399,0.4572 0.04516,0.685447 -0.820914,-0.08079 -1.655586,-0.07902 -2.478264,-0.08467 -0.231069,-0.0014 -0.382058,0.183445 -0.399697,0.399345 -0.07373,0.904169 -0.07056,1.799519 0.01376,2.693106 -0.108302,0.350308 -0.07267,0.75318 0.102306,1.069234 0.01517,0.02783 0.03351,0.05676 0.0508,0.08569 3.53e-4,2.82e-4 3.53e-4,7.05e-4 3.53e-4,9.87e-4 0.02187,0.120686 0.08608,0.195792 0.167922,0.237138 0.196497,0.239783 0.457905,0.431482 0.763411,0.353483 0.126647,-0.03253 0.183445,-0.140829 0.197908,-0.260738 0.05891,-0.48955 -0.561622,-0.737553 -0.405694,-1.282947 0.164747,-0.575734 1.142647,-0.679803 1.623483,-0.726017 0.303037,-0.02928 0.41275,-0.486833 0.0762,-0.563033 -0.646289,-0.146756 -1.292577,-0.130528 -1.812925,0.172508 -0.01411,-0.464255 -0.01588,-0.928511 0.0056,-1.395236 0.793044,-0.02999 1.592439,-0.05362 2.377017,-0.157339 0.130527,-0.01729 0.268816,-0.104775 0.257527,-0.257528 -0.0254,-0.338314 -0.0635,-0.672394 -0.110772,-1.006122 0.330553,-0.03704 0.652992,-0.100189 0.952853,-0.20567 0.424744,-0.149577 0.318558,-0.694619 -0.100895,-0.747183"
|
||||
style="fill:#00bee5;fill-opacity:0.2;fill-rule:nonzero;stroke:#00bee1;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path962" />
|
||||
<path
|
||||
d="m 63.23485,85.298569 -0.02928,0.02928 c -0.375003,0.375003 0.205669,0.955675 0.580672,0.580673 l 0.02928,-0.02928 c 0.37465,-0.375003 -0.206023,-0.955322 -0.580673,-0.580672"
|
||||
style="fill:#00bee5;fill-opacity:0.2;fill-rule:nonzero;stroke:#00bee1;stroke-width:0.0352778;stroke-opacity:0.2"
|
||||
id="path964" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
80
src/App.vue
|
|
@ -1,77 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import 'vue-sonner/style.css'
|
||||
import { useMarketPreloader } from '@/modules/market/composables/useMarketPreloader'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { toast } from 'vue-sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
// Initialize theme (applies dark mode immediately)
|
||||
useTheme()
|
||||
|
||||
// Initialize preloader
|
||||
const marketPreloader = useMarketPreloader()
|
||||
|
||||
// Show layout on all pages except login
|
||||
const showLayout = computed(() => {
|
||||
return route.path !== '/login'
|
||||
})
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome back!')
|
||||
|
||||
// Trigger preloading after successful login
|
||||
marketPreloader.preloadMarket()
|
||||
|
||||
// Chat initialization is now handled by the chat module
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Initialize authentication
|
||||
try {
|
||||
await auth.initialize()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize authentication:', error)
|
||||
}
|
||||
|
||||
// Relay hub initialization is handled by the base module
|
||||
})
|
||||
|
||||
// Watch for authentication changes and trigger preloading
|
||||
watch(() => auth.isAuthenticated.value, async (isAuthenticated) => {
|
||||
if (isAuthenticated) {
|
||||
if (!marketPreloader.isPreloaded.value) {
|
||||
console.log('User authenticated, triggering market preload...')
|
||||
marketPreloader.preloadMarket()
|
||||
}
|
||||
// Chat connection is now handled by the chat module automatically
|
||||
}
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<!-- Sidebar layout for authenticated pages -->
|
||||
<AppLayout v-if="showLayout">
|
||||
<router-view />
|
||||
</AppLayout>
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Login page without sidebar -->
|
||||
<div v-else class="min-h-screen">
|
||||
<router-view />
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<Toaster />
|
||||
<!-- Login dialog -->
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -14,26 +14,8 @@ import App from './App.vue'
|
|||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Accept an auth token from a URL parameter (e.g. ?token=xxx).
|
||||
* This allows the main app to link users directly into Castle
|
||||
* without requiring a separate login. The token is stored in
|
||||
* localStorage and the parameter is stripped from the URL.
|
||||
*/
|
||||
function acceptTokenFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
if (token) {
|
||||
localStorage.setItem('lnbits_access_token', token)
|
||||
// Also persist user data key so auth service picks it up
|
||||
params.delete('token')
|
||||
const clean = params.toString()
|
||||
const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
console.log('[Castle] Auth token accepted from URL')
|
||||
}
|
||||
}
|
||||
import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
/**
|
||||
* Initialize the standalone Castle accounting app
|
||||
|
|
@ -42,7 +24,7 @@ export async function createAppInstance() {
|
|||
console.log('Starting Castle — Accounting App...')
|
||||
|
||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||
acceptTokenFromUrl()
|
||||
acceptTokenFromUrl('Castle')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
|
@ -89,9 +71,13 @@ export async function createAppInstance() {
|
|||
component: () => import('./views/SettingsPage.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
// Castle has no public view — every non-login route requires auth.
|
||||
installStrictAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
|
|
@ -131,22 +117,11 @@ export async function createAppInstance() {
|
|||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Initialize auth
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
|
||||
// Auth guard — only redirect for routes that explicitly require auth
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
|
||||
if (requiresAuth && !auth.isAuthenticated.value) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.isAuthenticated.value) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
markAuthReady(auth)
|
||||
|
||||
// Global error handling
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
// PWA service worker with periodic updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
registerSW({
|
||||
|
|
|
|||
|
|
@ -13,24 +13,8 @@ import App from './App.vue'
|
|||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
|
||||
/**
|
||||
* Accept an auth token from a URL parameter (e.g. ?token=xxx).
|
||||
* Allows the main app to link users directly into this standalone
|
||||
* app without requiring a separate login.
|
||||
*/
|
||||
function acceptTokenFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
if (token) {
|
||||
localStorage.setItem('lnbits_access_token', token)
|
||||
params.delete('token')
|
||||
const clean = params.toString()
|
||||
const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
console.log('[Sortir] Auth token accepted from URL')
|
||||
}
|
||||
}
|
||||
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
/**
|
||||
* Initialize the standalone activities app
|
||||
|
|
@ -39,7 +23,7 @@ export async function createAppInstance() {
|
|||
console.log('🚀 Starting Sortir — Activities App...')
|
||||
|
||||
// Accept token from URL before anything else (cross-subdomain auth relay)
|
||||
acceptTokenFromUrl()
|
||||
acceptTokenFromUrl('Sortir')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
|
|
@ -73,9 +57,12 @@ export async function createAppInstance() {
|
|||
component: () => import('./views/SettingsPage.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
installLenientAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
|
|
@ -109,22 +96,11 @@ export async function createAppInstance() {
|
|||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Initialize auth
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
|
||||
// Auth guard — only redirect for routes that explicitly require auth
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
|
||||
if (requiresAuth && !auth.isAuthenticated.value) {
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.isAuthenticated.value) {
|
||||
next('/')
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
markAuthReady(auth)
|
||||
|
||||
// Global error handling
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
// PWA service worker with periodic updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
registerSW({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import type { AppConfig } from './core/types'
|
||||
|
||||
function parseMapCenter(envValue: string | undefined, fallback: { lat: number; lng: number }) {
|
||||
if (!envValue) return fallback
|
||||
const [lat, lng] = envValue.split(',').map(Number)
|
||||
if (isNaN(lat) || isNaN(lng)) return fallback
|
||||
return { lat, lng }
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal AIO hub configuration.
|
||||
* The all-in-one app at app.${domain} ships only the base module —
|
||||
* each feature module (wallet, chat, market, tasks, forum, activities,
|
||||
* castle) is now its own standalone PWA at its own subdomain.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
|
|
@ -18,7 +17,7 @@ export const appConfig: AppConfig = {
|
|||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000, // 24 hours
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
|
|
@ -29,115 +28,9 @@ export const appConfig: AppConfig = {
|
|||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
'nostr-feed': {
|
||||
name: 'nostr-feed',
|
||||
enabled: false, // Disabled - replaced by links module
|
||||
lazy: false,
|
||||
config: {
|
||||
refreshInterval: 30000,
|
||||
maxPosts: 100,
|
||||
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]'),
|
||||
feedTypes: ['announcements', 'general']
|
||||
}
|
||||
},
|
||||
links: {
|
||||
name: 'links',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
maxSubmissions: 50,
|
||||
corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '',
|
||||
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
name: 'tasks',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
maxTasks: 200,
|
||||
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
|
||||
}
|
||||
},
|
||||
market: {
|
||||
name: 'market',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
defaultCurrency: 'sats',
|
||||
paymentTimeout: 300000, // 5 minutes
|
||||
maxOrderHistory: 50,
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
|
||||
}
|
||||
}
|
||||
},
|
||||
chat: {
|
||||
name: 'chat',
|
||||
enabled: true,
|
||||
lazy: false, // Load on startup to register routes
|
||||
config: {
|
||||
maxMessages: 500,
|
||||
autoScroll: true,
|
||||
showTimestamps: true,
|
||||
notifications: {
|
||||
enabled: true,
|
||||
soundEnabled: false,
|
||||
wildcardSupport: true
|
||||
}
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
name: 'activities',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||
apiKey: import.meta.env.VITE_API_KEY || ''
|
||||
},
|
||||
defaultMapCenter: parseMapCenter(import.meta.env.VITE_DEFAULT_MAP_CENTER, { lat: 46.6034, lng: 1.8883 }),
|
||||
maxTicketsPerUser: 10,
|
||||
enableMap: true,
|
||||
enablePrivateEvents: false
|
||||
}
|
||||
},
|
||||
wallet: {
|
||||
name: 'wallet',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
defaultReceiveAmount: 1000, // 1000 sats
|
||||
maxReceiveAmount: 1000000, // 1M sats
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
|
||||
},
|
||||
websocket: {
|
||||
enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false', // Can be disabled via env var
|
||||
reconnectDelay: 2000, // 2 seconds (increased from 1s to reduce server load)
|
||||
maxReconnectAttempts: 3, // Reduced from 5 to avoid overwhelming server
|
||||
fallbackToPolling: true, // Enable polling fallback when WebSocket fails
|
||||
pollingInterval: 10000 // 10 seconds for polling updates
|
||||
}
|
||||
}
|
||||
},
|
||||
expenses: {
|
||||
name: 'expenses',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000',
|
||||
timeout: 30000 // 30 seconds for API requests
|
||||
},
|
||||
defaultCurrency: 'sats',
|
||||
maxExpenseAmount: 1000000, // 1M sats
|
||||
requireDescription: true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
|
|
@ -146,4 +39,4 @@ export const appConfig: AppConfig = {
|
|||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
export default appConfig
|
||||
|
|
|
|||
176
src/app.ts
|
|
@ -1,67 +1,44 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
// Core plugin system
|
||||
import { pluginManager } from './core/plugin-manager'
|
||||
import { eventBus } from './core/event-bus'
|
||||
import { container } from './core/di-container'
|
||||
|
||||
// App configuration
|
||||
import appConfig from './app.config'
|
||||
|
||||
// Base modules
|
||||
import baseModule from './modules/base'
|
||||
import nostrFeedModule from './modules/nostr-feed'
|
||||
import chatModule from './modules/chat'
|
||||
import activitiesModule from './modules/activities'
|
||||
import marketModule from './modules/market'
|
||||
import walletModule from './modules/wallet'
|
||||
import expensesModule from './modules/expenses'
|
||||
import linksModule from './modules/links'
|
||||
import tasksModule from './modules/tasks'
|
||||
|
||||
// Root component
|
||||
import App from './App.vue'
|
||||
|
||||
// Styles
|
||||
import './assets/index.css'
|
||||
|
||||
// Use existing i18n setup
|
||||
import { i18n } from './i18n'
|
||||
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
|
||||
/**
|
||||
* Initialize and start the modular application
|
||||
* Initialize and start the minimal AIO hub.
|
||||
*
|
||||
* The all-in-one app at app.${domain} now ships only the base module
|
||||
* plus a chakra icon hub linking out to the standalone module apps
|
||||
* (wallet, chat, market, tasks, forum, activities, castle).
|
||||
*/
|
||||
export async function createAppInstance() {
|
||||
console.log('🚀 Starting modular application...')
|
||||
console.log('🚀 Starting AIO hub...')
|
||||
|
||||
// Create Vue app
|
||||
const app = createApp(App)
|
||||
|
||||
// Collect all module routes automatically to avoid duplication
|
||||
const moduleRoutes = [
|
||||
// Extract routes from modules directly
|
||||
...baseModule.routes || [],
|
||||
...nostrFeedModule.routes || [],
|
||||
...chatModule.routes || [],
|
||||
...activitiesModule.routes || [],
|
||||
...marketModule.routes || [],
|
||||
...walletModule.routes || [],
|
||||
...expensesModule.routes || [],
|
||||
...linksModule.routes || [],
|
||||
...tasksModule.routes || []
|
||||
].filter(Boolean)
|
||||
|
||||
// Create router with all routes available immediately
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
// Default routes
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('./pages/Home.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
name: 'hub',
|
||||
component: () => import('./pages/Hub.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
|
|
@ -71,175 +48,72 @@ export async function createAppInstance() {
|
|||
: () => import('./pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
// Pre-register module routes
|
||||
...moduleRoutes
|
||||
...moduleRoutes,
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
// Use existing i18n setup
|
||||
// Register guards immediately (Vue Router docs: before app.use(router)).
|
||||
// Guards await auth readiness internally — see router-helpers.ts.
|
||||
installLenientAuthGuard(router)
|
||||
|
||||
// Create Pinia store
|
||||
const pinia = createPinia()
|
||||
|
||||
// Install core plugins
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
// Initialize plugin manager
|
||||
pluginManager.init(app, router)
|
||||
|
||||
// Register modules based on configuration
|
||||
const moduleRegistrations = []
|
||||
|
||||
// Register base module first (required)
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
// Register nostr-feed module
|
||||
if (appConfig.modules['nostr-feed'].enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(nostrFeedModule, appConfig.modules['nostr-feed'])
|
||||
)
|
||||
}
|
||||
|
||||
// Register chat module
|
||||
if (appConfig.modules.chat.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(chatModule, appConfig.modules.chat)
|
||||
)
|
||||
}
|
||||
|
||||
// Register activities module (events + ticketing)
|
||||
if (appConfig.modules.activities?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(activitiesModule, appConfig.modules.activities)
|
||||
)
|
||||
}
|
||||
|
||||
// Register market module
|
||||
if (appConfig.modules.market.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(marketModule, appConfig.modules.market)
|
||||
)
|
||||
}
|
||||
|
||||
// Register wallet module
|
||||
if (appConfig.modules.wallet?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(walletModule, appConfig.modules.wallet)
|
||||
)
|
||||
}
|
||||
|
||||
// Register expenses module
|
||||
if (appConfig.modules.expenses?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(expensesModule, appConfig.modules.expenses)
|
||||
)
|
||||
}
|
||||
|
||||
// Register links module
|
||||
if (appConfig.modules.links?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(linksModule, appConfig.modules.links)
|
||||
)
|
||||
}
|
||||
|
||||
// Register tasks module
|
||||
if (appConfig.modules.tasks?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(tasksModule, appConfig.modules.tasks)
|
||||
)
|
||||
}
|
||||
|
||||
// Wait for all modules to register
|
||||
await Promise.all(moduleRegistrations)
|
||||
|
||||
// Install all enabled modules
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Initialize auth before setting up router guards
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API) so it can't be imported at
|
||||
// the top of this file. Once initialized, we signal the router-guard
|
||||
// promise so any pending navigations can resolve.
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
markAuthReady(auth)
|
||||
console.log('Auth initialized, isAuthenticated:', auth.isAuthenticated.value)
|
||||
|
||||
// Set up auth guard
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
// Default to requiring auth unless explicitly set to false
|
||||
const requiresAuth = to.meta.requiresAuth !== false
|
||||
|
||||
if (requiresAuth && !auth.isAuthenticated.value) {
|
||||
console.log(`Auth guard: User not authenticated, redirecting from ${to.path} to login`)
|
||||
next('/login')
|
||||
} else if (to.path === '/login' && auth.isAuthenticated.value) {
|
||||
console.log('Auth guard: User already authenticated, redirecting to home')
|
||||
next('/')
|
||||
} else {
|
||||
console.log(`Auth guard: Allowing navigation to ${to.path} (requiresAuth: ${requiresAuth}, authenticated: ${auth.isAuthenticated.value})`)
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
// Check initial route and redirect if needed
|
||||
if (!auth.isAuthenticated.value) {
|
||||
const currentRoute = router.currentRoute.value
|
||||
const requiresAuth = currentRoute.meta.requiresAuth !== false
|
||||
if (requiresAuth) {
|
||||
console.log('Initial route requires auth but user not authenticated, redirecting to login')
|
||||
await router.push('/login')
|
||||
}
|
||||
}
|
||||
|
||||
// Global error handling
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
// Development helpers
|
||||
if (appConfig.features.developmentMode) {
|
||||
// Expose debugging helpers globally
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
|
||||
console.log('🔧 Development mode enabled')
|
||||
console.log('Available globals: __pluginManager, __eventBus, __container')
|
||||
}
|
||||
|
||||
console.log('✅ Application initialized successfully')
|
||||
|
||||
console.log('✅ AIO hub initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the application
|
||||
*/
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
|
||||
// Mount the app
|
||||
app.mount('#app')
|
||||
|
||||
console.log('🎉 Application started!')
|
||||
|
||||
// Emit app started event
|
||||
console.log('🎉 AIO hub started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
|
||||
} catch (error) {
|
||||
console.error('💥 Failed to start application:', error)
|
||||
|
||||
// Show error to user
|
||||
console.error('💥 Failed to start AIO hub:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Application Failed to Start</h1>
|
||||
<h1>AIO hub failed to start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page or contact support.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
src/chat-app/App.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
55
src/chat-app/app.config.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Chat app configuration.
|
||||
* Only enables base + chat modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
name: 'base',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
nostr: {
|
||||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
},
|
||||
imageUpload: {
|
||||
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
chat: {
|
||||
name: 'chat',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
maxMessages: 500,
|
||||
autoScroll: true,
|
||||
showTimestamps: true,
|
||||
notifications: {
|
||||
enabled: true,
|
||||
soundEnabled: false,
|
||||
wildcardSupport: true
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
electronApp: false,
|
||||
developmentMode: import.meta.env.DEV
|
||||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
121
src/chat-app/app.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { pluginManager } from '@/core/plugin-manager'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { container } from '@/core/di-container'
|
||||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import chatModule from '@/modules/chat'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Chat app...')
|
||||
|
||||
acceptTokenFromUrl('Chat')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...chatModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/chat'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: import.meta.env.VITE_DEMO_MODE === 'true'
|
||||
? () => import('@/pages/LoginDemo.vue')
|
||||
: () => import('@/pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
...moduleRoutes,
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
// Chat has no public view — every non-login route requires auth.
|
||||
installStrictAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||
await changeLocale(defaultLocale)
|
||||
}
|
||||
|
||||
pluginManager.init(app, router)
|
||||
|
||||
const moduleRegistrations = []
|
||||
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.chat?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(chatModule, appConfig.modules.chat)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
markAuthReady(auth)
|
||||
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
if (appConfig.features.developmentMode) {
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Chat app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Chat app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Chat app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
20
src/chat-app/main.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
const intervalMS = 60 * 60 * 1000
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Chat app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
startApp()
|
||||
|
|
@ -27,9 +27,7 @@ export function useLocale() {
|
|||
const flagMap: Record<string, string> = {
|
||||
'en': '🇬🇧',
|
||||
'es': '🇪🇸',
|
||||
'fr': '🇫🇷',
|
||||
'de': '🇩🇪',
|
||||
'zh': '🇨🇳'
|
||||
'fr': '🇫🇷'
|
||||
}
|
||||
return flagMap[locale] || '🌐'
|
||||
}
|
||||
|
|
|
|||
104
src/forum-app/App.vue
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LogIn, Newspaper, Hash, SquarePen, Search, Bell,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon: any
|
||||
path?: string
|
||||
comingSoon?: { issue: number; label: string }
|
||||
}
|
||||
|
||||
const bottomTabs: Tab[] = [
|
||||
{ name: 'Posts', icon: Newspaper, path: '/forum' },
|
||||
{ name: 'Spaces', icon: Hash, comingSoon: { issue: 31, label: 'Spaces' } },
|
||||
{ name: 'Submit', icon: SquarePen, path: '/submit' },
|
||||
{ name: 'Search', icon: Search, comingSoon: { issue: 15, label: 'Search' } },
|
||||
{ name: 'Alerts', icon: Bell, comingSoon: { issue: 32, label: 'Notifications' } },
|
||||
]
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(tab: Tab): boolean {
|
||||
if (!tab.path) return false
|
||||
if (tab.path === '/forum') return route.path === '/forum' || route.path.startsWith('/submission/')
|
||||
return route.path.startsWith(tab.path)
|
||||
}
|
||||
|
||||
function onTabClick(tab: Tab) {
|
||||
if (tab.path) {
|
||||
router.push(tab.path)
|
||||
} else if (tab.comingSoon) {
|
||||
toast.info(`${tab.comingSoon.label} — coming soon`, {
|
||||
description: `Tracked on issue #${tab.comingSoon.issue}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.name"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
isActiveTab(tab)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.comingSoon ? 'opacity-50' : '',
|
||||
]"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
50
src/forum-app/app.config.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Forum app configuration.
|
||||
* Only enables base + forum modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
name: 'base',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
nostr: {
|
||||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
},
|
||||
imageUpload: {
|
||||
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
forum: {
|
||||
name: 'forum',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
maxSubmissions: 50,
|
||||
corsProxyUrl: import.meta.env.VITE_CORS_PROXY_URL || '',
|
||||
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
electronApp: false,
|
||||
developmentMode: import.meta.env.DEV
|
||||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
120
src/forum-app/app.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { pluginManager } from '@/core/plugin-manager'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { container } from '@/core/di-container'
|
||||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import forumModule from '@/modules/forum'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Forum app...')
|
||||
|
||||
acceptTokenFromUrl('Forum')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...forumModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/forum'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: import.meta.env.VITE_DEMO_MODE === 'true'
|
||||
? () => import('@/pages/LoginDemo.vue')
|
||||
: () => import('@/pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
...moduleRoutes,
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
installLenientAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||
await changeLocale(defaultLocale)
|
||||
}
|
||||
|
||||
pluginManager.init(app, router)
|
||||
|
||||
const moduleRegistrations = []
|
||||
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.forum?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(forumModule, appConfig.modules.forum)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
markAuthReady(auth)
|
||||
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
if (appConfig.features.developmentMode) {
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Forum app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Forum app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Forum app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
20
src/forum-app/main.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
const intervalMS = 60 * 60 * 1000
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Forum app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
startApp()
|
||||
|
|
@ -5,7 +5,7 @@ import { useStorage } from '@vueuse/core'
|
|||
import en from './locales/en'
|
||||
|
||||
// Define available locales
|
||||
export const AVAILABLE_LOCALES = ['en', 'es', 'fr', 'de', 'zh'] as const
|
||||
export const AVAILABLE_LOCALES = ['en', 'es', 'fr'] as const
|
||||
export type AvailableLocale = typeof AVAILABLE_LOCALES[number]
|
||||
|
||||
// Type for our messages
|
||||
|
|
|
|||
|
|
@ -168,6 +168,15 @@ const messages: LocaleMessages = {
|
|||
notAvailable: 'Income submission is not yet available. This feature is coming soon.',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
auth: {
|
||||
loginPrompt: 'Log in to place your order',
|
||||
logIn: 'Log in',
|
||||
logInToCheckout: 'Log in to checkout',
|
||||
nostrKeyRequired: 'A Nostr identity is required',
|
||||
nostrKeyDescription: 'Configure your Nostr public key in Profile settings to place orders.',
|
||||
},
|
||||
},
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -168,6 +168,15 @@ const messages: LocaleMessages = {
|
|||
notAvailable: 'El registro de ingresos a\u00fan no est\u00e1 disponible. Esta funci\u00f3n llegar\u00e1 pronto.',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
auth: {
|
||||
loginPrompt: 'Inicia sesi\u00f3n para realizar tu pedido',
|
||||
logIn: 'Iniciar sesi\u00f3n',
|
||||
logInToCheckout: 'Iniciar sesi\u00f3n para finalizar compra',
|
||||
nostrKeyRequired: 'Se requiere una identidad Nostr',
|
||||
nostrKeyDescription: 'Configura tu clave p\u00fablica Nostr en los ajustes del perfil para realizar pedidos.',
|
||||
},
|
||||
},
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -168,6 +168,15 @@ const messages: LocaleMessages = {
|
|||
notAvailable: 'La saisie de revenus n\u2019est pas encore disponible. Cette fonctionnalit\u00e9 arrive bient\u00f4t.',
|
||||
},
|
||||
},
|
||||
market: {
|
||||
auth: {
|
||||
loginPrompt: 'Connectez-vous pour passer commande',
|
||||
logIn: 'Se connecter',
|
||||
logInToCheckout: 'Se connecter pour commander',
|
||||
nostrKeyRequired: 'Une identit\u00e9 Nostr est requise',
|
||||
nostrKeyDescription: 'Configurez votre cl\u00e9 publique Nostr dans les param\u00e8tres du profil pour passer commande.',
|
||||
},
|
||||
},
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
year: 'numeric',
|
||||
|
|
|
|||
|
|
@ -144,6 +144,16 @@ export interface LocaleMessages {
|
|||
notAvailable: string
|
||||
}
|
||||
}
|
||||
// Market module
|
||||
market?: {
|
||||
auth: {
|
||||
loginPrompt: string
|
||||
logIn: string
|
||||
logInToCheckout: string
|
||||
nostrKeyRequired: string
|
||||
nostrKeyDescription: string
|
||||
}
|
||||
}
|
||||
// Add date/time formats
|
||||
dateTimeFormats: {
|
||||
short: {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,35 @@ export class LnbitsAPI extends BaseService {
|
|||
return !!this.accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-validate a token and adopt it if valid (issue #36).
|
||||
*
|
||||
* Called by AuthService.checkAuth() when a pending URL-supplied token is
|
||||
* found in localStorage. We can't trust the token until the server has
|
||||
* confirmed it represents a real session, so:
|
||||
* 1. Temporarily set the candidate token on the API client
|
||||
* 2. Try getCurrentUser() with it
|
||||
* 3. On success → persist to AUTH_TOKEN_KEY, return the user
|
||||
* 4. On failure → restore the previous token (if any), return null
|
||||
*
|
||||
* The pending token is the caller's responsibility to remove from
|
||||
* localStorage afterwards.
|
||||
*/
|
||||
async tryAdoptToken(candidateToken: string): Promise<User | null> {
|
||||
const previousToken = this.accessToken
|
||||
this.accessToken = candidateToken
|
||||
try {
|
||||
const user = await this.getCurrentUser()
|
||||
// Server confirmed — persist for future page loads
|
||||
setAuthToken(candidateToken)
|
||||
return user
|
||||
} catch (err) {
|
||||
console.warn('[LnbitsAPI] Pending URL token rejected by server:', err)
|
||||
this.accessToken = previousToken
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ export const LNBITS_CONFIG = {
|
|||
// Auth token storage key
|
||||
AUTH_TOKEN_KEY: 'lnbits_access_token',
|
||||
|
||||
// Transient key for tokens received via ?token=… URL params. They live here
|
||||
// until validateAndAdoptPendingToken() server-checks them; only validated
|
||||
// tokens get promoted to AUTH_TOKEN_KEY. See issue #36.
|
||||
PENDING_AUTH_TOKEN_KEY: 'lnbits_pending_token',
|
||||
|
||||
// User storage key
|
||||
USER_STORAGE_KEY: 'lnbits_user_data'
|
||||
}
|
||||
|
|
@ -42,4 +47,20 @@ export function setAuthToken(token: string): void {
|
|||
// Helper function to remove auth token from storage
|
||||
export function removeAuthToken(): void {
|
||||
localStorage.removeItem(LNBITS_CONFIG.AUTH_TOKEN_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
// Pending token (URL-supplied, unvalidated) helpers.
|
||||
// Pending tokens land here from acceptTokenFromUrl() and only get promoted
|
||||
// to the real AUTH_TOKEN_KEY after server validation.
|
||||
export function getPendingAuthToken(): string | null {
|
||||
return localStorage.getItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setPendingAuthToken(token: string): void {
|
||||
localStorage.setItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function removePendingAuthToken(): void {
|
||||
localStorage.removeItem(LNBITS_CONFIG.PENDING_AUTH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
|
|
|
|||
42
src/lib/dev-sw-cleanup.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Unregister any service worker that was registered on this origin during
|
||||
* a previous dev session (when VitePWA's devOptions.enabled was true).
|
||||
*
|
||||
* Once devOptions.enabled was turned off, Vite stopped registering SWs in
|
||||
* dev — but the browser keeps the previously-registered SWs alive across
|
||||
* server restarts. They then intercept navigation and serve cached, often
|
||||
* stale, bundles. This call clears them out at app boot.
|
||||
*
|
||||
* Production builds skip this entirely so the legitimate SW from
|
||||
* `registerSW()` survives.
|
||||
*/
|
||||
export async function cleanupStaleDevServiceWorkers(): Promise<void> {
|
||||
if (!import.meta.env.DEV) return
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
|
||||
try {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
if (regs.length === 0) return
|
||||
|
||||
console.warn(
|
||||
`[dev-sw-cleanup] Unregistering ${regs.length} stale service worker(s) from a previous dev session.`
|
||||
)
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
|
||||
// Also clear any cache the dev SW left behind.
|
||||
if ('caches' in window) {
|
||||
const keys = await caches.keys()
|
||||
await Promise.all(keys.map(k => caches.delete(k)))
|
||||
}
|
||||
|
||||
// Reload once so the next request hits the network instead of the
|
||||
// about-to-be-removed SW. Guard with a sessionStorage flag so we don't
|
||||
// loop on browsers that take an extra tick to release the controller.
|
||||
if (!sessionStorage.getItem('dev-sw-cleanup-reloaded')) {
|
||||
sessionStorage.setItem('dev-sw-cleanup-reloaded', '1')
|
||||
window.location.reload()
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[dev-sw-cleanup] failed to unregister:', err)
|
||||
}
|
||||
}
|
||||
79
src/lib/router-helpers.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import type { Router, RouteRecordRaw } from 'vue-router'
|
||||
|
||||
/**
|
||||
* Auth-readiness deferred promise.
|
||||
*
|
||||
* Each app boots in three phases:
|
||||
* 1. createRouter(...) and install guards (this file)
|
||||
* 2. pluginManager.installAll() registers services (incl. LNbits API)
|
||||
* 3. dynamic-import('@/composables/useAuthService') and auth.initialize()
|
||||
*
|
||||
* The auth service depends on services registered in phase 2, so it can only
|
||||
* be loaded after that completes. But Vue Router's docs recommend installing
|
||||
* guards before app.use(router). The deferred promise resolves the order
|
||||
* mismatch: guards register early but await this promise before reading
|
||||
* auth state. Phase 3 calls markAuthReady() once auth is initialized.
|
||||
*/
|
||||
type AuthUserLike = { value: { pubkey?: string } | null }
|
||||
type AuthLike = {
|
||||
isAuthenticated: { value: boolean }
|
||||
// Populated after server-validated getCurrentUser() in auth.checkAuth().
|
||||
// Guards require BOTH isAuthenticated and a user with a pubkey — token
|
||||
// presence alone is not enough (issue #36).
|
||||
currentUser: AuthUserLike
|
||||
}
|
||||
|
||||
let resolveAuth!: (a: AuthLike) => void
|
||||
const authReady: Promise<AuthLike> = new Promise(r => { resolveAuth = r })
|
||||
|
||||
export function markAuthReady(auth: AuthLike): void {
|
||||
resolveAuth(auth)
|
||||
}
|
||||
|
||||
/**
|
||||
* Belt-and-suspenders auth check: token presence in localStorage isn't
|
||||
* sufficient — the server must have confirmed the token represents a real
|
||||
* session, which is signalled by currentUser being populated with a pubkey.
|
||||
*/
|
||||
function isFullyAuthed(auth: AuthLike): boolean {
|
||||
return auth.isAuthenticated.value && !!auth.currentUser?.value?.pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Strict guard — every non-/login route requires auth.
|
||||
* Used by wallet, chat, castle (no public view).
|
||||
*/
|
||||
export function installStrictAuthGuard(router: Router): void {
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = await authReady
|
||||
const authed = isFullyAuthed(auth)
|
||||
if (to.path === '/login') {
|
||||
return authed ? '/' : true
|
||||
}
|
||||
return authed ? true : '/login'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient guard — only routes with meta.requiresAuth === true require auth.
|
||||
* Used by hub and the public standalones (forum, market, tasks, activities).
|
||||
*/
|
||||
export function installLenientAuthGuard(router: Router): void {
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = await authReady
|
||||
const requiresAuth = to.meta.requiresAuth === true
|
||||
const authed = isFullyAuthed(auth)
|
||||
if (requiresAuth && !authed) return '/login'
|
||||
if (to.path === '/login' && authed) return '/'
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-all 404 → redirect home. Add as the LAST entry in any router's
|
||||
* routes array. Vue Router 4 warns if no catch-all is defined.
|
||||
*/
|
||||
export const catchAllRoute: RouteRecordRaw = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: '/',
|
||||
}
|
||||
27
src/lib/url-token.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { setPendingAuthToken } from '@/lib/config/lnbits'
|
||||
|
||||
/**
|
||||
* Cross-subdomain auth relay (issue #36): pull `?token=…` off the URL into
|
||||
* the pending-token slot in localStorage, then strip it from history so it
|
||||
* doesn't bleed into bookmarks or referrers.
|
||||
*
|
||||
* The token is NOT promoted to the real auth-token slot here. AuthService
|
||||
* .checkAuth() server-validates it via lnbitsAPI.tryAdoptToken() and only
|
||||
* persists it if the LNbits backend confirms it represents a real session.
|
||||
*
|
||||
* Call this synchronously at app boot, before createApp(), so the URL is
|
||||
* cleaned before vue-router has a chance to read it. The pending token sits
|
||||
* in localStorage until auth.initialize() picks it up later in the same
|
||||
* page load.
|
||||
*/
|
||||
export function acceptTokenFromUrl(appName: string): void {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const token = params.get('token')
|
||||
if (!token) return
|
||||
setPendingAuthToken(token)
|
||||
params.delete('token')
|
||||
const clean = params.toString()
|
||||
const newUrl = window.location.pathname + (clean ? `?${clean}` : '') + window.location.hash
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
console.log(`[${appName}] URL token captured for server validation`)
|
||||
}
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
// New modular application entry point
|
||||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
// Clean up any leftover dev-mode service workers from a previous session
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
// Simple periodic service worker updates
|
||||
const intervalMS = 60 * 60 * 1000 // 1 hour
|
||||
registerSW({
|
||||
|
|
|
|||
114
src/market-app/App.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import {
|
||||
Store, ShoppingCart, Package, LogIn, User as UserIcon,
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
interface Tab {
|
||||
name: string
|
||||
icon: any
|
||||
path?: string
|
||||
authRequired?: boolean
|
||||
badge?: () => number
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const bottomTabs = computed<Tab[]>(() => [
|
||||
{ name: 'Browse', icon: Store, path: '/market' },
|
||||
{ name: 'Cart', icon: ShoppingCart, path: '/cart', badge: () => marketStore.totalCartItems },
|
||||
{ name: 'My Store', icon: Package, path: '/market/dashboard', authRequired: true },
|
||||
isAuthenticated.value
|
||||
? { name: 'Profile', icon: UserIcon, path: '/profile' }
|
||||
: { name: 'Log in', icon: LogIn, path: '/login' },
|
||||
])
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
function isActiveTab(tab: Tab): boolean {
|
||||
if (!tab.path) return false
|
||||
if (tab.path === '/market') {
|
||||
return route.path === '/market' || route.path.startsWith('/market/stall/') || route.path.startsWith('/market/product/')
|
||||
}
|
||||
if (tab.path === '/cart') return route.path === '/cart' || route.path.startsWith('/checkout/')
|
||||
return route.path.startsWith(tab.path)
|
||||
}
|
||||
|
||||
function onTabClick(tab: Tab) {
|
||||
if (tab.authRequired && !isAuthenticated.value) {
|
||||
toast.info(`${tab.name} requires login`, {
|
||||
action: {
|
||||
label: 'Log in',
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (tab.path) router.push(tab.path)
|
||||
}
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<main class="flex-1" :class="{ 'pb-16': !isLoginPage }">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<nav
|
||||
v-if="!isLoginPage"
|
||||
class="fixed bottom-0 left-0 right-0 z-50 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<button
|
||||
v-for="tab in bottomTabs"
|
||||
:key="tab.name"
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full transition-colors"
|
||||
:class="[
|
||||
isActiveTab(tab)
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
tab.authRequired && !isAuthenticated ? 'opacity-50' : '',
|
||||
]"
|
||||
@click="onTabClick(tab)"
|
||||
>
|
||||
<span class="relative inline-flex">
|
||||
<component :is="tab.icon" class="w-5 h-5" />
|
||||
<span
|
||||
v-if="tab.badge && tab.badge() > 0"
|
||||
class="absolute -top-1.5 -right-2 min-w-[16px] h-4 px-1 rounded-full bg-primary text-primary-foreground text-[10px] font-semibold leading-4 text-center"
|
||||
>{{ tab.badge() }}</span>
|
||||
</span>
|
||||
<span class="text-[10px] font-medium">{{ tab.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
53
src/market-app/app.config.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Market app configuration.
|
||||
* Only enables base + market modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
name: 'base',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
nostr: {
|
||||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
},
|
||||
imageUpload: {
|
||||
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
market: {
|
||||
name: 'market',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
defaultCurrency: 'sats',
|
||||
paymentTimeout: 300000,
|
||||
maxOrderHistory: 50,
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
electronApp: false,
|
||||
developmentMode: import.meta.env.DEV
|
||||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
120
src/market-app/app.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { pluginManager } from '@/core/plugin-manager'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { container } from '@/core/di-container'
|
||||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import marketModule from '@/modules/market'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Market app...')
|
||||
|
||||
acceptTokenFromUrl('Market')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...marketModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/market'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: import.meta.env.VITE_DEMO_MODE === 'true'
|
||||
? () => import('@/pages/LoginDemo.vue')
|
||||
: () => import('@/pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
...moduleRoutes,
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
installLenientAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||
await changeLocale(defaultLocale)
|
||||
}
|
||||
|
||||
pluginManager.init(app, router)
|
||||
|
||||
const moduleRegistrations = []
|
||||
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.market?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(marketModule, appConfig.modules.market)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
markAuthReady(auth)
|
||||
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
if (appConfig.features.developmentMode) {
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Market app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Market app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Market app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
20
src/market-app/main.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
const intervalMS = 60 * 60 * 1000
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Market app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
startApp()
|
||||
|
|
@ -5,6 +5,7 @@ import { eventBus } from '@/core/event-bus'
|
|||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { LoginCredentials, RegisterData, User } from '@/lib/api/lnbits'
|
||||
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
|
||||
import { getPendingAuthToken, removePendingAuthToken } from '@/lib/config/lnbits'
|
||||
|
||||
export class AuthService extends BaseService {
|
||||
// Service metadata
|
||||
|
|
@ -49,6 +50,28 @@ export class AuthService extends BaseService {
|
|||
}
|
||||
|
||||
async checkAuth(): Promise<boolean> {
|
||||
// Pending URL-supplied token (from acceptTokenFromUrl in app shells).
|
||||
// Validate server-side before promoting to the real auth-token slot —
|
||||
// see issue #36. Always remove the pending entry whether validation
|
||||
// succeeds or fails so it can't recur on later boots.
|
||||
const pending = getPendingAuthToken()
|
||||
if (pending) {
|
||||
removePendingAuthToken()
|
||||
this.isLoading.value = true
|
||||
try {
|
||||
const adopted = await this.lnbitsAPI.tryAdoptToken(pending)
|
||||
if (adopted) {
|
||||
this.user.value = adopted
|
||||
this.isAuthenticated.value = true
|
||||
this.debug(`Adopted pending URL token for ${adopted.username || adopted.id}`)
|
||||
return true
|
||||
}
|
||||
this.debug('Pending URL token rejected — falling through to existing token')
|
||||
} finally {
|
||||
this.isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.lnbitsAPI.isAuthenticated()) {
|
||||
this.debug('No auth token found - user needs to login')
|
||||
this.isAuthenticated.value = false
|
||||
|
|
@ -59,14 +82,14 @@ export class AuthService extends BaseService {
|
|||
try {
|
||||
this.isLoading.value = true
|
||||
const userData = await this.lnbitsAPI.getCurrentUser()
|
||||
|
||||
|
||||
this.user.value = userData
|
||||
this.isAuthenticated.value = true
|
||||
|
||||
|
||||
this.debug(`User authenticated: ${userData.username || userData.id} (${userData.pubkey?.slice(0, 8)})`)
|
||||
|
||||
|
||||
return true
|
||||
|
||||
|
||||
} catch (error) {
|
||||
this.handleError(error, 'checkAuth')
|
||||
this.isAuthenticated.value = false
|
||||
|
|
|
|||
|
|
@ -147,6 +147,34 @@
|
|||
Use the "Broadcast to Nostr" button to manually re-broadcast your profile.
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger as-child>
|
||||
<Button variant="destructive" class="w-full">
|
||||
<LogOut class="mr-2 h-4 w-4" />
|
||||
Log out
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Log out of {{ user?.username || 'your account' }}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
You'll need to sign in again to access your wallet, post in the
|
||||
forum, place orders, or use any feature that needs your account.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction @click="onLogout" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
Log out
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -168,14 +196,28 @@ import {
|
|||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import ImageUpload from './ImageUpload.vue'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { LogOut } from 'lucide-vue-next'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { ImageUploadService } from '../services/ImageUploadService'
|
||||
import type { NostrMetadataService } from '../nostr/nostr-metadata-service'
|
||||
import { useToast } from '@/core/composables/useToast'
|
||||
|
||||
// Services
|
||||
const { user, updateProfile } = useAuth()
|
||||
const { user, updateProfile, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
const imageService = injectService<ImageUploadService>(SERVICE_TOKENS.IMAGE_UPLOAD_SERVICE)
|
||||
const metadataService = injectService<NostrMetadataService>(SERVICE_TOKENS.NOSTR_METADATA_SERVICE)
|
||||
const toast = useToast()
|
||||
|
|
@ -322,4 +364,17 @@ const broadcastMetadata = async () => {
|
|||
isBroadcasting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Log out + redirect to /login on this app's origin.
|
||||
const onLogout = async () => {
|
||||
try {
|
||||
await logout()
|
||||
toast.success('Logged out')
|
||||
router.push('/login')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to log out'
|
||||
console.error('Error logging out:', error)
|
||||
toast.error(`Logout failed: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { LinkPreviewService } from './services/LinkPreviewService'
|
|||
import SubmissionList from './components/SubmissionList.vue'
|
||||
import SubmitComposer from './components/SubmitComposer.vue'
|
||||
|
||||
export const linksModule: ModulePlugin = {
|
||||
name: 'links',
|
||||
export const forumModule: ModulePlugin = {
|
||||
name: 'forum',
|
||||
version: '1.0.0',
|
||||
dependencies: ['base'],
|
||||
|
||||
|
|
@ -25,6 +25,12 @@ export const linksModule: ModulePlugin = {
|
|||
],
|
||||
|
||||
routes: [
|
||||
{
|
||||
path: '/forum',
|
||||
name: 'forum',
|
||||
component: () => import('./views/ForumListPage.vue'),
|
||||
meta: { title: 'Forum', requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/submission/:id',
|
||||
name: 'submission-detail',
|
||||
|
|
@ -40,16 +46,16 @@ export const linksModule: ModulePlugin = {
|
|||
],
|
||||
|
||||
async install(app: App) {
|
||||
console.log('links module: Starting installation...')
|
||||
console.log('forum module: Starting installation...')
|
||||
|
||||
const submissionService = new SubmissionService()
|
||||
const linkPreviewService = new LinkPreviewService()
|
||||
|
||||
container.provide(SERVICE_TOKENS.SUBMISSION_SERVICE, submissionService)
|
||||
container.provide(SERVICE_TOKENS.LINK_PREVIEW_SERVICE, linkPreviewService)
|
||||
console.log('links module: Services registered in DI container')
|
||||
console.log('forum module: Services registered in DI container')
|
||||
|
||||
console.log('links module: Initializing services...')
|
||||
console.log('forum module: Initializing services...')
|
||||
await Promise.all([
|
||||
submissionService.initialize({
|
||||
waitForDependencies: true,
|
||||
|
|
@ -60,10 +66,10 @@ export const linksModule: ModulePlugin = {
|
|||
maxRetries: 3
|
||||
})
|
||||
])
|
||||
console.log('links module: Services initialized')
|
||||
console.log('forum module: Services initialized')
|
||||
|
||||
app.component('SubmissionList', SubmissionList)
|
||||
console.log('links module: Installation complete')
|
||||
console.log('forum module: Installation complete')
|
||||
},
|
||||
|
||||
components: {
|
||||
|
|
@ -74,4 +80,4 @@ export const linksModule: ModulePlugin = {
|
|||
composables: {}
|
||||
}
|
||||
|
||||
export default linksModule
|
||||
export default forumModule
|
||||
|
|
@ -369,7 +369,7 @@ export class SubmissionService extends BaseService {
|
|||
this._submissions.set(submission.id, submissionWithMeta)
|
||||
|
||||
// Emit event
|
||||
eventBus.emit('submission:new', { submission: submissionWithMeta }, 'links')
|
||||
eventBus.emit('submission:new', { submission: submissionWithMeta }, 'forum')
|
||||
}
|
||||
|
||||
/**
|
||||
32
src/modules/forum/views/ForumListPage.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import SubmissionList from '../components/SubmissionList.vue'
|
||||
import type { SubmissionWithMeta } from '../types/submission'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function onSubmissionClick(submission: SubmissionWithMeta) {
|
||||
router.push({ name: 'submission-detail', params: { id: submission.id } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-screen bg-background">
|
||||
<div class="sticky top-0 z-30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b">
|
||||
<div class="max-w-4xl mx-auto flex items-center justify-between px-4 py-2 sm:px-6">
|
||||
<h1 class="text-lg font-semibold">Forum</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6">
|
||||
<SubmissionList
|
||||
:show-ranks="false"
|
||||
:show-time-range="true"
|
||||
initial-sort="hot"
|
||||
@submission-click="onSubmissionClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
<template>
|
||||
<!-- Cart Summary Button -->
|
||||
<div v-if="marketStore.totalCartItems > 0" class="fixed bottom-4 right-4 z-50">
|
||||
<Button @click="viewCart" class="shadow-lg">
|
||||
<ShoppingCart class="w-5 h-5 mr-2" />
|
||||
Cart ({{ marketStore.totalCartItems }})
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ShoppingCart } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const marketStore = useMarketStore()
|
||||
|
||||
const viewCart = () => {
|
||||
router.push('/cart')
|
||||
}
|
||||
</script>
|
||||
|
|
@ -3,7 +3,7 @@ import { useMarketStore } from '../stores/market'
|
|||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { config } from '@/lib/config'
|
||||
import type { NostrmarketService } from '../services/nostrmarketService'
|
||||
import { nip04 } from 'nostr-tools'
|
||||
import { nip59 } from 'nostr-tools'
|
||||
import { useAsyncOperation } from '@/core/composables/useAsyncOperation'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
|
||||
|
|
@ -14,6 +14,32 @@ const MARKET_EVENT_KINDS = {
|
|||
PRODUCT: 30018
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Resolve a product's parent stall id from the event.
|
||||
*
|
||||
* NIP-15 lists `stall_id` inside the JSON `content`, but some publishers
|
||||
* (older nostrmarket builds, third-party clients) only emit the parent
|
||||
* reference via an `a` tag of the form
|
||||
* ["a", "30017:<merchantPubkey>:<stallId>"]
|
||||
*
|
||||
* Read content first, then fall back to the tag, then a sentinel that won't
|
||||
* match any real stall. Returning the tag form prevents "Unknown Stall"
|
||||
* from sticking when the JSON omits the field.
|
||||
*/
|
||||
function resolveStallId(event: any, productData: any): string {
|
||||
if (productData?.stall_id && typeof productData.stall_id === 'string') {
|
||||
return productData.stall_id
|
||||
}
|
||||
const aTag = event.tags?.find(
|
||||
(t: any) => Array.isArray(t) && t[0] === 'a' && typeof t[1] === 'string' && t[1].startsWith(`${MARKET_EVENT_KINDS.STALL}:`)
|
||||
)
|
||||
if (aTag) {
|
||||
const parts = aTag[1].split(':')
|
||||
if (parts[2]) return parts[2]
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
export function useMarket() {
|
||||
const marketStore = useMarketStore()
|
||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
||||
|
|
@ -28,19 +54,29 @@ export function useMarket() {
|
|||
throw new Error('AuthService not available. Make sure base module is installed.')
|
||||
}
|
||||
|
||||
// Register market DM handler with chat service (if available)
|
||||
// Subscribe to incoming order gift wraps (NIP-17 / kind 1059) addressed to the user.
|
||||
//
|
||||
// The chat service still runs on NIP-04 (kind 4); when it migrates to NIP-17 it
|
||||
// can take over routing of order DMs the way it does today via setMarketMessageHandler.
|
||||
// Until then the market subscribes directly so order flows aren't dependent on chat.
|
||||
const registerMarketMessageHandler = () => {
|
||||
try {
|
||||
// Try to get the chat service (it might not be available if chat module isn't loaded)
|
||||
const chatService = (globalThis as any).chatService
|
||||
if (chatService && chatService.setMarketMessageHandler) {
|
||||
chatService.setMarketMessageHandler(handleOrderDM)
|
||||
console.log('🛒 Registered market message handler with chat service')
|
||||
} else {
|
||||
console.log('🛒 Chat service not available, market will use its own DM subscription')
|
||||
const userPubkey = authService.user.value?.pubkey || auth.currentUser.value?.pubkey
|
||||
if (!userPubkey) {
|
||||
console.log('🛒 No user pubkey available; skipping order gift-wrap subscription')
|
||||
return
|
||||
}
|
||||
|
||||
const unsubscribe = relayHub.subscribe({
|
||||
id: `market-orders-${userPubkey.slice(0, 16)}`,
|
||||
filters: [{ kinds: [1059], '#p': [userPubkey] }],
|
||||
onEvent: (event: any) => handleOrderDM(event)
|
||||
})
|
||||
console.log('🎁 Subscribed to order gift wraps (kind 1059)')
|
||||
// unsubscribe is currently not retained; market lifecycle owns this
|
||||
void unsubscribe
|
||||
} catch (error) {
|
||||
console.log('🛒 Could not register with chat service:', error)
|
||||
console.warn('🛒 Failed to subscribe to order gift wraps:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -64,17 +100,15 @@ export function useMarket() {
|
|||
return 'disconnected'
|
||||
})
|
||||
|
||||
// Load market from naddr
|
||||
// Load market from naddr (or empty for public browse mode)
|
||||
const loadMarket = async (naddr: string) => {
|
||||
return await marketOperation.execute(async () => {
|
||||
// Parse naddr to get market data
|
||||
// Parse naddr (when given) to get market identifier + pubkey.
|
||||
// Empty naddr + unauth user → public browse mode (no pubkey filter).
|
||||
const parts = naddr ? naddr.split(':') : []
|
||||
const marketData = {
|
||||
identifier: naddr.split(':')[2] || 'default',
|
||||
pubkey: naddr.split(':')[1] || authService.user.value?.pubkey || ''
|
||||
}
|
||||
|
||||
if (!marketData.pubkey) {
|
||||
throw new Error('No pubkey available for market')
|
||||
identifier: parts[2] || 'default',
|
||||
pubkey: parts[1] || authService.user.value?.pubkey || ''
|
||||
}
|
||||
|
||||
await loadMarketData(marketData)
|
||||
|
|
@ -87,8 +121,30 @@ export function useMarket() {
|
|||
// Load market data from Nostr events
|
||||
const loadMarketData = async (marketData: any) => {
|
||||
try {
|
||||
console.log('🛒 Loading market data for:', { identifier: marketData.identifier, pubkey: marketData.pubkey?.slice(0, 8) })
|
||||
|
||||
console.log('🛒 Loading market data for:', { identifier: marketData.identifier, pubkey: marketData.pubkey?.slice(0, 8) || '(public browse)' })
|
||||
|
||||
// Public browse mode: no curated naddr and no logged-in user.
|
||||
// Skip the kind 30019 query and use a "Discover" placeholder market;
|
||||
// loadStalls/loadProducts treat browseAll=true as "no authors filter".
|
||||
if (!marketData.pubkey) {
|
||||
const market = {
|
||||
d: marketData.identifier,
|
||||
pubkey: '',
|
||||
relays: config.nostr.relays,
|
||||
selected: true,
|
||||
browseAll: true,
|
||||
opts: {
|
||||
name: 'Discover',
|
||||
description: 'Public stalls and products from your relays',
|
||||
merchants: [],
|
||||
ui: {}
|
||||
}
|
||||
}
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if we can query events (relays are connected)
|
||||
if (!isConnected.value) {
|
||||
console.log('🛒 Not connected to relays, creating default market')
|
||||
|
|
@ -105,12 +161,12 @@ export function useMarket() {
|
|||
ui: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
marketStore.addMarket(market)
|
||||
marketStore.setActiveMarket(market)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// Load market data from Nostr events
|
||||
// Fetch market configuration event
|
||||
const events = await relayHub.queryEvents([
|
||||
|
|
@ -148,7 +204,11 @@ export function useMarket() {
|
|||
relays: config.nostr.relays,
|
||||
selected: true,
|
||||
opts: {
|
||||
name: `${import.meta.env.VITE_APP_NAME} Market`,
|
||||
// Logged-in user has no published market event yet — show their
|
||||
// namespace as "My Market". Avoids leaking VITE_APP_NAME (which
|
||||
// is the brand of whichever standalone app is bundled, e.g.
|
||||
// "Sortir" for activities) into the market label.
|
||||
name: 'My Market',
|
||||
description: 'A communal market to sell your goods',
|
||||
merchants: [],
|
||||
ui: {}
|
||||
|
|
@ -179,7 +239,7 @@ export function useMarket() {
|
|||
}
|
||||
}
|
||||
|
||||
// Load stalls from market merchants
|
||||
// Load stalls from market merchants (or all stalls in public browse mode)
|
||||
const loadStalls = async () => {
|
||||
try {
|
||||
// Get the active market to filter by its merchants
|
||||
|
|
@ -188,19 +248,20 @@ export function useMarket() {
|
|||
return
|
||||
}
|
||||
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
|
||||
if (merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
const browseAll = (activeMarket as any).browseAll === true
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
|
||||
// Fetch stall events from market merchants only
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.STALL],
|
||||
authors: merchants
|
||||
}
|
||||
])
|
||||
if (!browseAll && merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Build filter: in browse-all mode no authors filter; otherwise scope to merchants.
|
||||
const stallFilter: any = { kinds: [MARKET_EVENT_KINDS.STALL] }
|
||||
if (!browseAll && merchants.length > 0) {
|
||||
stallFilter.authors = merchants
|
||||
}
|
||||
|
||||
const events = await relayHub.queryEvents([stallFilter])
|
||||
|
||||
console.log('🛒 Found', events.length, 'stall events for', merchants.length, 'merchants')
|
||||
|
||||
|
|
@ -245,7 +306,7 @@ export function useMarket() {
|
|||
}
|
||||
}
|
||||
|
||||
// Load products from market stalls
|
||||
// Load products from market stalls (or all products in public browse mode)
|
||||
const loadProducts = async () => {
|
||||
try {
|
||||
const activeMarket = marketStore.activeMarket
|
||||
|
|
@ -253,18 +314,19 @@ export function useMarket() {
|
|||
return
|
||||
}
|
||||
|
||||
const browseAll = (activeMarket as any).browseAll === true
|
||||
const merchants = [...(activeMarket.opts.merchants || [])]
|
||||
if (merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch product events from market merchants
|
||||
const events = await relayHub.queryEvents([
|
||||
{
|
||||
kinds: [MARKET_EVENT_KINDS.PRODUCT],
|
||||
authors: merchants
|
||||
}
|
||||
])
|
||||
if (!browseAll && merchants.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const productFilter: any = { kinds: [MARKET_EVENT_KINDS.PRODUCT] }
|
||||
if (!browseAll && merchants.length > 0) {
|
||||
productFilter.authors = merchants
|
||||
}
|
||||
|
||||
const events = await relayHub.queryEvents([productFilter])
|
||||
|
||||
console.log('🛒 Found', events.length, 'product events for', merchants.length, 'merchants')
|
||||
|
||||
|
|
@ -289,7 +351,7 @@ export function useMarket() {
|
|||
|
||||
try {
|
||||
const productData = JSON.parse(latestEvent.content)
|
||||
const stallId = productData.stall_id || 'unknown'
|
||||
const stallId = resolveStallId(latestEvent, productData)
|
||||
|
||||
// Extract categories from Nostr event tags (standard approach)
|
||||
const categories = latestEvent.tags
|
||||
|
|
@ -371,53 +433,46 @@ export function useMarket() {
|
|||
return null
|
||||
}
|
||||
|
||||
// Handle incoming order DMs (payment requests, status updates)
|
||||
// Convert hex string to Uint8Array (browser-compatible)
|
||||
const hexToUint8Array = (hex: string): Uint8Array => {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Handle incoming order gift wraps (kind 1059) — payment requests, status updates.
|
||||
//
|
||||
// The outer event's pubkey is an ephemeral key (NIP-59); the real merchant
|
||||
// pubkey is on the unwrapped rumor. Content is JSON with a `type` field
|
||||
// (1 = payment request, 2 = order status update).
|
||||
const handleOrderDM = async (event: any) => {
|
||||
try {
|
||||
console.log('🔔 Received order-related DM:', event.id, 'from:', event.pubkey.slice(0, 8))
|
||||
|
||||
// Check both injected auth service AND global auth composable
|
||||
const hasAuthService = authService.user.value?.prvkey
|
||||
const hasGlobalAuth = auth.currentUser.value?.prvkey
|
||||
|
||||
const userPrivkey = hasAuthService ? authService.user.value.prvkey : auth.currentUser.value?.prvkey
|
||||
const userPubkey = hasAuthService ? authService.user.value.pubkey : auth.currentUser.value?.pubkey
|
||||
|
||||
if (!userPrivkey || !userPubkey) {
|
||||
console.warn('Cannot decrypt DM: no user private key available', {
|
||||
hasAuthService: !!hasAuthService,
|
||||
hasGlobalAuth: !!hasGlobalAuth,
|
||||
authServicePrivkey: !!authService.user.value?.prvkey,
|
||||
globalAuthPrivkey: !!auth.currentUser.value?.prvkey
|
||||
})
|
||||
console.log('🎁 Received order gift wrap:', event.id, '(kind', event.kind + ')')
|
||||
|
||||
const userPrivkey =
|
||||
authService.user.value?.prvkey ?? auth.currentUser.value?.prvkey
|
||||
|
||||
if (!userPrivkey) {
|
||||
console.warn('Cannot unwrap gift wrap: no user private key available')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🔐 Market DM decryption auth check:', {
|
||||
hasAuthService: !!hasAuthService,
|
||||
hasGlobalAuth: !!hasGlobalAuth,
|
||||
usingAuthService: !!hasAuthService,
|
||||
userPubkey: userPubkey.substring(0, 10) + '...'
|
||||
})
|
||||
|
||||
console.log('🔓 Attempting to decrypt DM with private key available')
|
||||
const prvkeyBytes = hexToUint8Array(userPrivkey)
|
||||
const rumor = nip59.unwrapEvent(event, prvkeyBytes)
|
||||
console.log('🔓 Unwrapped rumor from merchant:', rumor.pubkey.slice(0, 10) + '...')
|
||||
|
||||
// Decrypt the DM content
|
||||
const decryptedContent = await nip04.decrypt(userPrivkey, event.pubkey, event.content)
|
||||
console.log('🔓 Decrypted DM content:', decryptedContent)
|
||||
|
||||
// Parse the decrypted content as JSON
|
||||
const messageData = JSON.parse(decryptedContent)
|
||||
const messageData = JSON.parse(rumor.content)
|
||||
console.log('📨 Parsed message data:', messageData)
|
||||
|
||||
// Handle different types of messages
|
||||
switch (messageData.type) {
|
||||
case 1: // Payment request
|
||||
console.log('💰 Processing payment request for order:', messageData.id)
|
||||
await nostrmarketService.handlePaymentRequest(messageData)
|
||||
console.log('✅ Payment request processed successfully')
|
||||
break
|
||||
case 2: // Order status update
|
||||
case 2: // Order status update
|
||||
console.log('📦 Processing order status update for order:', messageData.id)
|
||||
await nostrmarketService.handleOrderStatusUpdate(messageData)
|
||||
console.log('✅ Order status update processed successfully')
|
||||
|
|
@ -426,7 +481,7 @@ export function useMarket() {
|
|||
console.log('❓ Unknown message type:', messageData.type)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle order DM:', error)
|
||||
console.error('Failed to handle order gift wrap:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +544,7 @@ export function useMarket() {
|
|||
const productId = event.tags.find((tag: any) => tag[0] === 'd')?.[1]
|
||||
if (productId) {
|
||||
const productData = JSON.parse(event.content)
|
||||
const stallId = productData.stall_id || 'unknown'
|
||||
const stallId = resolveStallId(event, productData)
|
||||
|
||||
// Extract categories from Nostr event tags (standard approach)
|
||||
const categories = event.tags
|
||||
|
|
|
|||
99
src/modules/market/composables/useMarketStallSelfHeal.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import type { NostrmarketAPI } from '../services/nostrmarketAPI'
|
||||
|
||||
const SESSION_FLAG = 'market-stall-self-heal-checked'
|
||||
const STALL_EVENT_KIND = 30017
|
||||
|
||||
/**
|
||||
* Detect-and-recover from the LNbits orphan-stall bug
|
||||
* (aiolabs/lnbits#10): _create_default_merchant provisions the merchant
|
||||
* + stall in nostrmarket's internal SQLite but historically never
|
||||
* published the kind-30017 stall event to relays. The upstream fix is
|
||||
* already in c0f3743c on aiolabs/lnbits@demo, but it only helps NEW
|
||||
* signups. Existing accounts whose auto-stall never made it to a relay
|
||||
* stay orphaned until somebody republishes — which manifests in our
|
||||
* webapp as "Unknown Stall" on every product authored by them.
|
||||
*
|
||||
* This composable runs once per browser session (sessionStorage gate)
|
||||
* for any logged-in user who lands on the merchant dashboard:
|
||||
*
|
||||
* 1. Ask the relay for kind-30017 events authored by their pubkey.
|
||||
* 2. Ask LNbits for the merchant's known stalls.
|
||||
* 3. For each stall in (2) whose id isn't represented in (1), PUT the
|
||||
* stall back to LNbits. The PUT path on the LNbits side already
|
||||
* calls sign_and_send_to_nostr, so the kind-30017 event lands on
|
||||
* the relay without any user interaction.
|
||||
*
|
||||
* Silent on success. Logs to console.info on republish; console.warn on
|
||||
* failure. Never toasts — this is supposed to be invisible.
|
||||
*
|
||||
* Tracked in aiolabs/webapp#38.
|
||||
*/
|
||||
export function useMarketStallSelfHeal() {
|
||||
const { user } = useAuth()
|
||||
const relayHub = injectService(SERVICE_TOKENS.RELAY_HUB) as any
|
||||
const nostrmarketAPI = injectService(SERVICE_TOKENS.NOSTRMARKET_API) as NostrmarketAPI
|
||||
|
||||
async function selfHealOnce(): Promise<void> {
|
||||
if (sessionStorage.getItem(SESSION_FLAG)) return
|
||||
|
||||
const currentUser = user.value
|
||||
if (!currentUser?.pubkey) return
|
||||
|
||||
const wallets = (currentUser as any).wallets as Array<{ adminkey?: string; inkey?: string }> | undefined
|
||||
if (!wallets?.length) return
|
||||
|
||||
const adminWallet = wallets.find(w => w.adminkey) || wallets[0]
|
||||
if (!adminWallet?.adminkey || !adminWallet?.inkey) return
|
||||
|
||||
// Mark checked early — even on failure we don't want to retry on every
|
||||
// dashboard mount during the same tab session.
|
||||
sessionStorage.setItem(SESSION_FLAG, '1')
|
||||
|
||||
if (!relayHub || !nostrmarketAPI) {
|
||||
console.warn('[market-self-heal] Required services unavailable, skipping')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const relayEvents: Array<{ tags?: Array<[string, string?]> }> = await relayHub.queryEvents([
|
||||
{ kinds: [STALL_EVENT_KIND], authors: [currentUser.pubkey] },
|
||||
])
|
||||
const publishedStallIds = new Set<string>()
|
||||
for (const ev of relayEvents) {
|
||||
const dTag = ev.tags?.find(t => Array.isArray(t) && t[0] === 'd')
|
||||
const stallId = dTag?.[1]
|
||||
if (stallId) publishedStallIds.add(stallId)
|
||||
}
|
||||
|
||||
const lnbitsStalls = await nostrmarketAPI.getStalls(adminWallet.inkey)
|
||||
const orphans = lnbitsStalls.filter(s => !publishedStallIds.has(s.id))
|
||||
|
||||
if (orphans.length === 0) {
|
||||
console.info(
|
||||
`[market-self-heal] All ${lnbitsStalls.length} stall(s) have a relay event — no recovery needed.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.info(
|
||||
`[market-self-heal] Republishing ${orphans.length} orphan stall(s):`,
|
||||
orphans.map(s => `${s.id} (${s.name})`),
|
||||
)
|
||||
|
||||
for (const stall of orphans) {
|
||||
try {
|
||||
await nostrmarketAPI.updateStall(adminWallet.adminkey, stall)
|
||||
console.info(`[market-self-heal] Republished ${stall.id} (${stall.name})`)
|
||||
} catch (err) {
|
||||
console.warn(`[market-self-heal] Failed to republish ${stall.id}:`, err)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[market-self-heal] Self-heal check failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return { selfHealOnce }
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { finalizeEvent, type EventTemplate, nip04 } from 'nostr-tools'
|
||||
import { type EventTemplate, nip59 } from 'nostr-tools'
|
||||
import { BaseService } from '@/core/base/BaseService'
|
||||
import type { Order } from '@/modules/market/stores/market'
|
||||
|
||||
|
|
@ -159,12 +159,17 @@ export class NostrmarketService extends BaseService {
|
|||
// Stall and product publishing is now handled by LNbits API endpoints
|
||||
|
||||
/**
|
||||
* Publish an order event (kind 4 encrypted DM) to nostrmarket
|
||||
* Publish an order as a NIP-59 gift-wrapped (kind 1059) event to nostrmarket.
|
||||
*
|
||||
* The order JSON is placed in an unsigned kind 14 rumor, sealed (kind 13)
|
||||
* with the customer's key, and wrapped (kind 1059) with an ephemeral key.
|
||||
* Only the merchant can decrypt the wrap; the public event reveals nothing
|
||||
* about the sender.
|
||||
*/
|
||||
async publishOrder(order: Order, merchantPubkey: string): Promise<string> {
|
||||
const { prvkey } = this.getAuth()
|
||||
|
||||
// Convert order to nostrmarket format - exactly matching the specification
|
||||
|
||||
// Convert order to nostrmarket format - matches NIP-15 customer order spec
|
||||
const orderData = {
|
||||
type: 0, // DirectMessageType.CUSTOMER_ORDER
|
||||
id: order.id,
|
||||
|
|
@ -175,72 +180,37 @@ export class NostrmarketService extends BaseService {
|
|||
contact: {
|
||||
name: order.contactInfo?.message || order.contactInfo?.email || 'Unknown',
|
||||
email: order.contactInfo?.email || ''
|
||||
// Remove phone field - not in nostrmarket specification
|
||||
},
|
||||
// Only include address if it's a physical good and address is provided
|
||||
...(order.shippingZone?.requiresPhysicalShipping && order.contactInfo?.address ? {
|
||||
address: order.contactInfo.address
|
||||
} : {}),
|
||||
shipping_id: order.shippingZone?.id || 'online'
|
||||
}
|
||||
|
||||
// Encrypt the message using NIP-04
|
||||
console.log('🔐 NIP-04 encryption debug:', {
|
||||
prvkeyType: typeof prvkey,
|
||||
prvkeyIsString: typeof prvkey === 'string',
|
||||
prvkeyLength: prvkey.length,
|
||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
||||
merchantPubkeyType: typeof merchantPubkey,
|
||||
merchantPubkeyLength: merchantPubkey.length,
|
||||
orderDataString: JSON.stringify(orderData).substring(0, 50) + '...'
|
||||
})
|
||||
|
||||
let encryptedContent: string
|
||||
try {
|
||||
encryptedContent = await nip04.encrypt(prvkey, merchantPubkey, JSON.stringify(orderData))
|
||||
console.log('🔐 NIP-04 encryption successful:', {
|
||||
encryptedContentLength: encryptedContent.length,
|
||||
encryptedContentSample: encryptedContent.substring(0, 50) + '...'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('🔐 NIP-04 encryption failed:', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const eventTemplate: EventTemplate = {
|
||||
kind: 4, // Encrypted DM
|
||||
tags: [['p', merchantPubkey]], // Recipient (merchant)
|
||||
content: encryptedContent, // Use encrypted content
|
||||
const rumorTemplate: Partial<EventTemplate> = {
|
||||
kind: 14,
|
||||
tags: [['p', merchantPubkey]],
|
||||
content: JSON.stringify(orderData),
|
||||
created_at: Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
console.log('🔧 finalizeEvent debug:', {
|
||||
prvkeyType: typeof prvkey,
|
||||
prvkeyIsString: typeof prvkey === 'string',
|
||||
prvkeyLength: prvkey.length,
|
||||
prvkeySample: prvkey.substring(0, 10) + '...',
|
||||
encodedPrvkeyType: typeof new TextEncoder().encode(prvkey),
|
||||
encodedPrvkeyLength: new TextEncoder().encode(prvkey).length,
|
||||
eventTemplate
|
||||
})
|
||||
|
||||
// Convert hex string to Uint8Array properly
|
||||
const prvkeyBytes = this.hexToUint8Array(prvkey)
|
||||
console.log('🔧 prvkeyBytes debug:', {
|
||||
prvkeyBytesType: typeof prvkeyBytes,
|
||||
prvkeyBytesLength: prvkeyBytes.length,
|
||||
prvkeyBytesIsUint8Array: prvkeyBytes instanceof Uint8Array
|
||||
const giftWrap = nip59.wrapEvent(rumorTemplate, prvkeyBytes, merchantPubkey)
|
||||
|
||||
console.log('🎁 Order gift-wrapped (NIP-17):', {
|
||||
orderId: order.id,
|
||||
giftWrapId: giftWrap.id,
|
||||
kind: giftWrap.kind,
|
||||
merchantPubkey: merchantPubkey.substring(0, 10) + '...'
|
||||
})
|
||||
|
||||
const event = finalizeEvent(eventTemplate, prvkeyBytes)
|
||||
const result = await this.relayHub.publishEvent(event)
|
||||
|
||||
const result = await this.relayHub.publishEvent(giftWrap)
|
||||
|
||||
console.log('Order published to nostrmarket:', {
|
||||
orderId: order.id,
|
||||
eventId: result,
|
||||
eventId: giftWrap.id,
|
||||
merchantPubkey,
|
||||
content: orderData,
|
||||
encryptedContent: encryptedContent.substring(0, 50) + '...'
|
||||
content: orderData
|
||||
})
|
||||
|
||||
return result.success.toString()
|
||||
|
|
|
|||
|
|
@ -239,14 +239,23 @@ export const useMarketStore = defineStore('market', () => {
|
|||
}
|
||||
|
||||
const addProduct = (product: Product) => {
|
||||
const existingIndex = products.value.findIndex(p => p.id === product.id)
|
||||
// Lookup stallName from the current stall set — the value passed in by
|
||||
// the caller can be stale ("Unknown Stall") if the stall event hadn't
|
||||
// arrived yet. The reverse race (stall arrives first) is handled in
|
||||
// addStall below.
|
||||
const matchedStall = stalls.value.find(s => s.id === product.stall_id)
|
||||
const enriched: Product = matchedStall
|
||||
? { ...product, stallName: matchedStall.name }
|
||||
: product
|
||||
|
||||
const existingIndex = products.value.findIndex(p => p.id === enriched.id)
|
||||
if (existingIndex >= 0) {
|
||||
products.value[existingIndex] = product
|
||||
products.value[existingIndex] = enriched
|
||||
} else {
|
||||
products.value.push(product)
|
||||
products.value.push(enriched)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const addStall = (stall: Stall) => {
|
||||
const existingIndex = stalls.value.findIndex(s => s.id === stall.id)
|
||||
if (existingIndex >= 0) {
|
||||
|
|
@ -254,6 +263,14 @@ export const useMarketStore = defineStore('market', () => {
|
|||
} else {
|
||||
stalls.value.push(stall)
|
||||
}
|
||||
// Re-stamp stallName on any products that arrived before this stall did
|
||||
// (or whose stall name has changed). Direct property mutation on items
|
||||
// in a reactive array triggers Vue's deep reactivity.
|
||||
products.value.forEach(p => {
|
||||
if (p.stall_id === stall.id && p.stallName !== stall.name) {
|
||||
p.stallName = stall.name
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addMarket = (market: Market) => {
|
||||
|
|
|
|||
|
|
@ -241,6 +241,7 @@
|
|||
<!-- Place Order Button -->
|
||||
<div class="pt-4 border-t border-border">
|
||||
<Button
|
||||
v-if="auth.isAuthenticated.value"
|
||||
@click="placeOrder"
|
||||
:disabled="isPlacingOrder || !canPlaceOrder"
|
||||
class="w-full"
|
||||
|
|
@ -249,9 +250,21 @@
|
|||
<span v-if="isPlacingOrder" class="animate-spin mr-2">⚡</span>
|
||||
Place Order - {{ formatPrice(orderTotal, checkoutCart.currency) }}
|
||||
</Button>
|
||||
<p v-if="!canPlaceOrder" class="text-xs text-destructive mt-2 text-center">
|
||||
<Button
|
||||
v-else
|
||||
@click="router.push('/login')"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
variant="outline"
|
||||
>
|
||||
{{ t('market.auth.logInToCheckout') }}
|
||||
</Button>
|
||||
<p v-if="auth.isAuthenticated.value && !canPlaceOrder" class="text-xs text-destructive mt-2 text-center">
|
||||
{{ orderValidationMessage }}
|
||||
</p>
|
||||
<p v-else-if="!auth.isAuthenticated.value" class="text-xs text-muted-foreground mt-2 text-center">
|
||||
{{ t('market.auth.loginPrompt') }}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -262,7 +275,9 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
|
|
@ -292,6 +307,8 @@ import {
|
|||
|
||||
const { thumbnail } = useImageOptimizer()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const marketStore = useMarketStore()
|
||||
const authService = injectService(SERVICE_TOKENS.AUTH_SERVICE) as any
|
||||
|
||||
|
|
@ -434,12 +451,24 @@ const placeOrder = async () => {
|
|||
// Try to get pubkey from main auth first, fallback to auth service
|
||||
const userPubkey = auth.currentUser.value?.pubkey || authService.user.value?.pubkey
|
||||
|
||||
// Friendly toast instead of throw — same pattern as Activities favorites prompt.
|
||||
if (!auth.isAuthenticated.value) {
|
||||
throw new Error('You must be logged in to place an order')
|
||||
toast.info(t('market.auth.loginPrompt'), {
|
||||
action: {
|
||||
label: t('market.auth.logIn'),
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
isPlacingOrder.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!userPubkey) {
|
||||
throw new Error('Nostr identity required: Please configure your Nostr public key in your profile settings to place orders.')
|
||||
toast.info(t('market.auth.nostrKeyRequired'), {
|
||||
description: t('market.auth.nostrKeyDescription'),
|
||||
})
|
||||
isPlacingOrder.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Create the order using the market store's order placement functionality
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMarketStore } from '@/modules/market/stores/market'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
|
|
@ -77,6 +77,7 @@ import OrderHistory from '../components/OrderHistory.vue'
|
|||
import MerchantStore from '../components/MerchantStore.vue'
|
||||
import MarketSettings from '../components/MarketSettings.vue'
|
||||
import { auth } from '@/composables/useAuthService'
|
||||
import { useMarketStallSelfHeal } from '../composables/useMarketStallSelfHeal'
|
||||
|
||||
const route = useRoute()
|
||||
const marketStore = useMarketStore()
|
||||
|
|
@ -138,9 +139,25 @@ const tabs = computed(() => [
|
|||
}
|
||||
])
|
||||
|
||||
const router = useRouter()
|
||||
const { selfHealOnce } = useMarketStallSelfHeal()
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
// Defence-in-depth: the router guard should already have redirected an
|
||||
// unauthenticated visitor, but if a regression slips past it (issue #36
|
||||
// root cause), bounce here too. Auth is "real" only when both the token
|
||||
// is present AND the server-validated user object has a pubkey.
|
||||
const fullyAuthed = auth.isAuthenticated.value && !!auth.currentUser?.value?.pubkey
|
||||
if (!fullyAuthed) {
|
||||
console.warn('[MarketDashboard] Mounted without full auth — redirecting to /login')
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
console.log('Market Dashboard mounted')
|
||||
// Self-heal orphan stalls (issue #38) once per browser session.
|
||||
// Fire-and-forget — never blocks the dashboard render.
|
||||
void selfHealOnce()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -67,9 +67,6 @@
|
|||
@view-stall="viewStall"
|
||||
/>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<CartButton />
|
||||
|
||||
</LoadingErrorState>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -86,7 +83,6 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
|||
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||
import ProductGrid from '../components/ProductGrid.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import CartButton from '../components/CartButton.vue'
|
||||
import LoadingErrorState from '../components/LoadingErrorState.vue'
|
||||
import type { Product } from '../types/market'
|
||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
|
|
|
|||
|
|
@ -131,9 +131,6 @@
|
|||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Cart Summary -->
|
||||
<CartButton />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
|
@ -153,7 +150,6 @@ import {
|
|||
import { ArrowLeft, Store, X } from 'lucide-vue-next'
|
||||
import MarketSearchBar from '../components/MarketSearchBar.vue'
|
||||
import ProductGrid from '../components/ProductGrid.vue'
|
||||
import CartButton from '../components/CartButton.vue'
|
||||
import CategoryFilterBar from '../components/CategoryFilterBar.vue'
|
||||
import type { Product, Stall } from '../types/market'
|
||||
import type { FuzzySearchOptions } from '@/composables/useFuzzySearch'
|
||||
|
|
|
|||
|
|
@ -74,9 +74,9 @@ import { Button } from '@/components/ui/button'
|
|||
import { Plus } from 'lucide-vue-next'
|
||||
import * as LucideIcons from 'lucide-vue-next'
|
||||
import PWAInstallPrompt from '@/components/pwa/PWAInstallPrompt.vue'
|
||||
import SubmissionList from '@/modules/links/components/SubmissionList.vue'
|
||||
import SubmissionList from '@/modules/forum/components/SubmissionList.vue'
|
||||
import { useQuickActions } from '@/composables/useQuickActions'
|
||||
import type { SubmissionWithMeta } from '@/modules/links/types/submission'
|
||||
import type { SubmissionWithMeta } from '@/modules/forum/types/submission'
|
||||
import type { QuickAction } from '@/core/types'
|
||||
|
||||
const router = useRouter()
|
||||
|
|
|
|||
239
src/pages/Hub.vue
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { useLocale } from '@/composables/useLocale'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
Castle, ListTodo, Newspaper, MessageCircle, Wallet, CalendarDays,
|
||||
Store, UtensilsCrossed,
|
||||
User as UserIcon, LogIn, Sun, Moon, Monitor, Globe, Coins,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
|
||||
DropdownMenuRadioGroup, DropdownMenuRadioItem,
|
||||
DropdownMenuLabel, DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTrigger,
|
||||
} from '@/components/ui/sheet'
|
||||
import ProfileSettings from '@/modules/base/components/ProfileSettings.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const { theme, setTheme, currentTheme } = useTheme()
|
||||
const { currentLocale, locales, setLocale } = useLocale()
|
||||
|
||||
interface Module {
|
||||
label: string
|
||||
chakra: string
|
||||
icon: any
|
||||
bgClass: string
|
||||
glow: string
|
||||
envKey?: string
|
||||
status?: string
|
||||
/** When true, the tile is ghosted out unless the user is logged in. */
|
||||
authRequired?: boolean
|
||||
/** Unread count for the corner badge. Wire to real data via #32. */
|
||||
unread?: number
|
||||
}
|
||||
|
||||
// Lower (root/red) → upper (crown/violet)
|
||||
const modules: Module[] = [
|
||||
{ label: 'Restaurant', chakra: 'Muladhara', icon: UtensilsCrossed, bgClass: '', glow: 'rgba(255,80,80,0.5)', status: 'coming soon' },
|
||||
{ label: 'Market', chakra: 'Muladhara', icon: Store, bgClass: '', glow: 'rgba(255,80,80,0.5)', envKey: 'VITE_HUB_MARKET_URL', status: 'alpha' },
|
||||
{ label: 'Wallet', chakra: 'Manipura', icon: Wallet, bgClass: '', glow: 'rgba(255,200,0,0.5)', envKey: 'VITE_HUB_WALLET_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Activities', chakra: 'Swadhisthana', icon: CalendarDays, bgClass: '', glow: 'rgba(255,165,0,0.5)', envKey: 'VITE_HUB_ACTIVITIES_URL', status: 'beta' },
|
||||
{ label: 'Chat', chakra: 'Anahata', icon: MessageCircle, bgClass: '', glow: 'rgba(0,200,80,0.5)', envKey: 'VITE_HUB_CHAT_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Forum', chakra: 'Vishuddha', icon: Newspaper, bgClass: '', glow: 'rgba(60,120,255,0.5)', envKey: 'VITE_HUB_FORUM_URL', status: 'alpha' },
|
||||
{ label: 'Tasks', chakra: 'Ajna', icon: ListTodo, bgClass: '', glow: 'rgba(99,80,200,0.5)', envKey: 'VITE_HUB_TASKS_URL', status: 'alpha', authRequired: true },
|
||||
{ label: 'Castle', chakra: 'Sahasrara', icon: Castle, bgClass: '', glow: 'rgba(160,80,220,0.5)', envKey: 'VITE_HUB_CASTLE_URL', status: 'beta', authRequired: true },
|
||||
]
|
||||
// Crown at top, root at bottom
|
||||
const orderedModules = computed(() => [...modules].reverse())
|
||||
|
||||
const token = computed(() => localStorage.getItem('lnbits_access_token') || '')
|
||||
|
||||
function hubLink(m: Module): string | null {
|
||||
if (!m.envKey) return null
|
||||
// Auth-only modules (wallet, chat, castle, tasks) are ghosted when not logged in.
|
||||
if (m.authRequired && !isAuthenticated.value) return null
|
||||
const url = import.meta.env[m.envKey] as string | undefined
|
||||
if (!url) return null
|
||||
if (isAuthenticated.value && token.value) {
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(token.value)}`
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function isAuthGated(m: Module): boolean {
|
||||
return !!(m.authRequired && !isAuthenticated.value)
|
||||
}
|
||||
|
||||
function onTileClick(m: Module, event: Event) {
|
||||
// Ghosted auth-required tiles aren't anchors; intercept and toast.
|
||||
if (isAuthGated(m)) {
|
||||
event.preventDefault()
|
||||
toast.info(`${m.label} requires login`, {
|
||||
action: {
|
||||
label: 'Log in',
|
||||
onClick: () => router.push('/login'),
|
||||
},
|
||||
})
|
||||
}
|
||||
// "Coming soon" tiles (no envKey, no authRequired) silently do nothing.
|
||||
}
|
||||
|
||||
const showProfile = ref(false)
|
||||
|
||||
function notImplemented() {
|
||||
toast.info('Currency picker — coming soon', {
|
||||
description: 'A preferred-currency setting (sats/USD/EUR) is on the roadmap.',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-screen flex flex-col text-foreground overflow-hidden bg-background"
|
||||
style="
|
||||
background-image:
|
||||
linear-gradient(to bottom,
|
||||
rgba(124, 58, 237, 0.10) 0%,
|
||||
rgba(37, 99, 235, 0.06) 28%,
|
||||
rgba(22, 163, 74, 0.04) 50%,
|
||||
rgba(234, 88, 12, 0.06) 75%,
|
||||
rgba(185, 28, 28, 0.10) 100%);
|
||||
"
|
||||
>
|
||||
<!-- Main grid -->
|
||||
<div class="relative w-full max-w-2xl mx-auto px-4 pt-6 pb-2 flex-1 flex flex-col min-h-0">
|
||||
<h1 class="text-2xl font-light text-center text-foreground/90 tracking-wide">aiolabs</h1>
|
||||
<p class="text-[10px] text-center text-muted-foreground/70 mb-3">
|
||||
Powered by
|
||||
<a
|
||||
href="https://lnbits.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="hover:text-foreground transition-colors"
|
||||
>⚡ LNbits</a>
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 flex-1 min-h-0">
|
||||
<component
|
||||
v-for="m in orderedModules"
|
||||
:key="m.label"
|
||||
:is="hubLink(m) ? 'a' : (isAuthGated(m) ? 'button' : 'div')"
|
||||
:href="hubLink(m) || undefined"
|
||||
class="relative flex flex-col items-center justify-center gap-1 rounded-xl border backdrop-blur-sm transition-all p-2 min-h-0"
|
||||
:class="[
|
||||
hubLink(m)
|
||||
? 'bg-card/60 hover:bg-card/40 border-white/10 hover:border-white/25 cursor-pointer'
|
||||
: isAuthGated(m)
|
||||
? 'bg-card/70 hover:bg-card/55 border-white/5 opacity-60 cursor-pointer'
|
||||
: 'bg-card/70 border-white/5 opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
@click="onTileClick(m, $event)"
|
||||
>
|
||||
<component :is="m.icon" class="h-7 w-7 text-foreground/90" :style="{ filter: `drop-shadow(0 0 8px ${m.glow})` }" />
|
||||
<div class="text-center leading-tight">
|
||||
<p class="text-sm font-semibold text-foreground drop-shadow">{{ m.label }}</p>
|
||||
<p v-if="m.status" class="text-[9px] font-light text-muted-foreground">{{ m.status }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Notification badge — wired to data once #32 lands. Hidden when unread is falsy/0. -->
|
||||
<span
|
||||
v-if="m.unread"
|
||||
class="absolute top-1.5 right-1.5 min-w-[18px] h-[18px] px-1 rounded-full bg-red-500 text-white text-[10px] font-semibold flex items-center justify-center shadow ring-1 ring-background/60"
|
||||
:aria-label="`${m.unread} unread`"
|
||||
>
|
||||
{{ m.unread > 99 ? '99+' : m.unread }}
|
||||
</span>
|
||||
</component>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar: profile & user preferences -->
|
||||
<nav
|
||||
class="relative z-10 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
|
||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||
>
|
||||
<div class="flex items-center justify-around h-14 max-w-lg mx-auto">
|
||||
<!-- Profile (when logged in) / Log in (when not) -->
|
||||
<Sheet v-if="isAuthenticated" v-model:open="showProfile">
|
||||
<SheetTrigger as-child>
|
||||
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
|
||||
<UserIcon class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Profile</span>
|
||||
</button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="h-[90vh] overflow-y-auto">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Profile</SheetTitle>
|
||||
<SheetDescription>Your Nostr identity and display name.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div class="mt-4">
|
||||
<ProfileSettings />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<button
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors"
|
||||
@click="router.push('/login')"
|
||||
>
|
||||
<LogIn class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Log in</span>
|
||||
</button>
|
||||
|
||||
<!-- Theme -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
|
||||
<component :is="currentTheme === 'dark' ? Moon : Sun" class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Theme</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" class="w-40">
|
||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup :model-value="theme" @update:model-value="(v: string) => setTheme(v as 'dark' | 'light' | 'system')">
|
||||
<DropdownMenuRadioItem value="light"><Sun class="w-4 h-4 mr-2" />Light</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="dark"><Moon class="w-4 h-4 mr-2" />Dark</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="system"><Monitor class="w-4 h-4 mr-2" />System</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Language -->
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<button class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors">
|
||||
<Globe class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Language</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="center" class="w-44">
|
||||
<DropdownMenuLabel>Language</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup :model-value="currentLocale" @update:model-value="(v: string) => setLocale(v)">
|
||||
<DropdownMenuRadioItem v-for="l in locales" :key="l.code" :value="l.code">
|
||||
<span class="mr-2">{{ l.flag }}</span>{{ l.name }}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<!-- Currency (placeholder) -->
|
||||
<button
|
||||
class="flex flex-col items-center justify-center gap-0.5 flex-1 h-full text-muted-foreground hover:text-foreground transition-colors opacity-50"
|
||||
@click="notImplemented"
|
||||
>
|
||||
<Coins class="w-5 h-5" />
|
||||
<span class="text-[10px] font-medium">Currency</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
47
src/tasks-app/App.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
49
src/tasks-app/app.config.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Tasks app configuration.
|
||||
* Only enables base + tasks modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
name: 'base',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
nostr: {
|
||||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
},
|
||||
imageUpload: {
|
||||
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
tasks: {
|
||||
name: 'tasks',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
maxTasks: 200,
|
||||
adminPubkeys: JSON.parse(import.meta.env.VITE_ADMIN_PUBKEYS || '[]')
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
electronApp: false,
|
||||
developmentMode: import.meta.env.DEV
|
||||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
120
src/tasks-app/app.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { pluginManager } from '@/core/plugin-manager'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { container } from '@/core/di-container'
|
||||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import tasksModule from '@/modules/tasks'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
import { installLenientAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Tasks app...')
|
||||
|
||||
acceptTokenFromUrl('Tasks')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...tasksModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/tasks'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: import.meta.env.VITE_DEMO_MODE === 'true'
|
||||
? () => import('@/pages/LoginDemo.vue')
|
||||
: () => import('@/pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
...moduleRoutes,
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
installLenientAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||
await changeLocale(defaultLocale)
|
||||
}
|
||||
|
||||
pluginManager.init(app, router)
|
||||
|
||||
const moduleRegistrations = []
|
||||
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.tasks?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(tasksModule, appConfig.modules.tasks)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
markAuthReady(auth)
|
||||
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
if (appConfig.features.developmentMode) {
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Tasks app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Tasks app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Tasks app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
20
src/tasks-app/main.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
const intervalMS = 60 * 60 * 1000
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Tasks app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
startApp()
|
||||
48
src/wallet-app/App.vue
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import LoginDialog from '@/components/auth/LoginDialog.vue'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useAuth } from '@/composables/useAuthService'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { LogIn } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
useTheme()
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
const showLoginDialog = ref(false)
|
||||
|
||||
const isLoginPage = computed(() => route.path === '/login')
|
||||
|
||||
async function handleLoginSuccess() {
|
||||
showLoginDialog.value = false
|
||||
toast.success('Welcome!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background font-sans antialiased">
|
||||
<div class="relative flex min-h-screen flex-col"
|
||||
style="padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom)">
|
||||
|
||||
<!-- Top bar with login -->
|
||||
<div v-if="!isLoginPage && !isAuthenticated" class="fixed top-0 right-0 z-50 p-3" style="padding-top: env(safe-area-inset-top)">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="router.push('/login')">
|
||||
<LogIn class="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<main class="flex-1">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Toaster />
|
||||
<LoginDialog v-model:is-open="showLoginDialog" @success="handleLoginSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
59
src/wallet-app/app.config.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { AppConfig } from '@/core/types'
|
||||
|
||||
/**
|
||||
* Standalone Wallet app configuration.
|
||||
* Only enables base + wallet modules.
|
||||
*/
|
||||
export const appConfig: AppConfig = {
|
||||
modules: {
|
||||
base: {
|
||||
name: 'base',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
nostr: {
|
||||
relays: JSON.parse(import.meta.env.VITE_NOSTR_RELAYS || '["wss://relay.damus.io", "wss://nos.lol"]')
|
||||
},
|
||||
auth: {
|
||||
sessionTimeout: 24 * 60 * 60 * 1000,
|
||||
},
|
||||
pwa: {
|
||||
autoPrompt: true
|
||||
},
|
||||
imageUpload: {
|
||||
baseUrl: import.meta.env.VITE_PICTRS_BASE_URL || 'https://img.mydomain.com',
|
||||
maxSizeMB: 10,
|
||||
acceptedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/gif']
|
||||
}
|
||||
}
|
||||
},
|
||||
wallet: {
|
||||
name: 'wallet',
|
||||
enabled: true,
|
||||
lazy: false,
|
||||
config: {
|
||||
defaultReceiveAmount: 1000,
|
||||
maxReceiveAmount: 1000000,
|
||||
apiConfig: {
|
||||
baseUrl: import.meta.env.VITE_LNBITS_BASE_URL || 'http://localhost:5000'
|
||||
},
|
||||
websocket: {
|
||||
enabled: import.meta.env.VITE_WEBSOCKET_ENABLED !== 'false',
|
||||
reconnectDelay: 2000,
|
||||
maxReconnectAttempts: 3,
|
||||
fallbackToPolling: true,
|
||||
pollingInterval: 10000
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
features: {
|
||||
pwa: true,
|
||||
pushNotifications: true,
|
||||
electronApp: false,
|
||||
developmentMode: import.meta.env.DEV
|
||||
}
|
||||
}
|
||||
|
||||
export default appConfig
|
||||
123
src/wallet-app/app.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { pluginManager } from '@/core/plugin-manager'
|
||||
import { eventBus } from '@/core/event-bus'
|
||||
import { container } from '@/core/di-container'
|
||||
|
||||
import appConfig from './app.config'
|
||||
import baseModule from '@/modules/base'
|
||||
import walletModule from '@/modules/wallet'
|
||||
|
||||
import App from './App.vue'
|
||||
|
||||
import '@/assets/index.css'
|
||||
import { i18n, changeLocale, type AvailableLocale } from '@/i18n'
|
||||
import { installStrictAuthGuard, markAuthReady, catchAllRoute } from '@/lib/router-helpers'
|
||||
import { acceptTokenFromUrl } from '@/lib/url-token'
|
||||
|
||||
export async function createAppInstance() {
|
||||
console.log('Starting Wallet app...')
|
||||
|
||||
acceptTokenFromUrl('Wallet')
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
const moduleRoutes = [
|
||||
...baseModule.routes || [],
|
||||
...walletModule.routes || [],
|
||||
].filter(Boolean)
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/wallet'
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: import.meta.env.VITE_DEMO_MODE === 'true'
|
||||
? () => import('@/pages/LoginDemo.vue')
|
||||
: () => import('@/pages/Login.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
...moduleRoutes,
|
||||
catchAllRoute,
|
||||
]
|
||||
})
|
||||
|
||||
// Wallet has no public view — every non-login route requires auth.
|
||||
// Guard is installed before app.use(router); it awaits auth readiness
|
||||
// internally (see router-helpers.ts).
|
||||
installStrictAuthGuard(router)
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
const defaultLocale = import.meta.env.VITE_DEFAULT_LOCALE as AvailableLocale | undefined
|
||||
if (defaultLocale && !localStorage.getItem('user-locale')) {
|
||||
await changeLocale(defaultLocale)
|
||||
}
|
||||
|
||||
pluginManager.init(app, router)
|
||||
|
||||
const moduleRegistrations = []
|
||||
|
||||
if (appConfig.modules.base.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(baseModule, appConfig.modules.base)
|
||||
)
|
||||
}
|
||||
|
||||
if (appConfig.modules.wallet?.enabled) {
|
||||
moduleRegistrations.push(
|
||||
pluginManager.register(walletModule, appConfig.modules.wallet)
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(moduleRegistrations)
|
||||
await pluginManager.installAll()
|
||||
|
||||
// Dynamic import: useAuthService depends on services registered by
|
||||
// pluginManager.installAll() (LNbits API).
|
||||
const { auth } = await import('@/composables/useAuthService')
|
||||
await auth.initialize()
|
||||
markAuthReady(auth)
|
||||
|
||||
app.config.errorHandler = (err, _vm, info) => {
|
||||
console.error('Global error:', err, info)
|
||||
eventBus.emit('app:error', { error: err, info }, 'app')
|
||||
}
|
||||
|
||||
if (appConfig.features.developmentMode) {
|
||||
;(window as any).__pluginManager = pluginManager
|
||||
;(window as any).__eventBus = eventBus
|
||||
;(window as any).__container = container
|
||||
}
|
||||
|
||||
console.log('Wallet app initialized')
|
||||
return { app, router }
|
||||
}
|
||||
|
||||
export async function startApp() {
|
||||
try {
|
||||
const { app } = await createAppInstance()
|
||||
app.mount('#app')
|
||||
console.log('Wallet app started!')
|
||||
eventBus.emit('app:started', {}, 'app')
|
||||
} catch (error) {
|
||||
console.error('Failed to start Wallet app:', error)
|
||||
document.getElementById('app')!.innerHTML = `
|
||||
<div style="padding: 20px; text-align: center; color: red;">
|
||||
<h1>Failed to Start</h1>
|
||||
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
|
||||
<p>Please refresh the page.</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
20
src/wallet-app/main.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { startApp } from './app'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import { cleanupStaleDevServiceWorkers } from '@/lib/dev-sw-cleanup'
|
||||
import 'vue-sonner/style.css'
|
||||
|
||||
cleanupStaleDevServiceWorkers()
|
||||
|
||||
const intervalMS = 60 * 60 * 1000
|
||||
registerSW({
|
||||
onRegistered(r) {
|
||||
r && setInterval(() => {
|
||||
r.update()
|
||||
}, intervalMS)
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.log('Wallet app ready to work offline')
|
||||
}
|
||||
})
|
||||
|
||||
startApp()
|
||||
20
tasks.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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">
|
||||
<title>Tasks — Work Orders</title>
|
||||
<meta name="apple-mobile-web-app-title" content="Tasks">
|
||||
<meta name="description" content="Decentralized task management on Nostr">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/tasks-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -15,13 +15,16 @@ function activitiesHtmlPlugin(): Plugin {
|
|||
name: 'activities-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Rewrite all non-asset requests to activities.html
|
||||
// Rewrite all non-asset requests to activities.html.
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!req.url.includes('.') // skip files with extensions
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/activities.html'
|
||||
}
|
||||
|
|
@ -40,6 +43,12 @@ function activitiesHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-activities',
|
||||
server: {
|
||||
port: 5181,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
activitiesHtmlPlugin(),
|
||||
vue(),
|
||||
|
|
@ -47,7 +56,7 @@ export default defineConfig(({ mode }) => ({
|
|||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
|
|
|
|||
|
|
@ -15,13 +15,16 @@ function castleHtmlPlugin(): Plugin {
|
|||
name: 'castle-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Rewrite all non-asset requests to castle.html
|
||||
// Rewrite all non-asset requests to castle.html.
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!req.url.includes('.') // skip files with extensions
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/castle.html'
|
||||
}
|
||||
|
|
@ -40,6 +43,12 @@ function castleHtmlPlugin(): Plugin {
|
|||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-castle',
|
||||
server: {
|
||||
port: 5180,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
castleHtmlPlugin(),
|
||||
vue(),
|
||||
|
|
@ -47,7 +56,7 @@ export default defineConfig(({ mode }) => ({
|
|||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
|
|
@ -101,10 +110,13 @@ export default defineConfig(({ mode }) => ({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
// CRITICAL: Remap @/app.config to the castle app's config
|
||||
// ExpensesAPI and other modules import from @/app.config directly
|
||||
// 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
|
||||
// to ./src/app.config (the hub config). ExpensesAPI etc. import
|
||||
// from @/app.config and need the per-app config.
|
||||
'@/app.config': fileURLToPath(new URL('./src/accounting-app/app.config.ts', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
|
|
|
|||
125
vite.chat.config.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
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'
|
||||
|
||||
function chatHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'chat-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/chat.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Chat app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/chat/ → app.${domain}/chat/ (shared auth)
|
||||
* (default: /) → chat.${domain} (standalone subdomain)
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-chat',
|
||||
server: {
|
||||
port: 5183,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
chatHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: { enabled: false },
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallback: 'chat.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Chat — Encrypted',
|
||||
short_name: 'Chat',
|
||||
description: 'End-to-end encrypted Nostr chat',
|
||||
theme_color: '#16a34a',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
scope: process.env.VITE_BASE_PATH || '/',
|
||||
id: 'chat-app',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ViteImageOptimizer({
|
||||
jpg: { quality: 80 },
|
||||
png: { quality: 80 },
|
||||
webp: { lossless: true },
|
||||
}),
|
||||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-chat/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// 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)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-chat',
|
||||
rollupOptions: {
|
||||
input: 'chat.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
}))
|
||||
|
|
@ -9,13 +9,21 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
|||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-hub',
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: true
|
||||
// SW disabled in dev — was caching stale bundles across restarts.
|
||||
// Run `npm run preview` to test PWA behaviour against a real build.
|
||||
enabled: false
|
||||
},
|
||||
// strategies: 'injectManifest',
|
||||
srcDir: 'public',
|
||||
|
|
@ -25,7 +33,7 @@ export default defineConfig(({ mode }) => ({
|
|||
'**/*.{js,css,html,ico,png,svg}'
|
||||
],
|
||||
// Don't intercept standalone app paths — they have their own service workers
|
||||
navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//],
|
||||
navigateFallbackDenylist: [/^\/sortir\//, /^\/castle\//, /^\/wallet\//, /^\/chat\//, /^\/market\//, /^\/cart\//, /^\/checkout\//, /^\/tasks\//, /^\/forum\//, /^\/submit\//, /^\/submission\//],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
|
|
|
|||
125
vite.forum.config.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
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'
|
||||
|
||||
function forumHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'forum-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/forum.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Forum app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/forum/ → app.${domain}/forum/ (shared auth)
|
||||
* (default: /) → forum.${domain} (standalone subdomain)
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-forum',
|
||||
server: {
|
||||
port: 5184,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
forumHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: { enabled: false },
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallback: 'forum.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'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',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
scope: process.env.VITE_BASE_PATH || '/',
|
||||
id: 'forum-app',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ViteImageOptimizer({
|
||||
jpg: { quality: 80 },
|
||||
png: { quality: 80 },
|
||||
webp: { lossless: true },
|
||||
}),
|
||||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-forum/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// 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)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-forum',
|
||||
rollupOptions: {
|
||||
input: 'forum.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
}))
|
||||
125
vite.market.config.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
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'
|
||||
|
||||
function marketHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'market-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/market.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Market app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/market/ → app.${domain}/market/ (shared auth)
|
||||
* (default: /) → market.${domain} (standalone subdomain)
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-market',
|
||||
server: {
|
||||
port: 5185,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
marketHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: { enabled: false },
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallback: 'market.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'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',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
scope: process.env.VITE_BASE_PATH || '/',
|
||||
id: 'market-app',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ViteImageOptimizer({
|
||||
jpg: { quality: 80 },
|
||||
png: { quality: 80 },
|
||||
webp: { lossless: true },
|
||||
}),
|
||||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-market/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// 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)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-market',
|
||||
rollupOptions: {
|
||||
input: 'market.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
}))
|
||||
125
vite.tasks.config.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
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'
|
||||
|
||||
function tasksHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'tasks-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/tasks.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Tasks app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/tasks/ → app.${domain}/tasks/ (shared auth)
|
||||
* (default: /) → tasks.${domain} (standalone subdomain)
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-tasks',
|
||||
server: {
|
||||
port: 5186,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
tasksHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: { enabled: false },
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallback: 'tasks.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'icon-maskable-512.png',
|
||||
],
|
||||
manifest: {
|
||||
name: 'Tasks — Work Orders',
|
||||
short_name: 'Tasks',
|
||||
description: 'Decentralized task management on Nostr',
|
||||
theme_color: '#4338ca',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
scope: process.env.VITE_BASE_PATH || '/',
|
||||
id: 'tasks-app',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ViteImageOptimizer({
|
||||
jpg: { quality: 80 },
|
||||
png: { quality: 80 },
|
||||
webp: { lossless: true },
|
||||
}),
|
||||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-tasks/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// 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)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-tasks',
|
||||
rollupOptions: {
|
||||
input: 'tasks.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
}))
|
||||
131
vite.wallet.config.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
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'
|
||||
|
||||
/**
|
||||
* Plugin to rewrite dev server requests to wallet.html
|
||||
* (SPA fallback for the standalone Wallet app entry point)
|
||||
*/
|
||||
function walletHtmlPlugin(): Plugin {
|
||||
return {
|
||||
name: 'wallet-html-rewrite',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
// Strip query before checking for an extension — JWTs (e.g. ?token=...)
|
||||
// contain dots and would otherwise get mistaken for an asset request.
|
||||
const path = req.url ? req.url.split('?')[0] : ''
|
||||
if (
|
||||
req.url &&
|
||||
!req.url.startsWith('/@') &&
|
||||
!req.url.startsWith('/src/') &&
|
||||
!req.url.startsWith('/node_modules/') &&
|
||||
!path.includes('.')
|
||||
) {
|
||||
req.url = '/wallet.html'
|
||||
}
|
||||
next()
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vite config for the standalone Wallet app.
|
||||
*
|
||||
* Set VITE_BASE_PATH to deploy under a path prefix:
|
||||
* VITE_BASE_PATH=/wallet/ → app.${domain}/wallet/ (shared auth)
|
||||
* (default: /) → wallet.${domain} (standalone subdomain)
|
||||
*/
|
||||
export default defineConfig(({ mode }) => ({
|
||||
base: process.env.VITE_BASE_PATH || '/',
|
||||
// Per-app dep cache so concurrent dev servers don't race on .vite/deps
|
||||
cacheDir: 'node_modules/.vite-wallet',
|
||||
server: {
|
||||
port: 5182,
|
||||
strictPort: true,
|
||||
},
|
||||
plugins: [
|
||||
walletHtmlPlugin(),
|
||||
vue(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
|
||||
navigateFallback: 'wallet.html',
|
||||
navigateFallbackAllowlist: [
|
||||
new RegExp(`^${(process.env.VITE_BASE_PATH || '/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`),
|
||||
],
|
||||
},
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
'apple-touch-icon.png',
|
||||
'mask-icon.svg',
|
||||
'icon-192.png',
|
||||
'icon-512.png',
|
||||
'icon-maskable-192.png',
|
||||
'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',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait-primary',
|
||||
start_url: process.env.VITE_BASE_PATH || '/',
|
||||
scope: process.env.VITE_BASE_PATH || '/',
|
||||
id: 'wallet-app',
|
||||
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' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
ViteImageOptimizer({
|
||||
jpg: { quality: 80 },
|
||||
png: { quality: 80 },
|
||||
webp: { lossless: true },
|
||||
}),
|
||||
mode === 'analyze' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'dist-wallet/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
// 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)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist-wallet',
|
||||
rollupOptions: {
|
||||
input: 'wallet.html',
|
||||
output: {
|
||||
manualChunks: {
|
||||
'vue-vendor': ['vue', 'vue-router', 'pinia'],
|
||||
'ui-vendor': ['radix-vue', '@vueuse/core'],
|
||||
'shadcn': ['class-variance-authority', 'clsx', 'tailwind-merge'],
|
||||
},
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
}))
|
||||
20
wallet.html
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<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">
|
||||
<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">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/wallet-app/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||