diff options
| author | polwex <polwex@sortug.com> | 2025-10-06 10:13:39 +0700 |
|---|---|---|
| committer | polwex <polwex@sortug.com> | 2025-10-06 10:13:39 +0700 |
| commit | 8751ba26ebf7b7761b9e237f2bf3453623dd1018 (patch) | |
| tree | dc37f12b3fd9b1a1e7a1b54a51c80697f37a04e8 | |
| parent | 6704650dcfccf609ccc203308df9004e0b511bb6 (diff) | |
added frontend WS connection for demonstration purposes
123 files changed, 14015 insertions, 46 deletions
diff --git a/app/app/nostrill.hoon b/app/app/nostrill.hoon index 2a1fa2f..271f774 100644 --- a/app/app/nostrill.hoon +++ b/app/app/nostrill.hoon @@ -30,8 +30,8 @@ ^- (quip card:agent:gall agent:gall) =/ default (default-state:lib bowl) :_ this(state default) - :~ shim-binding:cards - == + bindings:cards + :: ++ on-save ^- vase @@ -42,7 +42,9 @@ ^- (quip card:agent:gall agent:gall) =/ old-state !<(versioned-state old-state) ?- -.old-state - %0 `this(state old-state) + %0 :_ this(state old-state) + bindings:cards + == :: `this(state (default-state:lib bowl)) :: @@ -60,31 +62,48 @@ == ++ handle-ws-handshake =/ order !<([@ inbound-request:eyre] vase) + ~& >> nostrill-ws-handshake=order + =/ url url.request.order + =/ pat=(unit path) (rush url stap) + ?~ pat ~& "pat-parsing-failed" `this + =/ ok=? ?+ u.pat .n + [%nostrill-ui ~] authenticated.order + [%nostrill ~] .y :: TODO which nostr clients do we filter + [%nostr-shim ~] .y :: TODO deprecate? + == :_ this - :: TODO refuse if...? - (accept-handshake:ws -.order) + ?: ok (accept-handshake:ws -.order) (refuse-handshake:ws -.order) :: we behave like a Server here, mind you. messages from clients, not relays ++ handle-ws-msg - =/ order !<([wid=@ msg=websocket-message:eyre] vase) + =/ order !<([wid=@ =path msg=websocket-message:eyre] vase) :: ~& opcode=op=opcode.msg.order :: 0 for continuation, 1 for text, 2 for binary, 9 for ping 0xa for pong =/ msg message.msg.order ?~ msg `this - =/ jsons=@t q.data.u.msg - ~& >> ws-msg-jsons=jsons - =/ jsonm (de:json:html jsons) - ?~ jsonm `this - =/ client-msg (parse-client-msg:nreq u.jsonm) - ?~ client-msg ~& "wrong nostr ws msg from client" `this - :: TODO de-json thing and handle whatever - =^ cs state ?- -.u.client-msg - %req `state - %event (handle-client-event:mutan -.order event.u.client-msg) - %auth `state - %close `state + =/ wsdata=@t q.data.u.msg + ~& >> ws-msg-data=[path.order wsdata] + |^ + ?+ path.order `this + [%nostrill-ui ~] handle-ui-ws + [%nostrill ~] handle-nostr-client-ws == - [cs this] :: - :: + ++ handle-ui-ws + =/ cs (ui-ws-res:lib -.order wsdata) + [cs this] + ++ handle-nostr-client-ws + =/ jsonm (de:json:html wsdata) + ?~ jsonm `this + =/ client-msg (parse-client-msg:nreq u.jsonm) + ?~ client-msg ~& "wrong nostr ws msg from client" `this + :: TODO de-json thing and handle whatever + =^ cs state ?- -.u.client-msg + %req `state + %event (handle-client-event:mutan -.order event.u.client-msg) + %auth `state + %close `state + == + [cs this] + -- ++ handle-comms =/ pok (cast-poke:coms q.vase) ?: ?=(%dbug -.pok) (debug +.pok) diff --git a/app/lib/nostrill.hoon b/app/lib/nostrill.hoon index 41caff2..933dc7f 100644 --- a/app/lib/nostrill.hoon +++ b/app/lib/nostrill.hoon @@ -1,5 +1,6 @@ /- post=trill-post, nsur=nostr, sur=nostrill, gate=trill-gate, comms=nostrill-comms -/+ trill=trill-post, nostr-keys, sr=sortug, jsonlib=json-nostrill +/+ trill=trill-post, nostr-keys, sr=sortug, jsonlib=json-nostrill, + ws=websockets |% :: ++ default-state |= =bowl:gall ^- state:sur @@ -28,12 +29,28 @@ ~& >> total=total-received $(l t.l) +++ ui-ws-res |= [wid=@ msg=@t] + + =/ resmsg (cat 3 msg (cat 3 msg msg)) + =/ octs (as-octs:mimes:html resmsg) + =/ res-event=websocket-event:eyre [%message 1 `octs] + (give-ws-payload:ws wid res-event) :: ++ cards |_ =bowl:gall ++ shim-binding ^- card:agent:gall [%pass /binding %arvo %e %connect [~ /nostr-shim] dap.bowl] + + ++ relay-binding ^- card:agent:gall + [%pass /binding %arvo %e %connect [~ /nostrill] dap.bowl] + ++ ui-binding ^- card:agent:gall + [%pass /binding %arvo %e %connect [~ /nostrill-ui] dap.bowl] + ++ bindings + :~ shim-binding + relay-binding + ui-binding + == ++ update-ui |= =fact:ui:sur ^- card:agent:gall =/ jon (fact:en:jsonlib fact) [%give %fact ~[/ui] %json !>(jon)] diff --git a/app/lib/websockets.hoon b/app/lib/websockets.hoon index 9faea96..4d1f952 100644 --- a/app/lib/websockets.hoon +++ b/app/lib/websockets.hoon @@ -10,5 +10,8 @@ ++ accept-handshake |= wid=@ =/ response [%accept ~] (give-ws-payload wid response) + ++ refuse-handshake |= wid=@ + =/ response [%reject ~] + (give-ws-payload wid response) -- diff --git a/arvo/eyre.hoon b/arvo/eyre.hoon index 0abd1c3..6fcd05f 100644 --- a/arvo/eyre.hoon +++ b/arvo/eyre.hoon @@ -820,7 +820,7 @@ =^ ?(invalid=@uv [suv=@uv =identity som=(list move)]) state (session-for-request:authentication request) ?@ - - :: the request provided a session cookie that's not (or no longer) + :: the request provided a session coocokie that's not (or no longer) :: valid. to make sure they're aware, tell them 401 :: ::NOTE some code duplication with below, but request handling deserves @@ -944,7 +944,6 @@ ?: &(?=([~ @ ^] cached) ?=(%'GET' method.request)) (handle-cache-req authenticated request u.val.u.cached) :: - ~& >> eyre-request-action=-.action ?- -.action %gen =/ bek=beak [our desk.generator.action da+now] @@ -1043,21 +1042,26 @@ ++ ws-event |= [wid=@ event=websocket-event] =/ conn (~(get by connections.state) duct) + :: ~& ws-conn=conn ?~ conn `state ?. ?=(%app -.action.u.conn) `state + =/ url url.request.inbound-request.u.conn + =/ pat=(unit path) (rush url stap) + ?~ pat ~& error-parsing-path=pat `state =/ app app.action.u.conn - ~& ws-event=[wid app] - =/ identity [%ours ~] - =/ wsid (scot %p wid) + =/ identity identity.u.conn + =/ wsid (scot %ud wid) + :: ~& >> ws-event=[identity app pat wsid] :: TODO damn how + :: ~& eyre-ws-event=-.event ?+ -.event `state %message :_ state :~ %+ deal-as - /run-ws-app-request/(scot %uw (cut 3 [2 4] eny)) + /run-ws-app-request/[wsid] :^ identity our app :+ %poke %websocket-server-message - !>([wid message.event]) + !>([wid u.pat message.event]) == %disconnect =. connections.state (~(del by connections.state) duct) @@ -1070,30 +1074,33 @@ ++ ws-handshake |= [wid=@ secure=? =address:eyre =request:http] ^- [(list move) server-state] - ~& >>> sending-ws-handshake=[wid eyre-id] =/ host=(unit @t) (get-header:http 'host' header-list.request) =/ [=action suburl=@t] (get-action-for-binding host url.request) :: TODO enable other actions - ?> ?=(%app -.action) + ?. ?=(%app -.action) `state :: TODO!! get clear what all the identity thing has to be - =/ =identity [%ours ~] =/ app app.action - =^ ?(invalid=@uv [suv=@uv fi=^identity som=(list move)]) state + =^ ?(invalid=@uv [@uv identity (list move)]) state (session-for-request:authentication request) - =/ connection=outstanding-connection + =/ [session-id=@uv =identity som=(list move)] ?@ - ~& invalid-session=- - [action [.n secure address request] [invalid identity] ~ 0] - [action [.n secure address request] [suv identity] ~ 0] + [invalid [%fake *@p] ~] - + + =/ authenticated ?=(%ours -.identity) + =/ connection=outstanding-connection + [action [authenticated secure address request] [session-id identity] ~ 0] =. connections.state (~(put by connections.state) duct connection) :: eyre-id is assigned way up in this arm =/ wsid (scot %ud wid) :_ state + ~& som=som + %+ weld som :~ %+ deal-as /ws-watch-response/[wsid] [identity our app %watch /websocket-server/[wsid]] @@ -3202,7 +3209,6 @@ ++ handle-ws-response |= [wid=@ event=websocket-event] ^- [(list move) server-state] - ~& eyre-handle-ws-response=event :: TODO remove if not accepted? =. connections.state ?. ?=(%reject -.event) connections.state @@ -3655,7 +3661,6 @@ ^- [(list move) _http-server-gate] :: =/ task=task ((harden task) wrapped-task) - ~& > eyre-task=[-.task duct=duct] :: :: XX handle more error notifications :: @@ -3952,7 +3957,6 @@ =^ moves server-state.ax (set-response:server +.task) [moves http-server-gate] %websocket-event - ~& websocket-event=+.task =^ moves server-state.ax (ws-event:server +.task) [moves http-server-gate] @@ -3965,8 +3969,6 @@ ++ take ~/ %eyre-take |= [=wire =duct dud=(unit goof) =sign] - ~& >>> duct=duct - ~& >> take-wire-eyre=wire ^- [(list move) _http-server-gate] => %= . sign @@ -4001,7 +4003,6 @@ ++ watch-ws-response =/ event-args [[eny duct now rof] server-state.ax] ?> ?=([@ *] t.wire) - ~& >> ws-sign=[`@t`-.sign `@t`+<.sign ((soft @t) +>-.sign)] ?+ sign `http-server-gate [%gall %unto %watch-ack *] ?~ p.p.sign @@ -4017,14 +4018,12 @@ [moves http-server-gate] [%gall %unto %fact *] =/ mark p.cage.p.sign - ~& > eyre-ws-response-fact=mark ?. ?=(%websocket-response mark) =/ handle-gall-error handle-gall-error:(per-server-event event-args) =^ moves server-state.ax (handle-gall-error leaf+"eyre bad mark {(trip mark)}" ~) [moves http-server-gate] - ~& websocket-vase=q.q.cage.p.sign =/ event !<([@ websocket-event] q.cage.p.sign) =/ handle-ws-response handle-ws-response:(per-server-event event-args) =^ moves server-state.ax @@ -4033,7 +4032,6 @@ == ++ run-ws-app-request - ~& run-ws-app-req=sign `http-server-gate @@ -4063,7 +4061,6 @@ =/ event-args [[eny duct now rof] server-state.ax] :: ?> ?=([@ *] t.wire) - ~& >> http-sign=[`@t`-.sign `@t`+<.sign ((soft @t) +>-.sign)] ?: ?=([%gall %unto %watch-ack *] sign) ?~ p.p.sign :: received a positive acknowledgment: take no action @@ -4085,7 +4082,6 @@ :: ?> ?=([%gall %unto %fact *] sign) =/ =mark p.cage.p.sign - ~& eyre-watch-response-fact=mark =/ =vase q.cage.p.sign ?. ?= ?(%http-response-header %http-response-data %http-response-cancel) mark diff --git a/gui/.envrc b/gui/.envrc new file mode 100644 index 0000000..7e9a2d6 --- /dev/null +++ b/gui/.envrc @@ -0,0 +1,10 @@ +export DIRENV_WARN_TIMEOUT=20s + +eval "$(devenv direnvrc)" + +# `use devenv` supports the same options as the `devenv shell` command. +# +# To silence the output, use `--quiet`. +# +# Example usage: use devenv --quiet --impure --option services.postgres.enable:bool true +use devenv diff --git a/gui/.gitignore b/gui/.gitignore new file mode 100644 index 0000000..356ff08 --- /dev/null +++ b/gui/.gitignore @@ -0,0 +1,44 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml diff --git a/gui/README.md b/gui/README.md new file mode 100644 index 0000000..7959ce4 --- /dev/null +++ b/gui/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/gui/bun.lock b/gui/bun.lock new file mode 100644 index 0000000..77fd532 --- /dev/null +++ b/gui/bun.lock @@ -0,0 +1,650 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "front", + "dependencies": { + "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query": "^5.85.9", + "any-ascii": "^0.3.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", + "tailwindcss": "^4.1.14", + "urbit-api": "file:../../../urbit/bun/http-api", + "urbit-ob": "file:../../../urbit/bun/urbit-ob", + "urbit-sigils": "file:../../../urbit/bun/sigil-ts", + "wouter": "^3.7.1", + "zustand": "^5.0.8", + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/compat-data": ["@babel/compat-data@7.28.4", "", {}, "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw=="], + + "@babel/core": ["@babel/core@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.4", "@babel/types": "^7.28.4", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA=="], + + "@babel/generator": ["@babel/generator@7.28.3", "", { "dependencies": { "@babel/parser": "^7.28.3", "@babel/types": "^7.28.2", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + + "@babel/parser": ["@babel/parser@7.28.4", "", { "dependencies": { "@babel/types": "^7.28.4" }, "bin": "./bin/babel-parser.js" }, "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.28.4", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.3", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.4", "@babel/template": "^7.27.2", "@babel/types": "^7.28.4", "debug": "^4.3.1" } }, "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ=="], + + "@babel/types": ["@babel/types@7.28.4", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.10", "", { "os": "aix", "cpu": "ppc64" }, "sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.10", "", { "os": "android", "cpu": "arm" }, "sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.10", "", { "os": "android", "cpu": "arm64" }, "sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.10", "", { "os": "android", "cpu": "x64" }, "sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.10", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.10", "", { "os": "freebsd", "cpu": "x64" }, "sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.10", "", { "os": "linux", "cpu": "arm" }, "sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.10", "", { "os": "linux", "cpu": "ia32" }, "sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.10", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.10", "", { "os": "linux", "cpu": "none" }, "sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.10", "", { "os": "linux", "cpu": "s390x" }, "sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.10", "", { "os": "linux", "cpu": "x64" }, "sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.10", "", { "os": "none", "cpu": "x64" }, "sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.10", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.10", "", { "os": "openbsd", "cpu": "x64" }, "sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.10", "", { "os": "none", "cpu": "arm64" }, "sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.10", "", { "os": "sunos", "cpu": "x64" }, "sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.10", "", { "os": "win32", "cpu": "ia32" }, "sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0" } }, "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog=="], + + "@eslint/core": ["@eslint/core@0.16.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.37.0", "", {}, "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.0", "", { "dependencies": { "@eslint/core": "^0.16.0", "levn": "^0.4.1" } }, "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.38", "", {}, "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.4", "", { "os": "android", "cpu": "arm" }, "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.4", "", { "os": "android", "cpu": "arm64" }, "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.52.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.52.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.52.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.52.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.52.4", "", { "os": "linux", "cpu": "arm" }, "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.52.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.52.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.52.4", "", { "os": "linux", "cpu": "none" }, "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.52.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.52.4", "", { "os": "linux", "cpu": "x64" }, "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.52.4", "", { "os": "none", "cpu": "arm64" }, "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.52.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.52.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.52.4", "", { "os": "win32", "cpu": "x64" }, "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.14", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.0", "lightningcss": "1.30.1", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", "tailwindcss": "4.1.14" } }, "sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.14", "", { "dependencies": { "detect-libc": "^2.0.4", "tar": "^7.5.1" }, "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.14", "@tailwindcss/oxide-darwin-arm64": "4.1.14", "@tailwindcss/oxide-darwin-x64": "4.1.14", "@tailwindcss/oxide-freebsd-x64": "4.1.14", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.14", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.14", "@tailwindcss/oxide-linux-arm64-musl": "4.1.14", "@tailwindcss/oxide-linux-x64-gnu": "4.1.14", "@tailwindcss/oxide-linux-x64-musl": "4.1.14", "@tailwindcss/oxide-wasm32-wasi": "4.1.14", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.14", "@tailwindcss/oxide-win32-x64-msvc": "4.1.14" } }, "sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.14", "", { "os": "android", "cpu": "arm64" }, "sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14", "", { "os": "linux", "cpu": "arm" }, "sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.14", "", { "os": "linux", "cpu": "x64" }, "sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.14", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.0.5", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.14", "", { "os": "win32", "cpu": "x64" }, "sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.14", "", { "dependencies": { "@tailwindcss/node": "4.1.14", "@tailwindcss/oxide": "4.1.14", "tailwindcss": "4.1.14" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.90.2", "", {}, "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.2", "", { "dependencies": { "@tanstack/query-core": "5.90.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/bun": ["@types/bun@1.2.23", "", { "dependencies": { "bun-types": "1.2.23" } }, "sha512-le8ueOY5b6VKYf19xT3McVbXqLqmxzPXHsQT/q9JHgikJ2X22wyTW3g3ohz2ZMnp7dod6aduIiq8A14Xyimm0A=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/invariant": ["@types/invariant@2.2.37", "", {}, "sha512-IwpIMieE55oGWiXkQPSBY1nw1nFs6bsKXTFskNY8sdS17K24vyEBRQZEwlRS7ZmXCWnJcQtbxWzly+cODWGs2A=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@24.6.2", "", { "dependencies": { "undici-types": "~7.13.0" } }, "sha512-d2L25Y4j+W3ZlNAeMKcy7yDsK425ibcAOO2t7aPTz6gNMH0z2GThtwENCDc0d/Pw9wgyRqE5Px1wkV7naz8ang=="], + + "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], + + "@types/react-dom": ["@types/react-dom@19.2.0", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.45.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/type-utils": "8.45.0", "@typescript-eslint/utils": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.45.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HC3y9CVuevvWCl/oyZuI47dOeDF9ztdMEfMH8/DW/Mhwa9cCLnK1oD7JoTVGW/u7kFzNZUKUoyJEqkaJh5y3Wg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.45.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TGf22kon8KW+DeKaUmOibKWktRY8b2NSAZNdtWh798COm1NWx8+xJ6iFBtk3IvLdv6+LGLJLRlyhrhEDZWargQ=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.45.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.45.0", "@typescript-eslint/types": "^8.45.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-3pcVHwMG/iA8afdGLMuTibGR7pDsn9RjDev6CCB+naRsSYs2pns5QbinF4Xqw6YC/Sj3lMrm/Im0eMfaa61WUg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0" } }, "sha512-clmm8XSNj/1dGvJeO6VGH7EUSeA0FMs+5au/u3lrA3KfG8iJ4u8ym9/j2tTEoacAffdW1TVUzXO30W1JTJS7dA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.45.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-aFdr+c37sc+jqNMGhH+ajxPXwjv9UtFZk79k8pLoJ6p4y0snmYpPA52GuWHgt2ZF4gRRW6odsEj41uZLojDt5w=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bpjepLlHceKgyMEPglAeULX1vixJDgaKocp0RVJ5u4wLJIMNuKtUXIczpJCPcn2waII0yuvks/5m5/h3ZQKs0A=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.45.0", "", {}, "sha512-WugXLuOIq67BMgQInIxxnsSyRLFxdkJEJu8r4ngLR56q/4Q5LrbfkFRH27vMTjxEK8Pyz7QfzuZe/G15qQnVRA=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.45.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.45.0", "@typescript-eslint/tsconfig-utils": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/visitor-keys": "8.45.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-GfE1NfVbLam6XQ0LcERKwdTTPlLvHvXXhOeUGC1OXi4eQBoyy1iVsW+uzJ/J9jtCz6/7GCQ9MtrQ0fml/jWCnA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.45.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.45.0", "@typescript-eslint/types": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-bxi1ht+tLYg4+XV2knz/F7RVhU0k6VrSMc9sb8DQ6fyCTrGQLHfo7lDtN0QJjZjKkLA2ThrKuCdHEvLReqtIGg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.45.0", "", { "dependencies": { "@typescript-eslint/types": "8.45.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-qsaFBA3e09MIDAGFUrTk+dzqtfv1XPVz8t8d1f0ybTzrCY7BKiMC5cjrl1O/P7UmHsNyW90EYSkU/ZWpmXelag=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.0.4", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.38", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-ascii": ["any-ascii@0.3.3", "", {}, "sha512-8hm+zPrc1VnlxD5eRgMo9F9k2wEMZhbZVLKwA/sPKIt6ywuz7bI9uV/yb27uvc8fv8q6Wl2piJT51q1saKX0Jw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.8.12", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.26.3", "", { "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", "electron-to-chromium": "^1.5.227", "node-releases": "^2.0.21", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w=="], + + "bun-types": ["bun-types@1.2.23", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-R9f0hKAZXgFU3mlrA0YpE/fiDvwV0FT9rORApt2aQVWSuJDzZOyB5QLc0N/4HF57CS8IXJ6+L5E4W1bW6NS2Aw=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001748", "", {}, "sha512-5P5UgAr0+aBmNiplks08JLw+AW/XG/SurlgZLgB1dDLfAw7EfRGxIwzPHxdSCGY/BTKDqIVyJL87cCN6s0ZR0w=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deep-rename-keys": ["deep-rename-keys@0.2.1", "", { "dependencies": { "kind-of": "^3.0.2", "rename-keys": "^1.1.2" } }, "sha512-RHd9ABw4Fvk+gYDWqwOftG849x0bYOySl/RgX0tLI9i27ZIeSO91mLZJEp7oPHOMFqHvpgu21YptmDt0FYD/0A=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.230", "", {}, "sha512-A6A6Fd3+gMdaed9wX83CvHYJb4UuapPD5X5SLq72VZJzxHSY0/LUweGXRWmQlh2ln7KV7iw7jnwXK7dlPoOnHQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + + "esbuild": ["esbuild@0.25.10", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.10", "@esbuild/android-arm": "0.25.10", "@esbuild/android-arm64": "0.25.10", "@esbuild/android-x64": "0.25.10", "@esbuild/darwin-arm64": "0.25.10", "@esbuild/darwin-x64": "0.25.10", "@esbuild/freebsd-arm64": "0.25.10", "@esbuild/freebsd-x64": "0.25.10", "@esbuild/linux-arm": "0.25.10", "@esbuild/linux-arm64": "0.25.10", "@esbuild/linux-ia32": "0.25.10", "@esbuild/linux-loong64": "0.25.10", "@esbuild/linux-mips64el": "0.25.10", "@esbuild/linux-ppc64": "0.25.10", "@esbuild/linux-riscv64": "0.25.10", "@esbuild/linux-s390x": "0.25.10", "@esbuild/linux-x64": "0.25.10", "@esbuild/netbsd-arm64": "0.25.10", "@esbuild/netbsd-x64": "0.25.10", "@esbuild/openbsd-arm64": "0.25.10", "@esbuild/openbsd-x64": "0.25.10", "@esbuild/openharmony-arm64": "0.25.10", "@esbuild/sunos-x64": "0.25.10", "@esbuild/win32-arm64": "0.25.10", "@esbuild/win32-ia32": "0.25.10", "@esbuild/win32-x64": "0.25.10" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.37.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.4.0", "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.37.0", "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.23", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-G4j+rv0NmbIR45kni5xJOrYvCtyD3/7LjpVH8MPPcudXDcNu8gv+4ATTDXTtbRR8rTCM5HxECvCSsRmxKnWDsA=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@2.0.3", "", {}, "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="], + + "goober": ["goober@2.1.18", "", { "peerDependencies": { "csstype": "^3.0.10" } }, "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "invariant": ["invariant@2.2.4", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA=="], + + "is-buffer": ["is-buffer@1.1.6", "", {}, "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.1", "", { "os": "linux", "cpu": "arm" }, "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.1.0", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.23", "", {}, "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], + + "react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="], + + "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], + + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + + "regexparam": ["regexparam@3.0.0", "", {}, "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q=="], + + "rename-keys": ["rename-keys@1.2.0", "", {}, "sha512-U7XpAktpbSgHTRSNRrjKSrjYkZKuhUukfoBlXWXUExCAqhzh1TU3BDRAfJmarcl5voKS+pbKU9MvyLWKZ4UEEg=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.52.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.4", "@rollup/rollup-android-arm64": "4.52.4", "@rollup/rollup-darwin-arm64": "4.52.4", "@rollup/rollup-darwin-x64": "4.52.4", "@rollup/rollup-freebsd-arm64": "4.52.4", "@rollup/rollup-freebsd-x64": "4.52.4", "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", "@rollup/rollup-linux-arm-musleabihf": "4.52.4", "@rollup/rollup-linux-arm64-gnu": "4.52.4", "@rollup/rollup-linux-arm64-musl": "4.52.4", "@rollup/rollup-linux-loong64-gnu": "4.52.4", "@rollup/rollup-linux-ppc64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-gnu": "4.52.4", "@rollup/rollup-linux-riscv64-musl": "4.52.4", "@rollup/rollup-linux-s390x-gnu": "4.52.4", "@rollup/rollup-linux-x64-gnu": "4.52.4", "@rollup/rollup-linux-x64-musl": "4.52.4", "@rollup/rollup-openharmony-arm64": "4.52.4", "@rollup/rollup-win32-arm64-msvc": "4.52.4", "@rollup/rollup-win32-ia32-msvc": "4.52.4", "@rollup/rollup-win32-x64-gnu": "4.52.4", "@rollup/rollup-win32-x64-msvc": "4.52.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "svgson": ["svgson@5.3.1", "", { "dependencies": { "deep-rename-keys": "^0.2.1", "xml-reader": "2.4.3" } }, "sha512-qdPgvUNWb40gWktBJnbJRelWcPzkLed/ShhnRsjbayXz8OtdPOzbil9jtiZdrYvSDumAz/VNQr6JaNfPx/gvPA=="], + + "tailwindcss": ["tailwindcss@4.1.14", "", {}, "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar": ["tar@7.5.1", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "transformation-matrix": ["transformation-matrix@3.1.0", "", {}, "sha512-oYubRWTi2tYFHAL2J8DLvPIqIYcYZ0fSOi2vmSy042Ho4jBW2ce6VP7QfD44t65WQz6bw5w1Pk22J7lcUpaTKA=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "typescript-eslint": ["typescript-eslint@8.45.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.45.0", "@typescript-eslint/parser": "8.45.0", "@typescript-eslint/typescript-estree": "8.45.0", "@typescript-eslint/utils": "8.45.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzDmZw/Z5beNLUrXfd0HIW6MzIaAV5WNDxmMs9/3ojGOpYavofgNAAD/nC6tGV2PczIi0iw8vot2eAe/sBn7zg=="], + + "undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "urbit-api": ["@urbit/http-api@file:../../../urbit/bun/http-api", { "devDependencies": { "@types/bun": "latest", "typescript": "^5" } }], + + "urbit-ob": ["urbit-ob-ts@file:../../../urbit/bun/urbit-ob", { "devDependencies": { "@types/bun": "latest" }, "peerDependencies": { "typescript": "^5" } }], + + "urbit-sigils": ["sigil-ts@file:../../../urbit/bun/sigil-ts", { "dependencies": { "invariant": "^2.2.4", "react": "^19.1.0", "svgson": "^5.3.1", "transformation-matrix": "^3.0.0" }, "devDependencies": { "@types/bun": "latest", "@types/invariant": "^2.2.37", "@types/react": "^19.1.2" }, "peerDependencies": { "typescript": "^5" } }], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wouter": ["wouter@3.7.1", "", { "dependencies": { "mitt": "^3.0.1", "regexparam": "^3.0.0", "use-sync-external-store": "^1.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-od5LGmndSUzntZkE2R5CHhoiJ7YMuTIbiXsa0Anytc2RATekgv4sfWRAxLEULBrp7ADzinWQw8g470lkT8+fOw=="], + + "xml-lexer": ["xml-lexer@0.2.2", "", { "dependencies": { "eventemitter3": "^2.0.0" } }, "sha512-G0i98epIwiUEiKmMcavmVdhtymW+pCAohMRgybyIME9ygfVu8QheIi+YoQh3ngiThsT0SQzJT4R0sKDEv8Ou0w=="], + + "xml-reader": ["xml-reader@2.4.3", "", { "dependencies": { "eventemitter3": "^2.0.0", "xml-lexer": "^0.2.2" } }, "sha512-xWldrIxjeAMAu6+HSf9t50ot1uL5M+BtOidRCWHXIeewvSeIpscWCsp4Zxjk8kHHhdqFBrfK8U0EJeCcnyQ/gA=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.5.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.6", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-DXj75ewm11LIWUk198QSKUTxjyRjsBwk09MuMk5DGK+GDUtyPhhEHOGP/Xwwj3DjQXXkivoBirmOnKrLfc0+9g=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + } +} diff --git a/gui/devenv.lock b/gui/devenv.lock new file mode 100644 index 0000000..19bac94 --- /dev/null +++ b/gui/devenv.lock @@ -0,0 +1,103 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1758064567, + "owner": "cachix", + "repo": "devenv", + "rev": "bc697443a9653586e5be5150b4458f3096a93f67", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1747046372, + "owner": "edolstra", + "repo": "flake-compat", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1757974173, + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "302af509428169db34f268324162712d10559f74", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1755783167, + "owner": "cachix", + "repo": "devenv-nixpkgs", + "rev": "4a880fb247d24fbca57269af672e8f78935b0328", + "type": "github" + }, + "original": { + "owner": "cachix", + "ref": "rolling", + "repo": "devenv-nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "git-hooks": "git-hooks", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": [ + "git-hooks" + ] + } + } + }, + "root": "root", + "version": 7 +} diff --git a/gui/devenv.nix b/gui/devenv.nix new file mode 100644 index 0000000..e4e3748 --- /dev/null +++ b/gui/devenv.nix @@ -0,0 +1,57 @@ +{ + pkgs, + lib, + config, + inputs, + ... +}: { + # https://devenv.sh/basics/ + env.GREET = "devenv"; + + # https://devenv.sh/packages/ + packages = with pkgs; [ + git + nodePackages.typescript-language-server + nodePackages.prettier + ]; + + # https://devenv.sh/languages/ + # languages.rust.enable = true; + languages.javascript = { + enable = true; + bun.enable = true; + }; + + # https://devenv.sh/processes/ + # processes.cargo-watch.exec = "cargo-watch"; + + # https://devenv.sh/services/ + # services.postgres.enable = true; + + # https://devenv.sh/scripts/ + scripts.hello.exec = '' + echo hello from $GREET + ''; + + enterShell = '' + hello + git --version + ''; + + # https://devenv.sh/tasks/ + # tasks = { + # "myproj:setup".exec = "mytool build"; + # "devenv:enterShell".after = [ "myproj:setup" ]; + # }; + + # https://devenv.sh/tests/ + enterTest = '' + echo "Running tests" + git --version | grep --color=auto "${pkgs.git.version}" + ''; + + # https://devenv.sh/git-hooks/ + # git-hooks.hooks.shellcheck.enable = true; + + # See full reference at https://devenv.sh/reference/options/ +} diff --git a/gui/devenv.yaml b/gui/devenv.yaml new file mode 100644 index 0000000..116a2ad --- /dev/null +++ b/gui/devenv.yaml @@ -0,0 +1,15 @@ +# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json +inputs: + nixpkgs: + url: github:cachix/devenv-nixpkgs/rolling + +# If you're using non-OSS software, you can set allowUnfree to true. +# allowUnfree: true + +# If you're willing to use a package that's vulnerable +# permittedInsecurePackages: +# - "openssl-1.1.1w" + +# If you have more than one devenv you can merge them +#imports: +# - ./backend diff --git a/gui/eslint.config.js b/gui/eslint.config.js new file mode 100644 index 0000000..d94e7de --- /dev/null +++ b/gui/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/gui/index.html b/gui/index.html new file mode 100644 index 0000000..94cc361 --- /dev/null +++ b/gui/index.html @@ -0,0 +1,16 @@ +<!doctype html> +<html lang="en"> + +<head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Nostrill</title> +</head> + +<body> + <div id="root"></div> + <script type="module" src="/src/main.tsx"></script> +</body> + +</html>
\ No newline at end of file diff --git a/gui/package.json b/gui/package.json new file mode 100644 index 0000000..4ddb8b3 --- /dev/null +++ b/gui/package.json @@ -0,0 +1,39 @@ +{ + "name": "front", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query": "^5.85.9", + "any-ascii": "^0.3.3", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", + "tailwindcss": "^4.1.14", + "urbit-api": "file:../../../urbit/bun/http-api", + "urbit-ob": "file:../../../urbit/bun/urbit-ob", + "urbit-sigils": "file:../../../urbit/bun/sigil-ts", + "wouter": "^3.7.1", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } +} diff --git a/gui/public/favicon.svg b/gui/public/favicon.svg new file mode 100644 index 0000000..790198c --- /dev/null +++ b/gui/public/favicon.svg @@ -0,0 +1,22 @@ +<svg +xmlns="http://www.w3.org/2000/svg" +xmlns:xlink="http://www.w3.org/1999/xlink" +aria-hidden="true" +role="img" +class="iconify iconify--fa-solid" +width="40" +height="32" +preserveAspectRatio="xMidYMid meet" +viewBox="0 0 640 512"> + <style> + path { + fill: #000; + } + @media (prefers-color-scheme: dark) { + path { + fill: #fff; + } + } +</style> + +<path d="M544 32h-16.36C513.04 12.68 490.09 0 464 0c-44.18 0-80 35.82-80 80v20.98L12.09 393.57A30.216 30.216 0 0 0 0 417.74c0 22.46 23.64 37.07 43.73 27.03L165.27 384h96.49l44.41 120.1c2.27 6.23 9.15 9.44 15.38 7.17l22.55-8.21c6.23-2.27 9.44-9.15 7.17-15.38L312.94 384H352c1.91 0 3.76-.23 5.66-.29l44.51 120.38c2.27 6.23 9.15 9.44 15.38 7.17l22.55-8.21c6.23-2.27 9.44-9.15 7.17-15.38l-41.24-111.53C485.74 352.8 544 279.26 544 192v-80l96-16c0-35.35-42.98-64-96-64zm-80 72c-13.25 0-24-10.75-24-24c0-13.26 10.75-24 24-24s24 10.74 24 24c0 13.25-10.75 24-24 24z" fill="currentColor"></path></svg> diff --git a/gui/public/fonts/Inter b/gui/public/fonts/Inter new file mode 120000 index 0000000..7a1c26c --- /dev/null +++ b/gui/public/fonts/Inter @@ -0,0 +1 @@ +/home/y/code/fonts/Inter
\ No newline at end of file diff --git a/gui/public/fonts/Source_Code_Pro b/gui/public/fonts/Source_Code_Pro new file mode 120000 index 0000000..ab04caf --- /dev/null +++ b/gui/public/fonts/Source_Code_Pro @@ -0,0 +1 @@ +/home/y/code/fonts/Source_Code_Pro
\ No newline at end of file diff --git a/gui/public/nostril-icon.png b/gui/public/nostril-icon.png Binary files differnew file mode 100644 index 0000000..73be722 --- /dev/null +++ b/gui/public/nostril-icon.png diff --git a/gui/src/App.tsx b/gui/src/App.tsx new file mode 100644 index 0000000..415cb66 --- /dev/null +++ b/gui/src/App.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from "react"; +import useLocalState from "@/state/state"; +import Router from "./Router"; +import "@/styles/styles.css"; +import { ThemeProvider } from "@/styles/ThemeProvider"; +import spinner from "@/assets/crowspinner.gif"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "react-hot-toast"; +const queryClient = new QueryClient(); + +// const isMobile = MOBILE_BROWSER_REGEX.test(navigator.userAgent); + +function App() { + const [loading, setLoading] = useState(true); + console.log("NOSTRILL INIT"); + const { init, modal } = useLocalState((s) => ({ + init: s.init, + modal: s.modal, + })); + useEffect(() => { + init().then((_res: any) => { + setLoading(false); + }); + }, []); + if (loading) + return ( + <div className="global-center"> + <img id="global-spinner" src={spinner} alt="" /> + <h3 style={{ textAlign: "center" }}>Syncing with your Urbit...</h3> + </div> + ); + else + return ( + <ThemeProvider> + <QueryClientProvider client={queryClient}> + {/* {isMobile ? <MobileUI /> : <DesktopUI />} */} + <Router /> + {modal && modal} + <Toaster position="top-center" /> + </QueryClientProvider> + </ThemeProvider> + ); +} + +export default App; diff --git a/gui/src/Router.tsx b/gui/src/Router.tsx new file mode 100644 index 0000000..ee3aa0d --- /dev/null +++ b/gui/src/Router.tsx @@ -0,0 +1,39 @@ +import Sidebar from "@/components/layout/Sidebar"; + +// new +import Feed from "@/pages/Feed"; +import Settings from "@/pages/Settings"; +import Thread from "@/pages/Thread"; +import { Switch, Router, Redirect, Route } from "wouter"; + +export default function r() { + return ( + <Switch> + <Router base="/apps/nostrill"> + <Sidebar /> + <main> + <Route path="/" component={toGlobal} /> + <Route path="/sets" component={Settings} /> + <Route path="/feed/:taip" component={Feed} /> + <Route path="/feed/:host/:id" component={Thread} /> + </main> + </Router> + <Route component={P404} /> + </Switch> + ); +} +function toGlobal() { + return <Redirect to="/feed/nostr" />; +} + +export function P404() { + return <h1 className="x-center">404</h1>; +} +export function ErrorPage({ msg }: { msg: string }) { + return ( + <div> + <P404 /> + <h3>{msg}</h3> + </div> + ); +} diff --git a/gui/src/assets/crowspinner.gif b/gui/src/assets/crowspinner.gif Binary files differnew file mode 100644 index 0000000..d0033d3 --- /dev/null +++ b/gui/src/assets/crowspinner.gif diff --git a/gui/src/assets/icons/bell.svg b/gui/src/assets/icons/bell.svg new file mode 100644 index 0000000..98e88cd --- /dev/null +++ b/gui/src/assets/icons/bell.svg @@ -0,0 +1,3 @@ +<svg width="18" height="21" viewBox="0 0 18 21" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M5.96822 18.0949C6.16344 18.7472 6.56388 19.3192 7.11007 19.7259C7.65625 20.1326 8.31904 20.3522 9 20.3522C9.68095 20.3522 10.3437 20.1326 10.8899 19.7259C11.4361 19.3192 11.8365 18.7472 12.0318 18.0949H5.96822ZM0.867065 17.1912H17.1329V14.4802L15.3256 11.7693V7.25097C15.3256 6.42027 15.162 5.59772 14.8441 4.83026C14.5262 4.0628 14.0603 3.36547 13.4729 2.77808C12.8855 2.1907 12.1882 1.72475 11.4207 1.40686C10.6532 1.08897 9.83069 0.925354 9 0.925354C8.1693 0.925354 7.34675 1.08897 6.57929 1.40686C5.81183 1.72475 5.1145 2.1907 4.52711 2.77808C3.93972 3.36547 3.47378 4.0628 3.15589 4.83026C2.838 5.59772 2.67438 6.42027 2.67438 7.25097V11.7693L0.867065 14.4802V17.1912Z" fill="#111111"/> +</svg> diff --git a/gui/src/assets/icons/comet.svg b/gui/src/assets/icons/comet.svg new file mode 100644 index 0000000..2d5c3f5 --- /dev/null +++ b/gui/src/assets/icons/comet.svg @@ -0,0 +1,23 @@ +<svg version="1.1" id="_x32_" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="512px" height="512px" viewBox="0 0 512 512" style="width: 256px; height: 256px; opacity: 1;" xml:space="preserve"> +<g> + <path class="st0" d="M503.694,7.871c1.344-2.047,1.047-4.781-0.734-6.453c-1.781-1.703-4.5-1.891-6.484-0.438l-98.547,71.344 + c-1.891,1.359-4.484,1.266-6.266-0.25s-2.313-4.047-1.266-6.141l10.844-21.656c1.031-2.047,0.547-4.547-1.156-6.063 + c-1.719-1.531-4.25-1.719-6.172-0.469L83.006,230.137c-3.156,1.813-6.297,3.688-9.328,5.75l-0.25,0.156l0.016,0.016 + c-7.625,5.219-14.922,11.125-21.688,17.891c-59.047,59.031-59.047,154.75-0.016,213.766 + c29.531,29.516,68.219,44.281,106.906,44.281s77.359-14.766,106.891-44.281c6.766-6.766,12.656-14.047,17.875-21.672v0.016 + l0.109-0.172c1.844-2.703,3.516-5.484,5.172-8.281l188.375-302.078c1.203-1.844,1.094-4.25-0.281-5.953 + c-1.375-1.734-3.688-2.375-5.75-1.594l-46.829,17.563c-2.063,0.75-4.375,0.125-5.75-1.594s-1.484-4.125-0.266-5.953L503.694,7.871z + M158.647,464.73c-27.766,0-53.859-10.797-73.484-30.438c-40.5-40.5-40.5-106.406,0-146.922 + c6.813-6.797,14.422-12.469,22.578-17.063c7.406-4.172,15.297-7.391,23.5-9.641c0.766-0.203,1.547-0.375,2.344-0.578 + c3.172-0.797,6.406-1.422,9.672-1.906c1.031-0.156,2.047-0.328,3.078-0.453c4.047-0.484,8.156-0.797,12.313-0.797 + c27.75,0,53.828,10.813,73.453,30.438c2.344,2.328,4.516,4.781,6.578,7.281c0.688,0.813,1.297,1.672,1.938,2.5 + c1.344,1.734,2.641,3.469,3.859,5.25c0.703,1.031,1.359,2.063,2.016,3.109c1.047,1.656,2.047,3.344,3,5.063 + c0.609,1.109,1.219,2.234,1.797,3.359c0.859,1.719,1.656,3.484,2.422,5.234c0.5,1.141,1.031,2.266,1.484,3.422 + c0.813,2.063,1.516,4.172,2.188,6.266c0.656,2.031,1.219,4.063,1.75,6.125c0.391,1.563,0.813,3.109,1.141,4.688 + c0.344,1.703,0.609,3.438,0.875,5.156c0.188,1.219,0.375,2.438,0.516,3.656c0.219,1.875,0.391,3.75,0.5,5.625 + c0.063,1.016,0.109,2.047,0.156,3.063c0.063,2.047,0.109,4.094,0.047,6.141c-0.016,0.656-0.063,1.328-0.094,1.984 + c-0.453,10.406-2.438,20.766-6,30.688c-0.063,0.219-0.141,0.438-0.219,0.641c-0.891,2.422-1.875,4.813-2.938,7.188 + c-0.125,0.266-0.266,0.563-0.391,0.828c-1.125,2.406-2.313,4.781-3.609,7.094c-4.547,8.078-10.141,15.703-17,22.563 + C212.475,453.934,186.397,464.73,158.647,464.73z"></path> +</g> +</svg> diff --git a/gui/src/assets/icons/copy.svg b/gui/src/assets/icons/copy.svg new file mode 100644 index 0000000..714e9f5 --- /dev/null +++ b/gui/src/assets/icons/copy.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<rect x="6.5" y="6.5" width="9" height="13" rx="1.5" stroke="#000000"/>
+<path d="M8.5 6C8.5 5.17157 9.17157 4.5 10 4.5H16C16.8284 4.5 17.5 5.17157 17.5 6V16C17.5 16.8284 16.8284 17.5 16 17.5" stroke="#000000"/>
+</svg>
\ No newline at end of file diff --git a/gui/src/assets/icons/crow.svg b/gui/src/assets/icons/crow.svg new file mode 100644 index 0000000..e967970 --- /dev/null +++ b/gui/src/assets/icons/crow.svg @@ -0,0 +1,29 @@ +<svg +xmlns="http://www.w3.org/2000/svg" +xmlns:xlink="http://www.w3.org/1999/xlink" +aria-hidden="true" +role="img" +class="iconify iconify--fa-solid" +width="40" +height="32" +preserveAspectRatio="xMidYMid meet" +viewBox="0 0 640 512"> + <style> + path { + fill: #000; + } + @media (prefers-color-scheme: dark) { + path { + <!-- fill: #fff; --> + fill: linear-gradient( + 90deg, + rgba(168, 221, 228, 0.2) 0%, + rgba(150, 221, 233, 0.2) 52%, + rgba(0, 209, 255, 0.2) 100% + ); + } + } +</style> + +<path d="M544 32h-16.36C513.04 12.68 490.09 0 464 0c-44.18 0-80 35.82-80 80v20.98L12.09 393.57A30.216 30.216 0 0 0 0 417.74c0 22.46 23.64 37.07 43.73 27.03L165.27 384h96.49l44.41 120.1c2.27 6.23 9.15 9.44 15.38 7.17l22.55-8.21c6.23-2.27 9.44-9.15 7.17-15.38L312.94 384H352c1.91 0 3.76-.23 5.66-.29l44.51 120.38c2.27 6.23 9.15 9.44 15.38 7.17l22.55-8.21c6.23-2.27 9.44-9.15 7.17-15.38l-41.24-111.53C485.74 352.8 544 279.26 544 192v-80l96-16c0-35.35-42.98-64-96-64zm-80 72c-13.25 0-24-10.75-24-24c0-13.26 10.75-24 24-24s24 10.74 24 24c0 13.25-10.75 24-24 24z" fill="currentColor"></path></svg> + diff --git a/gui/src/assets/icons/emoji.svg b/gui/src/assets/icons/emoji.svg new file mode 100644 index 0000000..7a957fd --- /dev/null +++ b/gui/src/assets/icons/emoji.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
+<svg width="800px" height="800px" viewBox="-3 -3 24 24"
+ xmlns="http://www.w3.org/2000/svg">
+ <path fill="#000000" fill-rule="evenodd" d="M255,160 L256,160 C256,162.209139 254.209139,164 252,164 C249.790861,164 248,162.209139 248,160 L249,160 C249,161.656854 250.343146,163 252,163 C253.656854,163 255,161.656854 255,160 Z M252,168 C256.970563,168 261,163.970563 261,159 C261,154.029437 256.970563,150 252,150 C247.029437,150 243,154.029437 243,159 C243,163.970563 247.029437,168 252,168 Z M252,167 C256.418278,167 260,163.418278 260,159 C260,154.581722 256.418278,151 252,151 C247.581722,151 244,154.581722 244,159 C244,163.418278 247.581722,167 252,167 Z M249,158 C249.552285,158 250,157.552285 250,157 C250,156.447715 249.552285,156 249,156 C248.447715,156 248,156.447715 248,157 C248,157.552285 248.447715,158 249,158 Z M255,158 C255.552285,158 256,157.552285 256,157 C256,156.447715 255.552285,156 255,156 C254.447715,156 254,156.447715 254,157 C254,157.552285 254.447715,158 255,158 Z" transform="translate(-243 -150)"/>
+</svg>
\ No newline at end of file diff --git a/gui/src/assets/icons/home.svg b/gui/src/assets/icons/home.svg new file mode 100644 index 0000000..64d7984 --- /dev/null +++ b/gui/src/assets/icons/home.svg @@ -0,0 +1,3 @@ +<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M19.743 10.9698L10.743 0.96979C10.364 0.54779 9.63599 0.54779 9.25699 0.96979L0.256994 10.9698C0.127674 11.1135 0.0427905 11.2916 0.0126187 11.4826C-0.017553 11.6736 0.00828102 11.8692 0.0869934 12.0458C0.246993 12.4068 0.604993 12.6388 0.999994 12.6388H2.99999V19.6388C2.99999 19.904 3.10535 20.1584 3.29289 20.3459C3.48042 20.5334 3.73478 20.6388 3.99999 20.6388H6.99999C7.26521 20.6388 7.51956 20.5334 7.7071 20.3459C7.89464 20.1584 7.99999 19.904 7.99999 19.6388V15.6388H12V19.6388C12 19.904 12.1054 20.1584 12.2929 20.3459C12.4804 20.5334 12.7348 20.6388 13 20.6388H16C16.2652 20.6388 16.5196 20.5334 16.7071 20.3459C16.8946 20.1584 17 19.904 17 19.6388V12.6388H19C19.1937 12.6396 19.3834 12.5841 19.546 12.4789C19.7087 12.3738 19.8372 12.2236 19.916 12.0467C19.9947 11.8698 20.0203 11.6737 19.9896 11.4825C19.9589 11.2913 19.8732 11.1131 19.743 10.9698Z" fill="black"/> +</svg> diff --git a/gui/src/assets/icons/key.png b/gui/src/assets/icons/key.png Binary files differnew file mode 100644 index 0000000..2efe10b --- /dev/null +++ b/gui/src/assets/icons/key.png diff --git a/gui/src/assets/icons/key.svg b/gui/src/assets/icons/key.svg new file mode 100644 index 0000000..c2ac4b9 --- /dev/null +++ b/gui/src/assets/icons/key.svg @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20"> + <title>key</title> + <path d="M15 6a1.54 1.54 0 0 1-1.5-1.5 1.5 1.5 0 0 1 3 0A1.54 1.54 0 0 1 15 6zm-1.5-5A5.55 5.55 0 0 0 8 6.5a6.81 6.81 0 0 0 .7 2.8L1 17v2h4v-2h2v-2h2l3.2-3.2a5.85 5.85 0 0 0 1.3.2A5.55 5.55 0 0 0 19 6.5 5.55 5.55 0 0 0 13.5 1z"/> +</svg> diff --git a/gui/src/assets/icons/logo.png b/gui/src/assets/icons/logo.png Binary files differnew file mode 100644 index 0000000..fdb3f22 --- /dev/null +++ b/gui/src/assets/icons/logo.png diff --git a/gui/src/assets/icons/logo.svg b/gui/src/assets/icons/logo.svg new file mode 100644 index 0000000..7cbac7c --- /dev/null +++ b/gui/src/assets/icons/logo.svg @@ -0,0 +1,8 @@ + +<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> +<rect width="512" height="512" fill="#FBC917"/> +<circle cx="243.919" cy="230.964" r="93.6337" fill="#F2F2F2"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M286.864 217.687C280.478 224.095 275.619 235.177 275.619 241.105C275.619 258.196 254.334 275.749 227.149 300.24C168.195 349.594 174.912 352.009 195.507 343.446C198.439 342.227 204.883 338.748 206.382 338.748C207.881 338.748 160.103 367.905 165.093 370.063C170.091 369.978 228.767 344.936 237.613 338.729C254.826 326.655 265.055 325.162 268.152 331.525C269.767 334.843 267.568 336.52 267.202 341.159C266.627 348.449 268.715 350.068 275.079 350.068C280.407 348.92 285.397 340.62 288.209 336.47C291.669 331.361 299.546 323.724 309.092 315.216C323.222 304.015 326.84 293.677 323.222 266.107C324.834 242.962 328.757 238.648 343.382 231.645C362.164 224.408 369.59 221.114 350.792 217.687C346.228 216.855 329.961 215.088 323.222 214.241C317.199 213.485 316.816 208.727 306.335 208.727C295.449 210.58 292.197 212.337 286.864 217.687Z" fill="#111111"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M284.32 375.939L267.423 353.07C266.391 351.674 266.378 349.771 267.39 348.361L282.518 327.285L289.017 331.95L275.588 350.659L290.755 371.185L284.32 375.939Z" fill="#111111"/> +<path fill-rule="evenodd" clip-rule="evenodd" d="M273.729 374.67L273.073 372.258C271.741 372.619 270.954 373.993 271.316 375.325C271.677 376.652 273.04 377.438 274.367 377.087L274.371 377.086C274.383 377.083 274.41 377.077 274.454 377.067C274.542 377.047 274.698 377.015 274.928 376.973C275.39 376.89 276.151 376.773 277.274 376.652C279.521 376.412 283.207 376.163 288.833 376.163C294.458 376.163 298.379 376.411 300.869 376.655C302.114 376.776 303.001 376.896 303.564 376.983C303.845 377.027 304.045 377.062 304.168 377.085C304.23 377.096 304.272 377.105 304.296 377.11L304.316 377.114M304.314 377.113L304.318 377.114C305.665 377.404 306.993 376.55 307.287 375.203C307.582 373.854 306.727 372.522 305.378 372.228L304.845 374.67C305.378 372.228 305.377 372.227 305.377 372.227L305.372 372.226L305.365 372.225L305.345 372.221L305.284 372.208C305.235 372.198 305.167 372.185 305.08 372.169C304.907 372.137 304.657 372.093 304.328 372.042C303.668 371.94 302.689 371.809 301.355 371.678C298.689 371.418 294.604 371.163 288.833 371.163C283.064 371.163 279.197 371.418 276.741 371.681C275.513 371.812 274.634 371.946 274.044 372.052C273.749 372.105 273.526 372.151 273.367 372.187C273.287 372.204 273.224 372.219 273.175 372.231C273.151 372.237 273.13 372.243 273.113 372.247L273.091 372.253L273.081 372.255L273.077 372.257L273.075 372.257C273.075 372.257 273.073 372.258 273.729 374.67" fill="#111111"/> +</svg> diff --git a/gui/src/assets/icons/messages.svg b/gui/src/assets/icons/messages.svg new file mode 100644 index 0000000..8a0b9c3 --- /dev/null +++ b/gui/src/assets/icons/messages.svg @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
+ <title>
+ message
+ </title>
+ <path d="M0 8v8a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8l-10 4z"/>
+ <path d="M18 2H2a2 2 0 0 0-2 2v2l10 4 10-4V4a2 2 0 0 0-2-2z"/>
+</svg>
diff --git a/gui/src/assets/icons/nostr.svg b/gui/src/assets/icons/nostr.svg new file mode 100644 index 0000000..80760a8 --- /dev/null +++ b/gui/src/assets/icons/nostr.svg @@ -0,0 +1,3 @@ +<svg width="620" height="620" viewBox="0 0 620 620" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M457.468 262.483C469.349 344.787 384.156 362.202 335.143 360.68C331.996 360.582 329.03 362.114 327.171 364.655C322.101 371.586 313.996 380.549 307.739 380.549C300.712 380.549 294.388 394.063 291.504 402.47C291.201 403.353 292.069 404.167 292.957 403.879C370.58 378.675 401.039 370.03 414.161 372.46C424.754 374.421 444.567 409.885 453.149 427.372C431.816 428.598 424.705 409.476 419.801 406.044C415.877 403.299 414.406 415.85 414.161 422.469C409.502 420.018 403.617 416.586 403.126 406.044C402.636 395.503 397.241 398.2 393.073 398.69C388.904 399.18 324.904 419.772 315.341 422.469C305.778 425.166 294.743 428.843 285.671 435.216C270.467 442.571 260.659 437.423 257.962 424.92C255.804 414.918 269.814 384.145 277.088 370.008C268.097 373.358 249.428 380.157 246.682 380.549C244.019 380.93 210.235 407.011 192.169 421.181C191.115 422.008 190.437 423.219 190.194 424.536C187.304 440.241 178.078 444.637 162.084 452.867C149.214 459.49 104.263 528.566 81.1495 565.856C79.5513 568.434 77.2056 570.416 74.6869 572.106C60.0054 581.962 45.053 607.044 38.9885 619.32C30.3571 594.413 41.6041 572.988 48.3065 565.388C45.364 562.839 38.5798 564.326 35.5556 565.388C43.8927 536.706 73.0728 536.706 72.0919 536.706C77.4866 532.048 140.751 441.59 142.222 437.423C143.682 433.289 141.28 413.957 173.077 403.358C173.582 403.189 174.083 402.959 174.53 402.672C199.302 386.75 217.885 361.084 224.123 350.151C191.05 347.877 153.177 329.438 133.375 317.595C129.106 315.042 124.579 312.82 119.636 312.261C92.5231 309.192 65.3757 326.536 54.4368 336.423C49.9249 330.148 55.0089 311.909 58.1149 303.574C47.1295 302.005 30.9783 317.956 24.2759 326.127C17.0176 314.753 23.3768 294.422 27.4636 285.678C12.9471 286.071 3.106 292.706 0 295.975C12.0153 206.497 103.921 232.52 104.95 233.708C100.046 228.609 100.455 221.941 101.272 219.244C156.935 220.715 182.927 211.4 199.356 201.839C328.828 129.522 384.613 173.893 405.211 185.047C425.808 196.201 463.203 200.736 489.073 190.44C519.969 176.467 515.318 140.449 509.057 126.702C502.192 111.626 463.448 90.7882 449.716 64.4356C435.985 38.0831 447.95 6.03652 463.858 1.56985C479.014 -2.68593 489.838 2.26516 498.145 9.7683C503.684 14.7705 519.234 18.3484 525.732 20.6772C532.23 23.0061 543.019 26.0704 542.406 27.909C541.793 29.7476 531.39 29.5022 529.778 29.5022C526.467 29.5022 522.544 29.9925 526.467 31.8311C531.068 33.6968 538.013 36.4085 541.338 38.1936C541.77 38.4256 541.681 39.03 541.207 39.1574C518.642 45.2302 498.494 32.6994 483.555 47.1526C468.352 61.8612 500.72 73.1378 517.395 90.7882C534.069 108.439 551.234 129.521 537.502 177.569C526.871 214.767 482.316 246.393 459.302 258.764C457.979 259.476 457.253 260.996 457.468 262.483Z" fill="black"/>
+</svg>
diff --git a/gui/src/assets/icons/pals.svg b/gui/src/assets/icons/pals.svg new file mode 100644 index 0000000..04b17a3 --- /dev/null +++ b/gui/src/assets/icons/pals.svg @@ -0,0 +1,3 @@ +<svg width="24" height="17" viewBox="0 0 24 17" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M7.2 8.63879C9.52125 8.63879 11.4 6.77961 11.4 4.48254C11.4 2.18547 9.52125 0.326294 7.2 0.326294C4.87875 0.326294 3 2.18547 3 4.48254C3 6.77961 4.87875 8.63879 7.2 8.63879ZM10.08 9.82629H9.76875C8.98875 10.1974 8.1225 10.42 7.2 10.42C6.2775 10.42 5.415 10.1974 4.63125 9.82629H4.32C1.935 9.82629 0 11.7411 0 14.1013V15.17C0 16.1534 0.80625 16.9513 1.8 16.9513H12.6C13.5938 16.9513 14.4 16.1534 14.4 15.17V14.1013C14.4 11.7411 12.465 9.82629 10.08 9.82629ZM18 8.63879C19.9875 8.63879 21.6 7.04309 21.6 5.07629C21.6 3.1095 19.9875 1.51379 18 1.51379C16.0125 1.51379 14.4 3.1095 14.4 5.07629C14.4 7.04309 16.0125 8.63879 18 8.63879ZM19.8 9.82629H19.6575C19.1363 10.0044 18.585 10.1232 18 10.1232C17.415 10.1232 16.8638 10.0044 16.3425 9.82629H16.2C15.435 9.82629 14.73 10.0452 14.1112 10.3978C15.0262 11.3738 15.6 12.6689 15.6 14.1013V15.5263C15.6 15.6079 15.5813 15.6859 15.5775 15.7638H22.2C23.1938 15.7638 24 14.9659 24 13.9825C24 11.6855 22.1213 9.82629 19.8 9.82629Z" fill="black"/> +</svg> diff --git a/gui/src/assets/icons/profile.svg b/gui/src/assets/icons/profile.svg new file mode 100644 index 0000000..f94e63d --- /dev/null +++ b/gui/src/assets/icons/profile.svg @@ -0,0 +1,3 @@ +<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path fill-rule="evenodd" clip-rule="evenodd" d="M5 4.63879C5 3.57793 5.42143 2.56051 6.17157 1.81037C6.92172 1.06022 7.93913 0.638794 9 0.638794C10.0609 0.638794 11.0783 1.06022 11.8284 1.81037C12.5786 2.56051 13 3.57793 13 4.63879C13 5.69966 12.5786 6.71708 11.8284 7.46722C11.0783 8.21737 10.0609 8.63879 9 8.63879C7.93913 8.63879 6.92172 8.21737 6.17157 7.46722C5.42143 6.71708 5 5.69966 5 4.63879ZM5 10.6388C3.67392 10.6388 2.40215 11.1656 1.46447 12.1033C0.526784 13.0409 0 14.3127 0 15.6388C0 16.4344 0.316071 17.1975 0.87868 17.7601C1.44129 18.3227 2.20435 18.6388 3 18.6388H15C15.7956 18.6388 16.5587 18.3227 17.1213 17.7601C17.6839 17.1975 18 16.4344 18 15.6388C18 14.3127 17.4732 13.0409 16.5355 12.1033C15.5979 11.1656 14.3261 10.6388 13 10.6388H5Z" fill="black"/> +</svg> diff --git a/gui/src/assets/icons/quote.svg b/gui/src/assets/icons/quote.svg new file mode 100644 index 0000000..5b847e3 --- /dev/null +++ b/gui/src/assets/icons/quote.svg @@ -0,0 +1,3 @@ +<svg width="14" height="15" viewBox="0 0 14 15" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M4.26338 0.906933C4.3437 0.846697 4.43509 0.80287 4.53234 0.777954C4.62959 0.753038 4.7308 0.747522 4.83019 0.761719C4.92957 0.775917 5.02519 0.809551 5.11157 0.860701C5.19796 0.911851 5.27342 0.979516 5.33366 1.05983C5.3939 1.14015 5.43772 1.23154 5.46264 1.32879C5.48756 1.42604 5.49307 1.52725 5.47887 1.62664C5.46468 1.72602 5.43104 1.82164 5.37989 1.90802C5.32874 1.99441 5.26108 2.06987 5.18076 2.13011C3.98511 3.02685 3.30013 3.89913 2.90795 4.66056C3.44643 4.52194 4.01472 4.55453 4.53382 4.75379C5.05293 4.95305 5.49707 5.30908 5.8045 5.7724C6.11193 6.23571 6.26738 6.7833 6.24925 7.33904C6.23112 7.89478 6.0403 8.43106 5.70332 8.87335C5.36634 9.31565 4.89994 9.64197 4.36895 9.80697C3.83796 9.97196 3.26876 9.96744 2.74046 9.79402C2.21216 9.6206 1.75101 9.2869 1.4211 8.83931C1.0912 8.39172 0.908937 7.85246 0.899644 7.2965C0.802772 6.35768 0.936068 5.40939 1.288 4.53365C1.7444 3.38234 2.63655 2.12705 4.26338 0.906933ZM11.1438 0.906933C11.2241 0.846697 11.3155 0.80287 11.4127 0.777954C11.51 0.753038 11.6112 0.747522 11.7106 0.761719C11.8099 0.775917 11.9056 0.809551 11.9919 0.860701C12.0783 0.911851 12.1538 0.979516 12.214 1.05983C12.2743 1.14015 12.3181 1.23154 12.343 1.32879C12.3679 1.42604 12.3734 1.52725 12.3592 1.62664C12.345 1.72602 12.3114 1.82164 12.2603 1.90802C12.2091 1.99441 12.1415 2.06987 12.0611 2.13011C10.8655 3.02685 10.1805 3.89913 9.78832 4.66056C10.3268 4.52194 10.8951 4.55453 11.4142 4.75379C11.9333 4.95305 12.3774 5.30908 12.6849 5.7724C12.9923 6.23571 13.1478 6.7833 13.1296 7.33904C13.1115 7.89478 12.9207 8.43106 12.5837 8.87335C12.2467 9.31565 11.7803 9.64197 11.2493 9.80697C10.7183 9.97196 10.1491 9.96744 9.62083 9.79402C9.09253 9.6206 8.63138 9.2869 8.30148 8.83931C7.97157 8.39172 7.78931 7.85246 7.78002 7.2965C7.68314 6.35768 7.81644 5.40939 8.16837 4.53365C8.62554 3.38234 9.51693 2.12705 11.1438 0.906933Z" fill="#757678"/> +</svg> diff --git a/gui/src/assets/icons/radio.svg b/gui/src/assets/icons/radio.svg new file mode 100644 index 0000000..5c98c15 --- /dev/null +++ b/gui/src/assets/icons/radio.svg @@ -0,0 +1,3 @@ +<svg width="20" height="22" viewBox="0 0 20 22" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.24 5.78879C0.51 6.06879 0 6.80879 0 7.63879V19.6388C0 20.7388 0.9 21.6388 2 21.6388H18C18.5304 21.6388 19.0391 21.4281 19.4142 21.053C19.7893 20.6779 20 20.1692 20 19.6388V7.63879C20 6.53879 19.1 5.63879 18 5.63879H6.3L13.73 2.63879C14.19 2.44879 14.41 1.92879 14.22 1.46879C14.1757 1.35955 14.1102 1.26015 14.0273 1.17632C13.9444 1.09249 13.8457 1.02589 13.737 0.980346C13.6282 0.934803 13.5115 0.911219 13.3936 0.910952C13.2757 0.910685 13.159 0.93374 13.05 0.97879L1.24 5.78879ZM5 19.6388C3.34 19.6388 2 18.2988 2 16.6388C2 14.9788 3.34 13.6388 5 13.6388C6.66 13.6388 8 14.9788 8 16.6388C8 18.2988 6.66 19.6388 5 19.6388ZM18 11.6388H16V10.6388C16 10.0888 15.55 9.63879 15 9.63879C14.45 9.63879 14 10.0888 14 10.6388V11.6388H2V8.63879C2 8.08879 2.45 7.63879 3 7.63879H17C17.55 7.63879 18 8.08879 18 8.63879V11.6388Z" fill="black"/> +</svg> diff --git a/gui/src/assets/icons/reply.svg b/gui/src/assets/icons/reply.svg new file mode 100644 index 0000000..db86cfd --- /dev/null +++ b/gui/src/assets/icons/reply.svg @@ -0,0 +1,3 @@ +<svg width="19" height="21" viewBox="0 0 19 21" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M9.25452 13.4142L4.66867 18V13.4142H2.83434C1.82545 13.4142 1 12.5887 1 11.5798V3.3253C1 2.31642 1.82545 1.49097 2.83434 1.49097H15.6747C16.6836 1.49097 17.509 2.31642 17.509 3.3253V11.5798C17.509 12.5887 16.6836 13.4142 15.6747 13.4142H9.25452Z" stroke="#757678" stroke-width="2"/> +</svg> diff --git a/gui/src/assets/icons/rt.svg b/gui/src/assets/icons/rt.svg new file mode 100644 index 0000000..43b4a36 --- /dev/null +++ b/gui/src/assets/icons/rt.svg @@ -0,0 +1,3 @@ +<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M1.998 9.98763C1.998 10.4097 2.04387 10.8256 2.13562 11.2354C2.22736 11.6452 2.36193 12.0428 2.5393 12.4281C2.71668 12.8134 2.93076 13.1743 3.18153 13.5107C3.4323 13.8471 3.56262 13.9511 3.89291 14.2263L3.05802 15.0612C2.6788 14.7248 2.5026 14.5597 2.20901 14.1621C1.91543 13.7645 1.66465 13.3394 1.45669 12.8868C1.24873 12.4342 1.09276 11.9663 0.988783 11.4831C0.884803 10.9999 0.829755 10.5014 0.823639 9.98763C0.823639 9.33928 0.906211 8.71541 1.07136 8.11599C1.2365 7.51658 1.47504 6.95693 1.78698 6.43703C2.09892 5.91713 2.46591 5.44005 2.88794 5.00578C3.30998 4.57151 3.784 4.20452 4.31002 3.90482C4.83603 3.60511 5.39874 3.36963 5.99816 3.19837C6.59757 3.02711 7.22145 2.94148 7.86979 2.94148H13.7966L12.3012 1.44601L13.1269 0.620285L16.0353 3.52866L13.1269 6.43703L12.3012 5.61131L13.7966 4.11584H7.86979C7.33154 4.11584 6.81164 4.18618 6.31009 4.32685C5.80855 4.46753 5.34064 4.66326 4.90637 4.91403C4.4721 5.16481 4.07759 5.47063 3.72284 5.8315C3.36808 6.19237 3.06226 6.58994 2.80537 7.02421C2.54848 7.45848 2.34969 7.92638 2.20901 8.42793C2.06834 8.92948 1.998 9.44938 1.998 9.98763ZM17.4011 4.89568C17.7803 5.22596 17.9344 5.41558 18.228 5.81315C18.5216 6.21072 18.7724 6.63581 18.9803 7.08843C19.1883 7.54105 19.3443 8.01202 19.4482 8.50133C19.5522 8.99065 19.6073 9.48608 19.6134 9.98763C19.6134 10.636 19.5308 11.2599 19.3657 11.8593C19.2005 12.4587 18.962 13.0183 18.65 13.5382C18.3381 14.0581 17.9711 14.5352 17.5491 14.9695C17.127 15.4037 16.653 15.7707 16.127 16.0704C15.601 16.3701 15.0383 16.6056 14.4389 16.7769C13.8394 16.9481 13.2156 17.0338 12.5672 17.0338H6.64038L8.13586 18.5293L7.31013 19.355L4.40176 16.4466L7.31013 13.5382L8.13586 14.3639L6.64038 15.8594H12.5672C13.1055 15.8594 13.6254 15.7891 14.1269 15.6484C14.6285 15.5077 15.0964 15.312 15.5306 15.0612C15.9649 14.8104 16.3594 14.5046 16.7142 14.1438C17.0689 13.7829 17.3748 13.3853 17.6316 12.951C17.8885 12.5168 18.0873 12.0489 18.228 11.5473C18.3687 11.0458 18.439 10.5259 18.439 9.98763C18.439 9.55948 18.3931 9.1405 18.3014 8.7307C18.2096 8.3209 18.0781 7.92333 17.9069 7.53799C17.7356 7.15265 17.5215 6.79484 17.2647 6.46455C17.0078 6.13426 16.8996 5.9997 16.5754 5.71222L17.4011 4.89568Z" fill="#757678" stroke="#757678" stroke-width="0.5" stroke-linejoin="round"/> +</svg> diff --git a/gui/src/assets/icons/rumors.svg b/gui/src/assets/icons/rumors.svg new file mode 100644 index 0000000..2df5165 --- /dev/null +++ b/gui/src/assets/icons/rumors.svg @@ -0,0 +1,3 @@ +<svg width="22" height="14" viewBox="0 0 22 14" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M11.022 0.263794C-3.5008 0.263794 -0.710755 13.0138 5.82254 13.0138C7.13176 13.0138 8.36453 12.3268 9.15038 11.1591L9.99465 9.90432C10.5085 9.14098 11.5359 9.14098 12.0497 9.90432L12.894 11.1591C13.6795 12.3268 14.9123 13.0138 16.2215 13.0138C22.4513 13.0138 25.7578 0.263794 11.022 0.263794ZM6.53753 8.37731C5.19024 8.37731 4.31415 7.52532 3.9099 7.01432C3.73829 6.7975 3.73829 6.48008 3.9099 6.26293C4.31415 5.75161 5.18992 4.89995 6.53753 4.89995C7.88514 4.89995 8.7609 5.75194 9.16515 6.26293C9.33676 6.47975 9.33676 6.79717 9.16515 7.01432C8.7609 7.52565 7.88481 8.37731 6.53753 8.37731ZM15.4625 8.37731C14.1152 8.37731 13.2392 7.52532 12.8349 7.01432C12.6633 6.7975 12.6633 6.48008 12.8349 6.26293C13.2392 5.75161 14.1149 4.89995 15.4625 4.89995C16.8101 4.89995 17.6859 5.75194 18.0902 6.26293C18.2618 6.47975 18.2618 6.79717 18.0902 7.01432C17.6859 7.52565 16.8098 8.37731 15.4625 8.37731Z" fill="black"/> +</svg> diff --git a/gui/src/assets/icons/settings.svg b/gui/src/assets/icons/settings.svg new file mode 100644 index 0000000..5c5e400 --- /dev/null +++ b/gui/src/assets/icons/settings.svg @@ -0,0 +1,3 @@ +<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M10.012 0.888794C10.746 0.896794 11.477 0.981794 12.194 1.14179C12.3465 1.17584 12.4846 1.2567 12.5889 1.37305C12.6933 1.48941 12.7587 1.63546 12.776 1.79079L12.946 3.31779C12.97 3.53308 13.0442 3.73972 13.1626 3.9211C13.281 4.10248 13.4404 4.25352 13.6278 4.36208C13.8153 4.47064 14.0256 4.53369 14.2419 4.54614C14.4581 4.5586 14.6743 4.52012 14.873 4.43379L16.273 3.81879C16.4152 3.75614 16.5734 3.73924 16.7257 3.77041C16.878 3.80158 17.0168 3.87929 17.123 3.99279C18.135 5.07395 18.8889 6.37042 19.328 7.78479C19.3738 7.93353 19.3723 8.09286 19.3236 8.24068C19.2748 8.38851 19.1813 8.51751 19.056 8.60979L17.815 9.52579C17.6401 9.65396 17.4979 9.82152 17.3999 10.0149C17.3019 10.2083 17.2508 10.422 17.2508 10.6388C17.2508 10.8556 17.3019 11.0693 17.3999 11.2627C17.4979 11.4561 17.6401 11.6236 17.815 11.7518L19.058 12.6668C19.1835 12.7591 19.2772 12.8883 19.3259 13.0363C19.3747 13.1844 19.3761 13.3439 19.33 13.4928C18.8912 14.9071 18.1377 16.2035 17.126 17.2848C17.02 17.3983 16.8814 17.476 16.7293 17.5074C16.5772 17.5387 16.4192 17.5221 16.277 17.4598L14.871 16.8428C14.6726 16.7558 14.4565 16.7167 14.2402 16.7288C14.0238 16.7408 13.8134 16.8036 13.6259 16.9121C13.4383 17.0205 13.279 17.1716 13.1607 17.3531C13.0424 17.5346 12.9685 17.7414 12.945 17.9568L12.775 19.4828C12.758 19.6363 12.694 19.7808 12.5918 19.8966C12.4896 20.0124 12.3542 20.0939 12.204 20.1298C10.7556 20.4751 9.24634 20.4751 7.79797 20.1298C7.64757 20.094 7.5119 20.0127 7.4095 19.8969C7.30711 19.7811 7.24301 19.6364 7.22597 19.4828L7.05697 17.9588C7.03242 17.7441 6.95787 17.5382 6.83931 17.3575C6.72074 17.1768 6.56146 17.0265 6.37426 16.9186C6.18707 16.8106 5.97716 16.7481 5.76141 16.736C5.54566 16.7238 5.33008 16.7625 5.13197 16.8488L3.72597 17.4648C3.58363 17.5273 3.42537 17.5441 3.27309 17.5127C3.12081 17.4814 2.98204 17.4035 2.87597 17.2898C1.8641 16.2073 1.11088 14.9094 0.672971 13.4938C0.626868 13.3449 0.628288 13.1854 0.677034 13.0373C0.72578 12.8893 0.819431 12.7601 0.944971 12.6678L2.18797 11.7518C2.36282 11.6236 2.50501 11.4561 2.60302 11.2627C2.70103 11.0693 2.75211 10.8556 2.75211 10.6388C2.75211 10.422 2.70103 10.2083 2.60302 10.0149C2.50501 9.82152 2.36282 9.65396 2.18797 9.52579L0.944971 8.61179C0.819431 8.51944 0.72578 8.39027 0.677034 8.24224C0.628288 8.09421 0.626868 7.93467 0.672971 7.78579C1.11201 6.37142 1.8659 5.07495 2.87797 3.99379C2.98416 3.88029 3.12298 3.80258 3.27526 3.77141C3.42753 3.74024 3.58573 3.75714 3.72797 3.81979L5.12797 4.43479C5.32699 4.52107 5.54347 4.55946 5.76002 4.54688C5.97657 4.53431 6.18715 4.47112 6.37484 4.36239C6.56254 4.25366 6.72212 4.10243 6.84076 3.92084C6.9594 3.73924 7.0338 3.53236 7.05797 3.31679L7.22797 1.79079C7.24513 1.63515 7.31058 1.48878 7.41513 1.37221C7.51968 1.25564 7.6581 1.17472 7.81097 1.14079C8.52697 0.982127 9.26064 0.898127 10.012 0.888794ZM9.99997 7.63879C9.20432 7.63879 8.44126 7.95486 7.87865 8.51747C7.31604 9.08008 6.99997 9.84314 6.99997 10.6388C6.99997 11.4344 7.31604 12.1975 7.87865 12.7601C8.44126 13.3227 9.20432 13.6388 9.99997 13.6388C10.7956 13.6388 11.5587 13.3227 12.1213 12.7601C12.6839 12.1975 13 11.4344 13 10.6388C13 9.84314 12.6839 9.08008 12.1213 8.51747C11.5587 7.95486 10.7956 7.63879 9.99997 7.63879Z" fill="black"/> +</svg> diff --git a/gui/src/assets/icons/youtube.svg b/gui/src/assets/icons/youtube.svg new file mode 100644 index 0000000..46d7db9 --- /dev/null +++ b/gui/src/assets/icons/youtube.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="800" width="1200" viewBox="-35.20005 -41.33325 305.0671 247.9995"><path d="M229.763 25.817c-2.699-10.162-10.65-18.165-20.748-20.881C190.716 0 117.333 0 117.333 0S43.951 0 25.651 4.936C15.553 7.652 7.6 15.655 4.903 25.817 0 44.236 0 82.667 0 82.667s0 38.429 4.903 56.85C7.6 149.68 15.553 157.681 25.65 160.4c18.3 4.934 91.682 4.934 91.682 4.934s73.383 0 91.682-4.934c10.098-2.718 18.049-10.72 20.748-20.882 4.904-18.421 4.904-56.85 4.904-56.85s0-38.431-4.904-56.85" fill="red"/><path d="M93.333 117.559l61.333-34.89-61.333-34.894z" fill="#fff"/></svg>
\ No newline at end of file diff --git a/gui/src/assets/reacts/chad.png b/gui/src/assets/reacts/chad.png Binary files differnew file mode 100644 index 0000000..51cfa0d --- /dev/null +++ b/gui/src/assets/reacts/chad.png diff --git a/gui/src/assets/reacts/cringe.png b/gui/src/assets/reacts/cringe.png Binary files differnew file mode 100644 index 0000000..d39a50c --- /dev/null +++ b/gui/src/assets/reacts/cringe.png diff --git a/gui/src/assets/reacts/cry.png b/gui/src/assets/reacts/cry.png Binary files differnew file mode 100644 index 0000000..f70fb28 --- /dev/null +++ b/gui/src/assets/reacts/cry.png diff --git a/gui/src/assets/reacts/doom.png b/gui/src/assets/reacts/doom.png Binary files differnew file mode 100644 index 0000000..e6df1f4 --- /dev/null +++ b/gui/src/assets/reacts/doom.png diff --git a/gui/src/assets/reacts/facepalm.png b/gui/src/assets/reacts/facepalm.png Binary files differnew file mode 100644 index 0000000..a03def9 --- /dev/null +++ b/gui/src/assets/reacts/facepalm.png diff --git a/gui/src/assets/reacts/galaxy.png b/gui/src/assets/reacts/galaxy.png Binary files differnew file mode 100644 index 0000000..3c496d3 --- /dev/null +++ b/gui/src/assets/reacts/galaxy.png diff --git a/gui/src/assets/reacts/gigachad.png b/gui/src/assets/reacts/gigachad.png Binary files differnew file mode 100644 index 0000000..5f3c2e1 --- /dev/null +++ b/gui/src/assets/reacts/gigachad.png diff --git a/gui/src/assets/reacts/pepechin.png b/gui/src/assets/reacts/pepechin.png Binary files differnew file mode 100644 index 0000000..dafd907 --- /dev/null +++ b/gui/src/assets/reacts/pepechin.png diff --git a/gui/src/assets/reacts/pepeeyes.png b/gui/src/assets/reacts/pepeeyes.png Binary files differnew file mode 100644 index 0000000..e57d5e6 --- /dev/null +++ b/gui/src/assets/reacts/pepeeyes.png diff --git a/gui/src/assets/reacts/pepegmi.png b/gui/src/assets/reacts/pepegmi.png Binary files differnew file mode 100644 index 0000000..7c3cae4 --- /dev/null +++ b/gui/src/assets/reacts/pepegmi.png diff --git a/gui/src/assets/reacts/pepesad.png b/gui/src/assets/reacts/pepesad.png Binary files differnew file mode 100644 index 0000000..51891fd --- /dev/null +++ b/gui/src/assets/reacts/pepesad.png diff --git a/gui/src/assets/reacts/pika.png b/gui/src/assets/reacts/pika.png Binary files differnew file mode 100644 index 0000000..791594b --- /dev/null +++ b/gui/src/assets/reacts/pika.png diff --git a/gui/src/assets/reacts/pink.png b/gui/src/assets/reacts/pink.png Binary files differnew file mode 100644 index 0000000..59fdc6a --- /dev/null +++ b/gui/src/assets/reacts/pink.png diff --git a/gui/src/assets/reacts/soy.png b/gui/src/assets/reacts/soy.png Binary files differnew file mode 100644 index 0000000..33dbe33 --- /dev/null +++ b/gui/src/assets/reacts/soy.png diff --git a/gui/src/assets/reacts/yeschad.png b/gui/src/assets/reacts/yeschad.png Binary files differnew file mode 100644 index 0000000..e001332 --- /dev/null +++ b/gui/src/assets/reacts/yeschad.png diff --git a/gui/src/assets/triangles.svg b/gui/src/assets/triangles.svg new file mode 100644 index 0000000..0b45c01 --- /dev/null +++ b/gui/src/assets/triangles.svg @@ -0,0 +1,18 @@ +<?xml version="1.0" encoding="utf-8"?> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: none; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid"> +<g transform="translate(50 42)"> + <g transform="scale(0.8)"> + <g transform="translate(-50 -50)"> + <polygon fill="#9f1515" points="72.5 50 50 11 27.5 50 50 50"> + <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 38.5;360 50 38.5" keyTimes="0;1"></animateTransform> + </polygon> + <polygon fill="#f5e116" points="5 89 50 89 27.5 50"> + <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 27.5 77.5;360 27.5 77.5" keyTimes="0;1"></animateTransform> + </polygon> + <polygon fill="#04284d" points="72.5 50 50 89 95 89"> + <animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 72.5 77.5;360 72 77.5" keyTimes="0;1"></animateTransform> + </polygon> + </g> + </g> +</g> +<!-- [ldio] generated by https://loading.io/ --></svg>
\ No newline at end of file diff --git a/gui/src/components/Avatar.tsx b/gui/src/components/Avatar.tsx new file mode 100644 index 0000000..a071655 --- /dev/null +++ b/gui/src/components/Avatar.tsx @@ -0,0 +1,62 @@ +import useLocalState from "@/state/state"; +import Sigil from "./Sigil"; +import { isValidPatp } from "urbit-ob"; +import type { UserProfile, UserType } from "@/types/nostrill"; +import Icon from "@/components/Icon"; +import UserModal from "./modals/UserModal"; + +export default function ({ + user, + userString, + size, + color, + noClickOnName, + profile, + picOnly = false, +}: { + user: UserType; + userString: string; + size: number; + color?: string; + noClickOnName?: boolean; + profile?: UserProfile; + picOnly?: boolean; +}) { + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); + // TODO revisit this when %whom updates + console.log({ profile }); + const avatarInner = profile ? ( + <img src={profile.picture} width={size} height={size} /> + ) : "urbit" in user && isValidPatp(user.urbit) ? ( + <Sigil patp={user.urbit} size={size} bg={color} /> + ) : ( + <Icon name="comet" /> + ); + const avatar = ( + <div className="avatar cp" onClick={openModal}> + {avatarInner} + </div> + ); + if (picOnly) return avatar; + + const tooLong = (s: string) => (s.length > 15 ? " too-long" : ""); + function openModal(e: React.MouseEvent) { + if (noClickOnName) return; + e.stopPropagation(); + setModal(<UserModal user={user} userString={userString} />); + } + const name = ( + <div className="name cp" role="link" onMouseUp={openModal}> + {profile ? ( + <p>{profile.name}</p> + ) : "urbit" in user ? ( + <p className={"p-only" + tooLong(user.urbit)}> + {user.urbit.length > 28 ? "Anon" : user.urbit} + </p> + ) : ( + <p className={"p-only" + tooLong(user.nostr)}>{user.nostr}</p> + )} + </div> + ); + return <div className="ship-avatar">{name}</div>; +} diff --git a/gui/src/components/Icon.tsx b/gui/src/components/Icon.tsx new file mode 100644 index 0000000..797a87b --- /dev/null +++ b/gui/src/components/Icon.tsx @@ -0,0 +1,137 @@ +import { useTheme } from "@/styles/ThemeProvider"; + +import bellSvg from "@/assets/icons/bell.svg"; +import cometSvg from "@/assets/icons/comet.svg"; +import copySvg from "@/assets/icons/copy.svg"; +import crowSvg from "@/assets/icons/crow.svg"; +import emojiSvg from "@/assets/icons/emoji.svg"; +import homeSvg from "@/assets/icons/home.svg"; +import keySvg from "@/assets/icons/key.svg"; +import messagesSvg from "@/assets/icons/messages.svg"; +import nostrSvg from "@/assets/icons/nostr.svg"; +import palsSvg from "@/assets/icons/pals.svg"; +import profileSvg from "@/assets/icons/profile.svg"; +import quoteSvg from "@/assets/icons/quote.svg"; +import radioSvg from "@/assets/icons/radio.svg"; +import replySvg from "@/assets/icons/reply.svg"; +import repostSvg from "@/assets/icons/rt.svg"; +import rumorsSvg from "@/assets/icons/rumors.svg"; +import settingsSvg from "@/assets/icons/settings.svg"; +import youtubeSvg from "@/assets/icons/youtube.svg"; + +export type IconName = + | "bell" + | "comet" + | "copy" + | "crow" + | "emoji" + | "home" + | "key" + | "messages" + | "nostr" + | "pals" + | "profile" + | "quote" + | "radio" + | "reply" + | "repost" + | "rumors" + | "settings" + | "youtube"; + +const iconMap: Record<IconName, string> = { + bell: bellSvg, + comet: cometSvg, + copy: copySvg, + crow: crowSvg, + emoji: emojiSvg, + home: homeSvg, + key: keySvg, + messages: messagesSvg, + nostr: nostrSvg, + pals: palsSvg, + profile: profileSvg, + quote: quoteSvg, + radio: radioSvg, + reply: replySvg, + repost: repostSvg, + rumors: rumorsSvg, + settings: settingsSvg, + youtube: youtubeSvg, +}; + +interface IconProps { + name: IconName; + size?: number; + className?: string; + title?: string; + onClick?: (e: React.MouseEvent) => any; + color?: "primary" | "text" | "textSecondary" | "textMuted" | "custom"; + customColor?: string; +} + +const Icon: React.FC<IconProps> = ({ + name, + size = 20, + className = "", + title, + onClick, + color = "text", + customColor, +}) => { + const { theme } = useTheme(); + + // Simple filter based on theme - icons should match text + const getFilter = () => { + // For dark themes, invert the black SVGs to white + if ( + theme.name === "dark" || + theme.name === "noir" || + theme.name === "gruvbox" + ) { + return "invert(1)"; + } + // For light themes with dark text, keep as is + if (theme.name === "light") { + return "none"; + } + // For colored themes, adjust brightness/contrast + if (theme.name === "sepia") { + return "sepia(1) saturate(2) hue-rotate(20deg) brightness(0.8)"; + } + if (theme.name === "ocean") { + return "brightness(0) saturate(100%) invert(13%) sepia(95%) saturate(3207%) hue-rotate(195deg) brightness(94%) contrast(106%)"; + } + if (theme.name === "forest") { + return "brightness(0) saturate(100%) invert(24%) sepia(95%) saturate(1352%) hue-rotate(87deg) brightness(92%) contrast(96%)"; + } + return "none"; + }; + + const iconUrl = iconMap[name]; + + if (!iconUrl) { + console.error(`Icon "${name}" not found`); + return null; + } + + return ( + <img + src={iconUrl} + className={`icon ${className}`} + onClick={onClick} + title={title} + alt={title || name} + style={{ + width: size, + height: size, + display: "inline-block", + cursor: onClick ? "pointer" : "default", + filter: getFilter(), + transition: "filter 0.2s ease", + }} + /> + ); +}; + +export default Icon; diff --git a/gui/src/components/NotificationCenter.tsx b/gui/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..44a6799 --- /dev/null +++ b/gui/src/components/NotificationCenter.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import useLocalState from "@/state/state"; +import Modal from "./modals/Modal"; +import Icon from "./Icon"; +import Avatar from "./Avatar"; +import { useLocation } from "wouter"; +import type { Notification, NotificationType } from "@/types/notifications"; +import "@/styles/NotificationCenter.css"; + +const NotificationCenter = () => { + const [_, navigate] = useLocation(); + const { + notifications, + unreadNotifications, + markNotificationRead, + markAllNotificationsRead, + clearNotifications, + setModal + } = useLocalState((s) => ({ + notifications: s.notifications, + unreadNotifications: s.unreadNotifications, + markNotificationRead: s.markNotificationRead, + markAllNotificationsRead: s.markAllNotificationsRead, + clearNotifications: s.clearNotifications, + setModal: s.setModal + })); + + const [filter, setFilter] = useState<"all" | "unread">("all"); + + const filteredNotifications = filter === "unread" + ? notifications.filter(n => !n.read) + : notifications; + + const handleNotificationClick = (notification: Notification) => { + // Mark as read + if (!notification.read) { + markNotificationRead(notification.id); + } + + // Navigate based on notification type + if (notification.postId) { + // Navigate to post + navigate(`/post/${notification.postId}`); + setModal(null); + } else if (notification.type === "follow" || notification.type === "access_request") { + // Navigate to user profile + navigate(`/feed/${notification.from}`); + setModal(null); + } + }; + + const getNotificationIcon = (type: NotificationType) => { + switch (type) { + case "follow": + case "unfollow": + return "pals"; + case "mention": + case "reply": + return "messages"; + case "repost": + return "repost"; + case "react": + return "emoji"; + case "access_request": + case "access_granted": + return "key"; + default: + return "bell"; + } + }; + + const getNotificationText = (notification: Notification) => { + switch (notification.type) { + case "follow": + return `${notification.from} started following you`; + case "unfollow": + return `${notification.from} unfollowed you`; + case "mention": + return `${notification.from} mentioned you in a post`; + case "reply": + return `${notification.from} replied to your post`; + case "repost": + return `${notification.from} reposted your post`; + case "react": + return `${notification.from} reacted ${notification.reaction || ""} to your post`; + case "access_request": + return `${notification.from} requested access to your feed`; + case "access_granted": + return `${notification.from} granted you access to their feed`; + default: + return notification.message || "New notification"; + } + }; + + const formatTimestamp = (date: Date) => { + const now = new Date(); + const diff = now.getTime() - new Date(date).getTime(); + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return "Just now"; + if (minutes < 60) return `${minutes}m ago`; + if (hours < 24) return `${hours}h ago`; + if (days < 7) return `${days}d ago`; + return new Date(date).toLocaleDateString(); + }; + + return ( + <Modal close={() => setModal(null)}> + <div className="notification-center"> + <div className="notification-header"> + <h2>Notifications</h2> + <div className="notification-actions"> + {unreadNotifications > 0 && ( + <button + className="mark-all-read-btn" + onClick={markAllNotificationsRead} + > + Mark all as read + </button> + )} + {notifications.length > 0 && ( + <button + className="clear-all-btn" + onClick={clearNotifications} + > + Clear all + </button> + )} + </div> + </div> + + <div className="notification-filters"> + <button + className={`filter-btn ${filter === "all" ? "active" : ""}`} + onClick={() => setFilter("all")} + > + All ({notifications.length}) + </button> + <button + className={`filter-btn ${filter === "unread" ? "active" : ""}`} + onClick={() => setFilter("unread")} + > + Unread ({unreadNotifications}) + </button> + </div> + + <div className="notification-list"> + {filteredNotifications.length === 0 ? ( + <div className="no-notifications"> + <Icon name="bell" size={48} color="textMuted" /> + <p>No {filter === "unread" ? "unread " : ""}notifications</p> + </div> + ) : ( + filteredNotifications.map((notification) => ( + <div + key={notification.id} + className={`notification-item ${!notification.read ? "unread" : ""}`} + onClick={() => handleNotificationClick(notification)} + > + <div className="notification-icon"> + <Icon + name={getNotificationIcon(notification.type)} + size={20} + color={!notification.read ? "primary" : "textSecondary"} + /> + </div> + + <div className="notification-content"> + <div className="notification-user"> + <Avatar p={notification.from} size={32} /> + <div className="notification-text"> + <p>{getNotificationText(notification)}</p> + <span className="notification-time"> + {formatTimestamp(notification.timestamp)} + </span> + </div> + </div> + </div> + + {!notification.read && <div className="unread-indicator" />} + </div> + )) + )} + </div> + </div> + </Modal> + ); +}; + +export default NotificationCenter;
\ No newline at end of file diff --git a/gui/src/components/Sigil.tsx b/gui/src/components/Sigil.tsx new file mode 100644 index 0000000..cbc2e57 --- /dev/null +++ b/gui/src/components/Sigil.tsx @@ -0,0 +1,50 @@ +import Icon from "@/components/Icon"; +import { auraToHex } from "@/logic/utils"; +import { isValidPatp } from "urbit-ob"; +import { sigil } from "urbit-sigils"; +import { reactRenderer } from "urbit-sigils"; + +interface SigilProps { + patp: string; + size: number; + bg?: string; + fg?: string; +} + +const Sigil = (props: SigilProps) => { + const bg = props.bg ? auraToHex(props.bg) : "var(--color-background)"; + const fg = props.fg ? auraToHex(props.fg) : "var(--color-primary)"; + if (props.patp.length > 28) + return ( + <Icon + name="comet" + size={props.size} + className="comet-icon" + /> + ); + else if (props.patp.length > 15) + // moons + return ( + <> + {sigil({ + patp: props.patp.substring(props.patp.length - 13), + renderer: reactRenderer, + size: props.size, + colors: ["grey", "white"], + })} + </> + ); + else + return ( + <> + {sigil({ + patp: props.patp, + renderer: reactRenderer, + size: props.size, + colors: [bg, fg], + })} + </> + ); +}; + +export default Sigil; diff --git a/gui/src/components/WsWidget.tsx b/gui/src/components/WsWidget.tsx new file mode 100644 index 0000000..75c773d --- /dev/null +++ b/gui/src/components/WsWidget.tsx @@ -0,0 +1,123 @@ +import { useWebSocket } from "@/hooks/useWs"; +import { useState } from "react"; + +type WidgetProps = { + url: string; + protocols?: string | string[]; +}; + +export default function WebSocketWidget({ url, protocols }: WidgetProps) { + const { + status, + retryCount, + lastMessage, + error, + bufferedAmount, + send, + reconnectNow, + close, + } = useWebSocket({ + url, + protocols, + onMessage: (ev) => { + // Example: auto reply to pings + console.log(ev.data, "ws event"); + if ( + typeof ev.data === "string" && + // ev.data.toLowerCase().includes("ping") + ev.data.toLowerCase().trim() == "ping" + ) { + try { + console.log("sending pong"); + send("pong"); + } catch {} + } + }, + }); + + const [outbound, setOutbound] = useState(""); + + return ( + <div className="w-full max-w-xl mx-auto p-4 grid gap-3"> + <header className="flex items-center justify-between"> + <h1 className="text-xl font-semibold">WebSocketWidget</h1> + <span className="text-sm px-2 py-1 rounded-full border"> + {status.toUpperCase()} {retryCount ? `(retry ${retryCount})` : ""} + </span> + </header> + + <div className="text-sm text-gray-600"> + <div> + <b>URL:</b> {url} + </div> + <div> + <b>Buffered:</b> {bufferedAmount} bytes + </div> + {error && ( + <div className="text-red-600"> + <b>Error:</b>{" "} + {"message" in error + ? (error as any).message + : String(error.type || "error")} + </div> + )} + </div> + + <div className="p-3 rounded-2xl border bg-gray-50 min-h-[4rem] font-mono text-sm break-words"> + <div className="opacity-70">Last message:</div> + <div> + {lastMessage + ? typeof lastMessage.data === "string" + ? lastMessage.data + : "(binary)" + : "—"} + </div> + </div> + + <form + className="flex gap-2" + onSubmit={(e) => { + e.preventDefault(); + if (!outbound) return; + send(outbound); + setOutbound(""); + }} + > + <input + className="flex-1 px-3 py-2 rounded-xl border" + placeholder="Type message…" + value={outbound} + onChange={(e) => setOutbound(e.target.value)} + /> + <button type="submit" className="px-3 py-2 rounded-xl border"> + Send + </button> + </form> + + <div className="flex gap-2"> + <button className="px-3 py-2 rounded-xl border" onClick={reconnectNow}> + Reconnect + </button> + <button className="px-3 py-2 rounded-xl border" onClick={() => close()}> + Close + </button> + </div> + + <details className="mt-2"> + <summary className="cursor-pointer">Usage</summary> + <pre className="text-xs bg-gray-100 p-2 rounded-xl overflow-auto"> + {`import WebSocketWidget from "./WebSocketWidget"; + +export default function App() { + return ( + <div className="p-6"> + <WebSocketWidget url="wss://echo.websocket.events" /> + </div> + ); +} +`} + </pre> + </details> + </div> + ); +} diff --git a/gui/src/components/composer/Composer.tsx b/gui/src/components/composer/Composer.tsx new file mode 100644 index 0000000..81d0358 --- /dev/null +++ b/gui/src/components/composer/Composer.tsx @@ -0,0 +1,205 @@ +import useLocalState from "@/state/state"; +import type { Poast } from "@/types/trill"; +import Sigil from "@/components/Sigil"; +import { useState, useEffect, useRef, type FormEvent } from "react"; +import Snippets, { ReplySnippet } from "./Snippets"; +import toast from "react-hot-toast"; +import Icon from "@/components/Icon"; +import { wait } from "@/logic/utils"; + +function Composer({ isAnon }: { isAnon?: boolean }) { + const { api, composerData, addNotification, setComposerData } = useLocalState( + (s) => ({ + api: s.api, + composerData: s.composerData, + addNotification: s.addNotification, + setComposerData: s.setComposerData, + }), + ); + const our = api!.airlock.our!; + const [input, setInput] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const [isLoading, setLoading] = useState(false); + const inputRef = useRef<HTMLInputElement>(null); + + useEffect(() => { + if (composerData) { + setIsExpanded(true); + if ( + composerData.type === "reply" && + composerData.post && + "trill" in composerData.post + ) { + const author = composerData.post.trill.author; + setInput(`${author} `); + } + // Auto-focus input when composer opens + setTimeout(() => { + inputRef.current?.focus(); + }, 100); // Small delay to ensure the composer is rendered + } + }, [composerData]); + async function poast(e: FormEvent<HTMLFormElement>) { + e.preventDefault(); + // TODO + // const parent = replying ? replying : null; + // const tokens = tokenize(input); + // const post: SentPoast = { + // host: parent ? parent.host : our, + // author: our, + // thread: parent ? parent.thread : null, + // parent: parent ? parent.id : null, + // contents: input, + // read: openLock, + // write: openLock, + // tags: input.match(HASHTAGS_REGEX) || [], + // }; + // TODO make it user choosable + setLoading(true); + + const res = + composerData?.type === "reply" && "trill" in composerData.post + ? api!.addReply( + input, + composerData.post.trill.host, + composerData.post.trill.id, + composerData.post.trill.thread || composerData.post.trill.id, + ) + : composerData?.type === "quote" && "trill" in composerData.post + ? api!.addQuote(input, { + ship: composerData.post.trill.host, + id: composerData.post.trill.id, + }) + : !composerData + ? api!.addPost(input) + : wait(500); + const ares = await res; + if (ares) { + // // Check for mentions in the post (ship names starting with ~) + const mentions = input.match(/~[a-z-]+/g); + if (mentions) { + mentions.forEach((mention) => { + if (mention !== our) { + // Don't notify self-mentions + addNotification({ + type: "mention", + from: our, + message: `You mentioned ${mention} in a post`, + }); + } + }); + } + + // If this is a reply, add notification + if ( + composerData?.type === "reply" && + composerData.post && + "trill" in composerData.post + ) { + if (composerData.post.trill.author !== our) { + addNotification({ + type: "reply", + from: our, + message: `You replied to ${composerData.post.trill.author}'s post`, + postId: composerData.post.trill.id, + }); + } + } + + setInput(""); + setComposerData(null); // Clear composer data after successful post + toast.success("post sent"); + setIsExpanded(false); + } + } + const placeHolder = + composerData?.type === "reply" + ? "Write your reply..." + : composerData?.type === "quote" + ? "Add your thoughts..." + : isAnon + ? "> be me" + : "What's going on in Urbit"; + + const clearComposer = (e: React.MouseEvent) => { + e.preventDefault(); + setComposerData(null); + setInput(""); + setIsExpanded(false); + }; + + return ( + <form + id="composer" + className={`${isExpanded ? "expanded" : ""} ${composerData ? "has-context" : ""}`} + onSubmit={poast} + > + <div className="sigil avatar"> + <Sigil patp={our} size={46} /> + </div> + + <div className="composer-content"> + {/* Reply snippets appear above input */} + {composerData && composerData.type === "reply" && ( + <div className="composer-context reply-context"> + <div className="context-header"> + <span className="context-type"> + <Icon name="reply" size={14} /> Replying to + </span> + <button + className="clear-context" + onClick={clearComposer} + title="Clear" + type="button" + > + × + </button> + </div> + <ReplySnippet post={composerData.post} /> + </div> + )} + + {/* Quote context header above input (without snippet) */} + {composerData && composerData.type === "quote" && ( + <div className="quote-header"> + <div className="context-header"> + <span className="context-type"> + <Icon name="quote" size={14} /> Quote posting + </span> + <button + className="clear-context" + onClick={clearComposer} + title="Clear" + type="button" + > + × + </button> + </div> + </div> + )} + + <div className="composer-input-row"> + <input + ref={inputRef} + value={input} + onInput={(e) => setInput(e.currentTarget.value)} + onFocus={() => setIsExpanded(true)} + placeholder={placeHolder} + /> + <button type="submit" disabled={!input.trim()} className="post-btn"> + Post + </button> + </div> + + {/* Quote snippets appear below input */} + {composerData && composerData.type === "quote" && ( + <div className="composer-context quote-context"> + <Snippets post={composerData.post} /> + </div> + )} + </div> + </form> + ); +} + +export default Composer; diff --git a/gui/src/components/composer/Snippets.tsx b/gui/src/components/composer/Snippets.tsx new file mode 100644 index 0000000..49d9b88 --- /dev/null +++ b/gui/src/components/composer/Snippets.tsx @@ -0,0 +1,86 @@ +import Quote from "@/components/post/Quote"; +import type { SPID } from "@/types/ui"; +import { NostrSnippet } from "../post/wrappers/Nostr"; + +export default Snippets; +function Snippets({ post }: { post: SPID }) { + return ( + <ComposerSnippet> + <PostSnippet post={post} /> + </ComposerSnippet> + ); +} + +export function ComposerSnippet({ + onClick, + children, +}: { + onClick?: any; + children: any; +}) { + function onc(e: React.MouseEvent) { + e.stopPropagation(); + if (onClick) onClick(); + } + return ( + <div className="composer-snippet"> + {onClick && ( + <div className="pop-snippet-icon cp" role="link" onClick={onc}> + × + </div> + )} + {children} + </div> + ); +} +function PostSnippet({ post }: { post: SPID }) { + if (!post) return <div className="snippet-error">No post data</div>; + + try { + if ("trill" in post) return <Quote data={post.trill} nest={0} />; + else if ("nostr" in post) return <NostrSnippet {...post.nostr} />; + // else if ("twatter" in post) + // return ( + // <div id={`composer-${type}`}> + // <Tweet tweet={post.post} quote={true} /> + // </div> + // ); + // else if ("rumors" in post) + // return ( + // <div id={`composer-${type}`}> + // <div className="rumor-quote f1"> + // <img src={rumorIcon} alt="" /> + // <Body poast={post.post} refetch={() => {}} /> + // <span>{date_diff(post.post.time, "short")}</span> + // </div> + // </div> + // ); + else return <div className="snippet-error">Unsupported post type</div>; + } catch (error) { + console.error("Error rendering post snippet:", error); + return <div className="snippet-error">Failed to load post</div>; + } +} + +export function ReplySnippet({ post }: { post: SPID }) { + if (!post) return <div className="snippet-error">No post to reply to</div>; + + try { + if ("trill" in post) + return ( + <div id="reply" className="reply-snippet"> + <Quote data={post.trill} nest={0} /> + </div> + ); + else if ("nostr" in post) + return ( + <div id="reply" className="reply-snippet"> + <NostrSnippet {...post.nostr} /> + </div> + ); + else return <div className="snippet-error">Cannot reply to this post type</div>; + } catch (error) { + console.error("Error rendering reply snippet:", error); + return <div className="snippet-error">Failed to load reply context</div>; + } +} diff --git a/gui/src/components/feed/PostList.tsx b/gui/src/components/feed/PostList.tsx new file mode 100644 index 0000000..b09a0e9 --- /dev/null +++ b/gui/src/components/feed/PostList.tsx @@ -0,0 +1,33 @@ +import TrillPost from "@/components/post/Post"; +import type { FC } from "@/types/trill"; +// import { useEffect } from "react"; +// import { useQueryClient } from "@tanstack/react-query"; +// import { toFull } from "../thread/helpers"; + +function TrillFeed({ data, refetch }: { data: FC; refetch: Function }) { + // const qc = useQueryClient(); + // useEffect(() => { + // Object.values(data.feed).forEach((poast) => { + // const queryKey = ["trill-thread", poast.host, poast.id]; + // const existing = qc.getQueryData(queryKey); + // if (!existing || !("fpost" in (existing as any))) { + // qc.setQueryData(queryKey, { + // fpost: toFull(poast), + // }); + // } + // }); + // }, [data]); + return ( + <> + {Object.keys(data.feed) + .sort() + .reverse() + .slice(0, 50) + .map((i) => ( + <TrillPost key={i} poast={data.feed[i]} refetch={refetch} /> + ))} + </> + ); +} + +export default TrillFeed; diff --git a/gui/src/components/layout/Sidebar.tsx b/gui/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..c267e2f --- /dev/null +++ b/gui/src/components/layout/Sidebar.tsx @@ -0,0 +1,80 @@ +import { RADIO, versionNum } from "@/logic/constants"; +import { useLocation } from "wouter"; +import useLocalState from "@/state/state"; +import logo from "@/assets/icons/logo.png"; +import Icon from "@/components/Icon"; +import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; + +function SlidingMenu() { + const [_, navigate] = useLocation(); + const { api, unreadNotifications, setModal } = useLocalState((s) => ({ + api: s.api, + unreadNotifications: s.unreadNotifications, + setModal: s.setModal + })); + + function goto(to: string) { + navigate(to); + } + + function openNotifications() { + // We'll create this component next + import("../NotificationCenter").then(({ default: NotificationCenter }) => { + setModal(<NotificationCenter />); + }); + } + return ( + <div id="left-menu"> + <div id="logo"> + <img src={logo} /> + <h3> Nostrill </h3> + </div> + <h3>Feeds</h3> + <div className="opt" role="link" onClick={() => goto(`/feed/global`)}> + <Icon name="home" size={20} /> + <div>Home</div> + </div> + <div className="opt notification-item" role="link" onClick={openNotifications}> + <div className="notification-icon-wrapper"> + <Icon name="bell" size={20} /> + {unreadNotifications > 0 && ( + <span className="notification-badge"> + {unreadNotifications > 99 ? "99+" : unreadNotifications} + </span> + )} + </div> + <div>Notifications</div> + </div> + <hr /> + + <div + className="opt tbd" + role="link" + // onClick={() => goto("/chat")} + > + <Icon name="messages" size={20} /> + <div>Messages</div> + </div> + <div className="opt" role="link" onClick={() => goto("/pals")}> + <Icon name="pals" size={20} /> + <div>Pals</div> + </div> + <hr /> + <div + className="opt" + role="link" + onClick={() => goto(`/feed/${api!.airlock.our}`)} + > + <Icon name="profile" size={20} /> + <div>Profile</div> + </div> + <hr /> + <div className="opt" role="link" onClick={() => goto("/sets")}> + <Icon name="settings" size={20} /> + <div>Settings</div> + </div> + <ThemeSwitcher /> + </div> + ); +} +export default SlidingMenu; diff --git a/gui/src/components/modals/Modal.tsx b/gui/src/components/modals/Modal.tsx new file mode 100644 index 0000000..e7bae78 --- /dev/null +++ b/gui/src/components/modals/Modal.tsx @@ -0,0 +1,72 @@ +import useLocalState from "@/state/state"; +import { useEffect, useRef, useState } from "react"; + +function Modal({ children }: any) { + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); + function onKey(event: any) { + if (event.key === "Escape") setModal(null); + } + useEffect(() => { + document.addEventListener("keyup", onKey); + return () => { + document.removeEventListener("keyup", onKey); + }; + }, [children]); + + function clickAway(e: React.MouseEvent) { + console.log("clicked away"); + e.stopPropagation(); + if (!modalRef.current || !modalRef.current.contains(e.target)) + setModal(null); + } + const modalRef = useRef(null); + return ( + <div id="modal-background" onClick={clickAway}> + <div id="modal" ref={modalRef}> + {children} + </div> + </div> + ); +} +export default Modal; + +export function Welcome() { + return ( + <Modal> + <div id="welcome-msg"> + <h1>Welcome to Nostril!</h1> + <p> + Trill is the world's only truly free and sovereign social media + platform, powered by Urbit. + </p> + <p> + Click on the crow icon on the top left to see all available feeds. + </p> + <p>The Global feed should be populated by default.</p> + <p>Follow people soon so your Global feed doesn't go stale.</p> + <p> + Trill is still on beta. The UI is Mobile only, we recommend you use + your phone or the browser dev tools. Desktop UI is on the works. + </p> + <p> + If you have any feedback please reach out to us on Groups at + ~hoster-dozzod-sortug/trill or here at ~polwex + </p> + </div> + </Modal> + ); +} + +export function Tooltip({ children, text, className }: any) { + const [show, toggle] = useState(false); + return ( + <div + className={"tooltip-wrapper " + (className || "")} + onMouseOver={() => toggle(true)} + onMouseOut={() => toggle(false)} + > + {children} + {show && <div className="tooltip">{text}</div>} + </div> + ); +} diff --git a/gui/src/components/modals/ShipModal.tsx b/gui/src/components/modals/ShipModal.tsx new file mode 100644 index 0000000..e823a3a --- /dev/null +++ b/gui/src/components/modals/ShipModal.tsx @@ -0,0 +1,48 @@ +import type { Ship } from "@/types/urbit"; +import Modal from "./Modal"; +import Avatar from "../Avatar"; +import Icon from "@/components/Icon"; +import useLocalState from "@/state/state"; +import { useLocation } from "wouter"; +import toast from "react-hot-toast"; + +export default function ({ ship }: { ship: Ship }) { + const { setModal, api } = useLocalState((s) => ({ + setModal: s.setModal, + api: s.api, + })); + const [_, navigate] = useLocation(); + function close() { + setModal(null); + } + async function copy(e: React.MouseEvent) { + e.stopPropagation(); + await navigator.clipboard.writeText(ship); + toast.success("Copied to clipboard"); + } + return ( + <Modal close={close}> + <div id="ship-modal"> + <div className="flex"> + <Avatar p={ship} size={60} /> + <Icon + name="copy" + size={20} + className="copy-icon cp" + onClick={copy} + title="Copy ship name" + /> + </div> + <div className="buttons f1"> + <button onClick={() => navigate(`/feed/${ship}`)}>Feed</button> + <button onClick={() => navigate(`/pals/${ship}`)}>Profile</button> + {ship !== api!.airlock.our && ( + <> + <button onClick={() => navigate(`/chat/dm/${ship}`)}>DM</button> + </> + )} + </div> + </div> + </Modal> + ); +} diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx new file mode 100644 index 0000000..6e3089d --- /dev/null +++ b/gui/src/components/modals/UserModal.tsx @@ -0,0 +1,65 @@ +import Modal from "./Modal"; +import Avatar from "../Avatar"; +import Icon from "@/components/Icon"; +import useLocalState from "@/state/state"; +import { useLocation } from "wouter"; +import toast from "react-hot-toast"; +import type { UserType } from "@/types/nostrill"; + +export default function ({ + user, + userString, +}: { + user: UserType; + userString: string; +}) { + const { setModal, api, pubkey } = useLocalState((s) => ({ + setModal: s.setModal, + api: s.api, + pubkey: s.pubkey, + })); + const [_, navigate] = useLocation(); + function close() { + setModal(null); + } + const itsMe = + "urbit" in user + ? user.urbit === api?.airlock.our + : "nostr" in user + ? user.nostr === pubkey + : false; + async function copy(e: React.MouseEvent) { + e.stopPropagation(); + await navigator.clipboard.writeText(userString); + toast.success("Copied to clipboard"); + } + return ( + <Modal close={close}> + <div id="ship-modal"> + <div className="flex"> + <Avatar user={user} userString={userString} size={60} /> + <Icon + name="copy" + size={20} + className="copy-icon cp" + onClick={copy} + title="Copy ship name" + /> + </div> + <div className="buttons f1"> + <button onClick={() => navigate(`/feed/${userString}`)}>Feed</button> + <button onClick={() => navigate(`/pals/${userString}`)}> + Profile + </button> + {itsMe && ( + <> + <button onClick={() => navigate(`/chat/dm/${userString}`)}> + DM + </button> + </> + )} + </div> + </div> + </Modal> + ); +} diff --git a/gui/src/components/post/Body.tsx b/gui/src/components/post/Body.tsx new file mode 100644 index 0000000..b4f1bb2 --- /dev/null +++ b/gui/src/components/post/Body.tsx @@ -0,0 +1,174 @@ +import type { + // TODO ref backend fetching!! + Reference, + Block, + Inline, + Media as MediaType, + ExternalContent, +} from "@/types/trill"; +import Icon from "@/components/Icon"; +import type { PostProps } from "./Post"; +import Media from "./Media"; +import JSONContent, { YoutubeSnippet } from "./External"; +import { useLocation } from "wouter"; +import Quote from "./Quote"; +import PostData from "./Loader"; +import Card from "./Card.tsx"; +import type { Ship } from "@/types/urbit.ts"; + +function Body(props: PostProps) { + const text = props.poast.contents.filter((c) => { + return ( + "paragraph" in c || + "blockquote" in c || + "heading" in c || + "codeblock" in c || + "list" in c + ); + }); + + const media: MediaType[] = props.poast.contents.filter( + (c): c is MediaType => "media" in c, + ); + + const refs = props.poast.contents.filter((c): c is Reference => "ref" in c); + const json = props.poast.contents.filter( + (c): c is ExternalContent => "json" in c, + ); + + return ( + <div className="trill-post-body body"> + <div className="body-text"> + {text.map((b, i) => ( + <TextBlock key={JSON.stringify(b) + i} block={b} /> + ))} + </div> + {media.length > 0 && <Media media={media} />} + {refs.map((r, i) => ( + <Ref r={r} nest={props.nest || 0} key={JSON.stringify(r) + i} /> + ))} + <JSONContent content={json} /> + </div> + ); +} +export default Body; + +function TextBlock({ block }: { block: Block }) { + const key = JSON.stringify(block); + return "paragraph" in block ? ( + <div className="trill-post-paragraph"> + {block.paragraph.map((i, ind) => ( + <Inlin key={key + ind} i={i} /> + ))} + </div> + ) : "blockquote" in block ? ( + <blockquote> + {block.blockquote.map((i, ind) => ( + <Inlin key={key + ind} i={i} /> + ))} + </blockquote> + ) : "heading" in block ? ( + <Heading string={block.heading.text} num={block.heading.num} /> + ) : "codeblock" in block ? ( + <pre> + <code className={`language-${block.codeblock.lang}`}> + {block.codeblock.code} + </code> + </pre> + ) : "list" in block ? ( + block.list.ordered ? ( + <ol> + {block.list.text.map((i, ind) => ( + <li key={JSON.stringify(i) + ind}> + <Inlin key={key + ind} i={i} /> + </li> + ))} + </ol> + ) : ( + <ul> + {block.list.text.map((i, ind) => ( + <li key={JSON.stringify(i) + ind}> + <Inlin key={JSON.stringify(i) + ind} i={i} /> + </li> + ))} + </ul> + ) + ) : null; +} +function Inlin({ i }: { i: Inline }) { + const [_, navigate] = useLocation(); + function gotoShip(e: React.MouseEvent, ship: Ship) { + e.stopPropagation(); + navigate(`/feed/${ship}`); + } + return "text" in i ? ( + <span>{i.text}</span> + ) : "italic" in i ? ( + <i>{i.italic}</i> + ) : "bold" in i ? ( + <strong>{i.bold}</strong> + ) : "strike" in i ? ( + <span>{i.strike}</span> + ) : "underline" in i ? ( + <span>{i.underline}</span> + ) : "sup" in i ? ( + <sup>{i.sup}</sup> + ) : "sub" in i ? ( + <sub>{i.sub}</sub> + ) : "ship" in i ? ( + <span + className="mention" + role="link" + onMouseUp={(e) => gotoShip(e, i.ship)} + > + {i.ship} + </span> + ) : "codespan" in i ? ( + <code>{i.codespan}</code> + ) : "link" in i ? ( + <LinkParser {...i.link} /> + ) : "break" in i ? ( + <br /> + ) : null; +} + +function LinkParser({ href, show }: { href: string; show: string }) { + const YOUTUBE_REGEX_1 = /(youtube\.com\/watch\?v=)(\w+)/; + const YOUTUBE_REGEX_2 = /(youtu\.be\/)([a-zA-Z0-9-_]+)/; + const m1 = href.match(YOUTUBE_REGEX_1); + const m2 = href.match(YOUTUBE_REGEX_2); + const ytb = m1 && m1[2] ? m1[2] : m2 && m2[2] ? m2[2] : ""; + return ytb ? ( + <YoutubeSnippet href={href} id={ytb} /> + ) : ( + <a href={href}>{show}</a> + ); +} +function Heading({ string, num }: { string: string; num: number }) { + return num === 1 ? ( + <h1>{string}</h1> + ) : num === 2 ? ( + <h2>{string}</h2> + ) : num === 3 ? ( + <h3>{string}</h3> + ) : num === 4 ? ( + <h4>{string}</h4> + ) : num === 5 ? ( + <h5>{string}</h5> + ) : num === 6 ? ( + <h6>{string}</h6> + ) : null; +} + +function Ref({ r, nest }: { r: Reference; nest: number }) { + if (r.ref.type === "trill") { + const comp = PostData({ + host: r.ref.ship, + id: r.ref.path.slice(1), + nest: nest + 1, + className: "quote-in-post", + })(Quote); + return <Card logo="crow">{comp}</Card>; + } + return <></>; +} diff --git a/gui/src/components/post/Card.tsx b/gui/src/components/post/Card.tsx new file mode 100644 index 0000000..9309423 --- /dev/null +++ b/gui/src/components/post/Card.tsx @@ -0,0 +1,12 @@ +import Icon from "@/components/Icon"; +import type { IconName } from "@/components/Icon"; + +export default function ({ children, logo, cn}: { cn?: string; logo: IconName; children: any }) { + const className = "trill-post-card" + (cn ? ` ${cn}`: "") + return ( + <div className={className}> + <Icon name={logo} size={20} className="trill-post-card-logo" /> + {children} + </div> + ); +} diff --git a/gui/src/components/post/External.tsx b/gui/src/components/post/External.tsx new file mode 100644 index 0000000..d52aec7 --- /dev/null +++ b/gui/src/components/post/External.tsx @@ -0,0 +1,40 @@ +import type { ExternalContent } from "@/types/trill"; +import Card from "./Card"; + +interface JSONProps { + content: ExternalContent[]; +} + +function JSONContent({ content }: JSONProps) { + return ( + <> + {content.map((c, i) => { + if (!JSON.parse(c.json.content)) return <p key={i}>Error</p>; + else + return ( + <p + key={JSON.stringify(c.json)} + className="external-content-warning" + > + External content from "{c.json.origin}", use + <a href="https://urbit.org/applications/~sortug/ufa">UFA</a> + to display. + </p> + ); + })} + </> + ); +} +export default JSONContent; + +export function YoutubeSnippet({ href, id }: { href: string; id: string }) { + const thumbnail = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; + // todo styiling + return ( + <Card logo="youtube" cn="youtube-thumbnail"> + <a href={href}> + <img src={thumbnail} alt="" /> + </a> + </Card> + ); +} diff --git a/gui/src/components/post/Footer.tsx b/gui/src/components/post/Footer.tsx new file mode 100644 index 0000000..5b79da0 --- /dev/null +++ b/gui/src/components/post/Footer.tsx @@ -0,0 +1,260 @@ +import type { PostProps } from "./Post"; +import Icon from "@/components/Icon"; +import { useState } from "react"; +import useLocalState from "@/state/state"; +import { useLocation } from "wouter"; +import { displayCount } from "@/logic/utils"; +import { TrillReactModal, stringToReact } from "./Reactions"; +import toast from "react-hot-toast"; +import NostrIcon from "./wrappers/NostrIcon"; +// TODO abstract this somehow + +function Footer({ poast, refetch }: PostProps) { + const [_showMenu, setShowMenu] = useState(false); + const [location, navigate] = useLocation(); + const [reposting, _setReposting] = useState(false); + const { api, setComposerData, setModal, addNotification } = useLocalState( + (s) => ({ + api: s.api, + setComposerData: s.setComposerData, + setModal: s.setModal, + addNotification: s.addNotification, + }), + ); + const our = api!.airlock.our!; + function doReply(e: React.MouseEvent) { + console.log("do reply"); + e.stopPropagation(); + e.preventDefault(); + setComposerData({ type: "reply", post: { trill: poast } }); + // Scroll to top where composer is located + window.scrollTo({ top: 0, behavior: "smooth" }); + // Focus will be handled by the composer component + } + function doQuote(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + setComposerData({ + type: "quote", + post: { trill: poast }, + }); + // Scroll to top where composer is located + window.scrollTo({ top: 0, behavior: "smooth" }); + } + const childrenCount = poast.children + ? poast.children.length + ? poast.children.length + : Object.keys(poast.children).length + : 0; + const myRP = poast.engagement.shared.find((r) => r.pid.ship === our); + async function cancelRP(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + const r = await api!.deletePost(our); + if (r) toast.success("Repost deleted"); + refetch(); + if (location.includes(poast.id)) navigate("/"); + } + async function sendRP(e: React.MouseEvent) { + // TODO update backend because contents are only markdown now + e.stopPropagation(); + e.preventDefault(); + // const c = [ + // { + // ref: { + // type: "trill", + // ship: poast.host, + // path: `/${poast.id}`, + // }, + // }, + // ]; + // const post: SentPoast = { + // host: our, + // author: our, + // thread: null, + // parent: null, + // contents: input, + // read: openLock, + // write: openLock, + // tags: [], // TODO + // }; + // const r = await api!.addPost(post, false); + // setReposting(true); + // if (r) { + // setReposting(false); + // toast.success("Your post was published"); + // } + } + function doReact(e: React.MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + const modal = <TrillReactModal poast={poast} />; + setModal(modal); + } + function showReplyCount() { + if (poast.children[0]) fetchAndShow(); // Flatpoast + // else { + // const authors = Object.keys(poast.children).map( + // (i) => poast.children[i].post.author + // ); + // setEngagement({ type: "replies", ships: authors }, poast); + // } + } + async function fetchAndShow() { + // let authors = []; + // for (let i of poast.children as string[]) { + // const res = await scrypoastFull(poast.host, i); + // if (res) + // authors.push(res.post.author || "deleter"); + // } + // setEngagement({ type: "replies", ships: authors }, poast); + } + function showRepostCount() { + // const ships = poast.engagement.shared.map((entry) => entry.host); + // setEngagement({ type: "reposts", ships: ships }, poast); + } + function showQuoteCount() { + // setEngagement({ type: "quotes", quotes: poast.engagement.quoted }, poast); + } + function showReactCount() { + // setEngagement({ type: "reacts", reacts: poast.engagement.reacts }, poast); + } + + const mostCommonReact = Object.values(poast.engagement.reacts).reduce( + (acc: any, item) => { + if (!acc.counts[item]) acc.counts[item] = 0; + acc.counts[item] += 1; + if (!acc.winner || acc.counts[item] > acc.counts[acc.winner]) + acc.winner = item; + return acc; + }, + { counts: {}, winner: "" }, + ).winner; + const reactIcon = stringToReact(mostCommonReact); + + // TODO round up all helpers + + return ( + <div className="footer-wrapper post-footer"> + <footer> + <div className="icon"> + <span role="link" onMouseUp={showReplyCount} className="reply-count"> + {displayCount(childrenCount)} + </span> + <div className="icon-wrapper" role="link" onMouseUp={doReply}> + <Icon name="reply" size={20} /> + </div> + </div> + <div className="icon"> + <span role="link" onMouseUp={showQuoteCount} className="quote-count"> + {displayCount(poast.engagement.quoted.length)} + </span> + <div className="icon-wrapper" role="link" onMouseUp={doQuote}> + <Icon name="quote" size={20} /> + </div> + </div> + <div className="icon"> + <span + role="link" + onMouseUp={showRepostCount} + className="repost-count" + > + {displayCount(poast.engagement.shared.length)} + </span> + {reposting ? ( + <p>...</p> + ) : myRP ? ( + <div className="icon-wrapper" role="link" onMouseUp={cancelRP}> + <Icon + name="repost" + size={20} + className="my-rp" + title="cancel repost" + /> + </div> + ) : ( + <div className="icon-wrapper" role="link" onMouseUp={sendRP}> + <Icon name="repost" size={20} title="repost" /> + </div> + )} + </div> + <div className="icon" role="link" onMouseUp={doReact}> + <span + role="link" + onMouseUp={showReactCount} + className="reaction-count" + > + {displayCount(Object.keys(poast.engagement.reacts).length)} + </span> + {reactIcon} + </div> + <NostrIcon poast={poast} /> + </footer> + </div> + ); +} +export default Footer; + +// function Menu({ +// poast, +// setShowMenu, +// refetch, +// }: { +// poast: Poast; +// setShowMenu: Function; +// refetch: Function; +// }) { +// const ref = useRef<HTMLDivElement>(null); +// const [location, navigate] = useLocation(); +// // TODO this is a mess and the event still propagates +// useEffect(() => { +// const checkIfClickedOutside = (e: any) => { +// e.stopPropagation(); +// if (ref && ref.current && !ref.current.contains(e.target)) +// setShowMenu(false); +// }; +// document.addEventListener("mousedown", checkIfClickedOutside); +// return () => { +// document.removeEventListener("mousedown", checkIfClickedOutside); +// }; +// }, []); +// const { our, setModal, setAlert } = useLocalState(); +// const mine = our === poast.host || our === poast.author; +// async function doDelete(e: React.MouseEvent) { +// e.stopPropagation(); +// deletePost(poast.host, poast.id); +// setAlert("Post deleted"); +// setShowMenu(false); +// refetch(); +// if (location.includes(poast.id)) navigate("/"); +// } +// async function copyLink(e: React.MouseEvent) { +// e.stopPropagation(); +// const link = trillPermalink(poast); +// await navigator.clipboard.writeText(link); +// // some alert +// setShowMenu(false); +// } +// function openStats(e: React.MouseEvent) { +// e.stopPropagation(); +// e.preventDefault(); +// const m = <StatsModal poast={poast} close={() => setModal(null)} />; +// setModal(m); +// } +// return ( +// <div ref={ref} id="post-menu"> +// {/* <p onClick={openShare}>Share to Groups</p> */} +// <p role="link" onMouseUp={openStats}> +// See Stats +// </p> +// <p role="link" onMouseUp={copyLink}> +// Permalink +// </p> +// {mine && ( +// <p role="link" onMouseUp={doDelete}> +// Delete Post +// </p> +// )} +// </div> +// ); +// } diff --git a/gui/src/components/post/Header.tsx b/gui/src/components/post/Header.tsx new file mode 100644 index 0000000..4e72fe8 --- /dev/null +++ b/gui/src/components/post/Header.tsx @@ -0,0 +1,40 @@ +import { date_diff } from "@/logic/utils"; +import type { PostProps } from "./Post"; +import { useLocation } from "wouter"; +import useLocalState from "@/state/state"; +function Header(props: PostProps) { + const [_, navigate] = useLocation(); + const profiles = useLocalState((s) => s.profiles); + const profile = profiles.get(props.poast.author); + // console.log("profile", profile); + // console.log(props.poast.author.length, "length"); + function go(e: React.MouseEvent) { + e.stopPropagation(); + } + function openThread(e: React.MouseEvent) { + e.stopPropagation(); + const sel = window.getSelection()?.toString(); + if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + } + const { poast } = props; + const name = profile ? ( + profile.name + ) : ( + <div className="name cp"> + <p className="p-only">{poast.author}</p> + </div> + ); + return ( + <header> + <div className="author flex-align" role="link" onMouseUp={go}> + {name} + </div> + <div role="link" onMouseUp={openThread} className="date"> + <p title={new Date(poast.time).toLocaleString()}> + {date_diff(poast.time, "short")} + </p> + </div> + </header> + ); +} +export default Header; diff --git a/gui/src/components/post/Loader.tsx b/gui/src/components/post/Loader.tsx new file mode 100644 index 0000000..e45e01a --- /dev/null +++ b/gui/src/components/post/Loader.tsx @@ -0,0 +1,148 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import spinner from "@/assets/triangles.svg"; +import { useEffect, useRef, useState } from "react"; +import useLocalState from "@/state/state"; +import type { FullNode, PostID } from "@/types/trill"; +import type { Ship } from "@/types/urbit"; +import type { AsyncRes } from "@/types/ui"; +import { toFlat } from "@/logic/trill/helpers"; + +type Props = { + host: Ship; + id: PostID; + nest?: number; // nested quotes + rter?: Ship; + rtat?: number; + rtid?: PostID; + className?: string; +}; +function PostData(props: Props) { + const { api } = useLocalState((s) => ({ + api: s.api, + })); + + const { host, id, nest } = props; + + const [enest, setEnest] = useState(nest || 0); + useEffect(() => { + if (nest) setEnest(nest); + }, [nest]); + + return function (Component: React.ElementType) { + // const [showNested, setShowNested] = useState(nest <= 3); + const handleShowNested = (e: React.MouseEvent) => { + e.stopPropagation(); + setEnest(enest! - 3); + }; + const [dead, setDead] = useState(false); + const [denied, setDenied] = useState(false); + const { isLoading, isError, data, refetch } = useQuery({ + queryKey: ["trill-thread", host, id], + queryFn: fetchNode, + }); + const queryClient = useQueryClient(); + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + async function fetchNode(): AsyncRes<FullNode> { + let error = ""; + const res = await api!.scryThread(host, id); + console.log("scry res", res); + if ("error" in res) error = res.error; + if ("ok" in res) return res; + else { + const res2 = await api!.peekThread(host, id); + return res2; + } + } + async function peekTheNode() { + // let timer; + // peekNode({ ship: host, id }); + // timer = setTimeout(() => { + // const gotPost = dataRef.current && "fpost" in dataRef.current; + // setDead(!gotPost); + // // clearTimeout(timer); + // }, 10_000); + } + + // useEffect(() => { + // const path = `${host}/${id}`; + // if (path in peekedPosts) { + // queryClient.setQueryData(["trill-thread", host, id], { + // fpost: peekedPosts[path], + // }); + // } else if (path in deniedPosts) { + // setDenied(true); + // } + // }, [peekedPosts]); + // useEffect(() => { + // const path = `${host}/${id}`; + // if (path in deniedPosts) setDenied(true); + // }, [deniedPosts]); + + // useEffect(() => { + // const l = lastThread; + // if (l && l.thread == id) { + // queryClient.setQueryData(["trill-thread", host, id], { fpost: l }); + // } + // }, [lastThread]); + function retryPeek(e: React.MouseEvent) { + // e.stopPropagation(); + // setDead(false); + // peekTheNode(); + } + if (enest > 3) + return ( + <div className={props.className}> + <div className="lazy x-center not-found"> + <button className="x-center" onMouseUp={handleShowNested}> + Load more + </button> + </div> + </div> + ); + else + return data ? ( + dead ? ( + <div className={props.className}> + <div className="no-response x-center not-found"> + <p>{host} did not respond</p> + <button className="x-center" onMouseUp={retryPeek}> + Try again + </button> + </div> + </div> + ) : denied ? ( + <div className={props.className}> + <p className="x-center not-found"> + {host} denied you access to this post + </p> + </div> + ) : "error" in data ? ( + <div className={props.className}> + <p className="x-center not-found">Post not found</p> + <p className="x-center not-found">{data.error}</p> + </div> + ) : ( + <Component + data={toFlat(data.ok)} + refetch={refetch} + {...props} + nest={enest} + /> + ) + ) : // no data + isLoading || isError ? ( + <div className={props.className}> + <img className="x-center post-spinner" src={spinner} alt="" /> + </div> + ) : ( + <div className={props.className}> + <p>...</p> + </div> + ); + }; +} +export default PostData; diff --git a/gui/src/components/post/Media.tsx b/gui/src/components/post/Media.tsx new file mode 100644 index 0000000..04ea156 --- /dev/null +++ b/gui/src/components/post/Media.tsx @@ -0,0 +1,35 @@ +import type { Media } from "@/types/trill"; +interface Props { + media: Media[]; +} +function M({ media }: Props) { + return ( + <div className="body-media"> + {media.map((m, i) => { + return "video" in m.media ? ( + <video key={JSON.stringify(m) + i} src={m.media.video} controls /> + ) : "audio" in m.media ? ( + <audio key={JSON.stringify(m) + i} src={m.media.audio} controls /> + ) : "images" in m.media ? ( + <Images key={JSON.stringify(m) + i} urls={m.media.images} /> + ) : null; + })} + </div> + ); +} +export default M; + +function Images({ urls }: { urls: string[] }) { + return ( + <> + {urls.map((u, i) => ( + <img + key={u + i} + className={`body-img body-img-1-of-${urls.length}`} + src={u} + alt="" + /> + ))} + </> + ); +} diff --git a/gui/src/components/post/Post.tsx b/gui/src/components/post/Post.tsx new file mode 100644 index 0000000..2965040 --- /dev/null +++ b/gui/src/components/post/Post.tsx @@ -0,0 +1,85 @@ +import type { PostID, Poast, Reference } from "@/types/trill"; + +import Header from "./Header"; +import Body from "./Body"; +import Footer from "./Footer"; +import { useLocation } from "wouter"; +import useLocalState from "@/state/state"; +import RP from "./RP"; +import ShipModal from "../modals/ShipModal"; +import type { Ship } from "@/types/urbit"; +import Sigil from "../Sigil"; +import type { UserProfile } from "@/types/nostrill"; + +export interface PostProps { + poast: Poast; + fake?: boolean; + rter?: Ship; + rtat?: number; + rtid?: PostID; + nest?: number; + refetch?: Function; + profile?: UserProfile; +} +function Post(props: PostProps) { + console.log("post", props); + const { poast } = props; + if (!poast || poast.contents === null) { + return null; + } + const isRP = + poast.contents.length === 1 && + "ref" in poast.contents[0] && + poast.contents[0].ref.type === "trill"; + if (isRP) { + const ref = (poast.contents[0] as Reference).ref; + return ( + <RP + host={ref.ship} + id={ref.path.slice(1)} + rter={poast.author} + rtat={poast.time} + rtid={poast.id} + /> + ); + } else return <TrillPost {...props} />; +} +export default Post; + +function TrillPost(props: PostProps) { + const { poast, profile, fake } = props; + const setModal = useLocalState((s) => s.setModal); + const [_, navigate] = useLocation(); + function openThread(_e: React.MouseEvent) { + const sel = window.getSelection()?.toString(); + if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + } + + function openModal(e: React.MouseEvent) { + e.stopPropagation(); + setModal(<ShipModal ship={poast.author} />); + } + const avatar = profile ? ( + <div className="avatar cp" role="link" onMouseUp={openModal}> + <img src={profile.picture} /> + </div> + ) : ( + <div className="avatar sigil cp" role="link" onMouseUp={openModal}> + <Sigil patp={poast.author} size={46} /> + </div> + ); + return ( + <div + className={`timeline-post trill-post cp`} + role="link" + onMouseUp={openThread} + > + <div className="left">{avatar}</div> + <div className="right"> + <Header {...props} /> + <Body {...props} /> + {!fake && <Footer {...props} />} + </div> + </div> + ); +} diff --git a/gui/src/components/post/PostWrapper.tsx b/gui/src/components/post/PostWrapper.tsx new file mode 100644 index 0000000..c4e754f --- /dev/null +++ b/gui/src/components/post/PostWrapper.tsx @@ -0,0 +1,14 @@ +import useLocalState from "@/state/state"; +import type { NostrPost, PostWrapper } from "@/types/nostrill"; + +export default Post; +function Post(pw: PostWrapper) { + if ("nostr" in pw) return <NostrPost post={pw.nostr} />; + else return <TrillPost post={pw.urbit.post} nostr={pw.urbit.nostr} />; +} + +function NostrPost({ post, event, relay }: NostrPost) { + const { profiles } = useLocalState(); + const profile = profiles.get(event.pubkey); + return <></>; +} diff --git a/gui/src/components/post/Quote.tsx b/gui/src/components/post/Quote.tsx new file mode 100644 index 0000000..28149f0 --- /dev/null +++ b/gui/src/components/post/Quote.tsx @@ -0,0 +1,64 @@ +import type { FullNode, Poast } from "@/types/trill"; +import { date_diff } from "@/logic/utils"; +import { useLocation } from "wouter"; +import Body from "./Body"; +import Sigil from "../Sigil"; + +// function Quote({ +// data, +// refetch, +// nest, +// }: { +// data: FullNode; +// refetch?: Function; +// nest: number; +// }) { +// const [_, navigate] = useLocation(); +// function gotoQuote(e: React.MouseEvent) { +// e.stopPropagation(); +// navigate(`/feed/${data.host}/${data.id}`); +// } +// return ( +// <div onMouseUp={gotoQuote} className="quote-in-post"> +// <header className="btw"> +// ( +// <div className="quote-author flex"> +// <Sigil patp={data.author} size={20} /> +// {data.author} +// </div> +// )<span>{date_diff(data.time, "short")}</span> +// </header> +// <Body poast={toFlat(data)} nest={nest} refetch={refetch!} /> +// </div> +// ); +// } +function Quote({ + data, + refetch, + nest, +}: { + data: Poast; + refetch?: Function; + nest: number; +}) { + const [_, navigate] = useLocation(); + function gotoQuote(e: React.MouseEvent) { + e.stopPropagation(); + navigate(`/feed/${data.host}/${data.id}`); + } + return ( + <div onMouseUp={gotoQuote} className="quote-in-post"> + <header className="btw"> + ( + <div className="quote-author flex"> + <Sigil patp={data.author} size={20} /> + {data.author} + </div> + )<span>{date_diff(data.time, "short")}</span> + </header> + <Body poast={data} nest={nest} refetch={refetch!} /> + </div> + ); +} + +export default Quote; diff --git a/gui/src/components/post/RP.tsx b/gui/src/components/post/RP.tsx new file mode 100644 index 0000000..27fa02d --- /dev/null +++ b/gui/src/components/post/RP.tsx @@ -0,0 +1,47 @@ +import Post from "./Post"; +import type { Ship } from "@/types/urbit"; +import type { Poast, FullNode, ID } from "@/types/trill"; +import PostData from "./Loader"; +export default function (props: { + host: string; + id: string; + rter: Ship; + rtat: number; + rtid: ID; + refetch?: Function; +}) { + return PostData(props)(RP); +} + +function RP({ + data, + refetch, + rter, + rtat, + rtid, +}: { + data: FullNode; + refetch: Function; + rter: Ship; + rtat: number; + rtid: ID; +}) { + return ( + <Post + poast={toFlat(data)} + rter={rter} + rtat={rtat} + rtid={rtid} + refetch={refetch} + /> + ); +} + +export function toFlat(n: FullNode): Poast { + return { + ...n, + children: !n.children + ? [] + : Object.keys(n.children).map((c) => n.children[c].id), + }; +} diff --git a/gui/src/components/post/Reactions.tsx b/gui/src/components/post/Reactions.tsx new file mode 100644 index 0000000..ae75d8c --- /dev/null +++ b/gui/src/components/post/Reactions.tsx @@ -0,0 +1,134 @@ +import type { Poast } from "@/types/trill"; +import yeschad from "@/assets/reacts/yeschad.png"; +import cringe from "@/assets/reacts/cringe.png"; +import cry from "@/assets/reacts/cry.png"; +import doom from "@/assets/reacts/doom.png"; +import galaxy from "@/assets/reacts/galaxy.png"; +import gigachad from "@/assets/reacts/gigachad.png"; +import pepechin from "@/assets/reacts/pepechin.png"; +import pepeeyes from "@/assets/reacts/pepeeyes.png"; +import pepegmi from "@/assets/reacts/pepegmi.png"; +import pepesad from "@/assets/reacts/pepesad.png"; +import pink from "@/assets/reacts/pink.png"; +import soy from "@/assets/reacts/soy.png"; +import chad from "@/assets/reacts/chad.png"; +import pika from "@/assets/reacts/pika.png"; +import facepalm from "@/assets/reacts/facepalm.png"; +import Icon from "@/components/Icon"; +import emojis from "@/logic/emojis.json"; +import Modal from "../modals/Modal"; +import useLocalState from "@/state/state"; + +export function ReactModal({ send }: { send: (s: string) => Promise<number> }) { + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); + async function sendReact(e: React.MouseEvent, s: string) { + e.stopPropagation(); + const res = await send(s); + if (res) setModal(null); + } + // todo one more meme + return ( + <Modal> + <div id="react-list"> + <span onMouseUp={(e) => sendReact(e, "❤️")}>️️❤️</span> + <span onMouseUp={(e) => sendReact(e, "🤔")}>🤔</span> + <span onMouseUp={(e) => sendReact(e, "😅")}>😅</span> + <span onMouseUp={(e) => sendReact(e, "🤬")}>🤬</span> + <span onMouseUp={(e) => sendReact(e, "😂")}>😂️</span> + <span onMouseUp={(e) => sendReact(e, "🫡")}>🫡️</span> + <span onMouseUp={(e) => sendReact(e, "🤢")}>🤢</span> + <span onMouseUp={(e) => sendReact(e, "😭")}>😭</span> + <span onMouseUp={(e) => sendReact(e, "😱")}>😱</span> + <img + onMouseUp={(e) => sendReact(e, "facepalm")} + src={facepalm} + alt="" + /> + <span onMouseUp={(e) => sendReact(e, "👍")}>👍️</span> + <span onMouseUp={(e) => sendReact(e, "👎")}>👎️</span> + <span onMouseUp={(e) => sendReact(e, "☝")}>☝️</span> + <span onMouseUp={(e) => sendReact(e, "🤝")}>🤝</span>️ + <span onMouseUp={(e) => sendReact(e, "🙏")}>🙏</span> + <span onMouseUp={(e) => sendReact(e, "🤡")}>🤡</span> + <span onMouseUp={(e) => sendReact(e, "👀")}>👀</span> + <span onMouseUp={(e) => sendReact(e, "🎤")}>🎤</span> + <span onMouseUp={(e) => sendReact(e, "💯")}>💯</span> + <span onMouseUp={(e) => sendReact(e, "🔥")}>🔥</span> + <img onMouseUp={(e) => sendReact(e, "yeschad")} src={yeschad} alt="" /> + <img + onMouseUp={(e) => sendReact(e, "gigachad")} + src={gigachad} + alt="" + /> + <img onMouseUp={(e) => sendReact(e, "pika")} src={pika} alt="" /> + <img onMouseUp={(e) => sendReact(e, "cringe")} src={cringe} alt="" /> + <img onMouseUp={(e) => sendReact(e, "pepegmi")} src={pepegmi} alt="" /> + <img onMouseUp={(e) => sendReact(e, "pepesad")} src={pepesad} alt="" /> + <img onMouseUp={(e) => sendReact(e, "galaxy")} src={galaxy} alt="" /> + <img onMouseUp={(e) => sendReact(e, "pink")} src={pink} alt="" /> + <img onMouseUp={(e) => sendReact(e, "soy")} src={soy} alt="" /> + <img onMouseUp={(e) => sendReact(e, "cry")} src={cry} alt="" /> + <img onMouseUp={(e) => sendReact(e, "doom")} src={doom} alt="" /> + </div> + </Modal> + ); +} + +export function stringToReact(s: string) { + const em = (emojis as Record<string, string>)[s.replace(/\:/g, "")]; + if (s === "yeschad") + return <img className="react-img" src={yeschad} alt="" />; + if (s === "facepalm") + return <img className="react-img" src={facepalm} alt="" />; + if (s === "yes.jpg") + return <img className="react-img" src={yeschad} alt="" />; + if (s === "gigachad") + return <img className="react-img" src={gigachad} alt="" />; + if (s === "pepechin") + return <img className="react-img" src={pepechin} alt="" />; + if (s === "pepeeyes") + return <img className="react-img" src={pepeeyes} alt="" />; + if (s === "pepegmi") + return <img className="react-img" src={pepegmi} alt="" />; + if (s === "pepesad") + return <img className="react-img" src={pepesad} alt="" />; + if (s === "") + return <Icon name="emoji" size={20} className="react-img no-react" />; + if (s === "cringe") return <img className="react-img" src={cringe} alt="" />; + if (s === "cry") return <img className="react-img" src={cry} alt="" />; + if (s === "crywojak") return <img className="react-img" src={cry} alt="" />; + if (s === "doom") return <img className="react-img" src={doom} alt="" />; + if (s === "galaxy") return <img className="react-img" src={galaxy} alt="" />; + if (s === "pink") return <img className="react-img" src={pink} alt="" />; + if (s === "pinkwojak") return <img className="react-img" src={pink} alt="" />; + if (s === "soy") return <img className="react-img" src={soy} alt="" />; + if (s === "chad") return <img className="react-img" src={chad} alt="" />; + if (s === "pika") return <img className="react-img" src={pika} alt="" />; + if (em) return <span className="react-icon">{em}</span>; + else if (s.length > 2) return <span className="react-icon"></span>; + else return <span className="react-icon">{s}</span>; +} + +export function TrillReactModal({ poast }: { poast: Poast }) { + const { api, addNotification } = useLocalState((s) => ({ + api: s.api, + addNotification: s.addNotification, + })); + const our = api!.airlock.our!; + + async function sendReact(s: string) { + const result = await api!.addReact(poast.host, poast.id, s); + // Only add notification if reacting to someone else's post + if (result && poast.author !== our) { + addNotification({ + type: "react", + from: our, + message: `You reacted to ${poast.author}'s post`, + reaction: s, + postId: poast.id, + }); + } + return result; + } + return <ReactModal send={sendReact} />; +} diff --git a/gui/src/components/post/StatsModal.tsx b/gui/src/components/post/StatsModal.tsx new file mode 100644 index 0000000..4720b2a --- /dev/null +++ b/gui/src/components/post/StatsModal.tsx @@ -0,0 +1,106 @@ +import type { Poast } from "@/types/trill"; +import Modal from "../modals/Modal"; +import { useState } from "react"; +import Post from "./Post"; +import RP from "./RP"; +import Avatar from "../Avatar"; +import { stringToReact } from "./Reactions"; + +function StatsModal({ poast, close }: { close: any; poast: Poast }) { + const [tab, setTab] = useState("replies"); + const replies = poast.children || []; + const quotes = poast.engagement.quoted; + const reposts = poast.engagement.shared; + const reacts = poast.engagement.reacts; + function set(e: React.MouseEvent, s: string) { + e.stopPropagation(); + setTab(s); + } + // TODO revise the global thingy here + return ( + <Modal close={close}> + <div id="stats-modal"> + <Post poast={poast} refetch={() => {}} /> + <div id="tabs"> + <div + role="link" + className={"tab" + (tab === "replies" ? " active-tab" : "")} + onClick={(e) => set(e, "replies")} + > + <h4>Replies</h4> + </div> + <div + role="link" + className={"tab" + (tab === "quotes" ? " active-tab" : "")} + onClick={(e) => set(e, "quotes")} + > + <h4>Quotes</h4> + </div> + <div + role="link" + className={"tab" + (tab === "reposts" ? " active-tab" : "")} + onClick={(e) => set(e, "reposts")} + > + <h4>Reposts</h4> + </div> + <div + role="link" + className={"tab" + (tab === "reacts" ? " active-tab" : "")} + onClick={(e) => set(e, "reacts")} + > + <h4>Reacts</h4> + </div> + </div> + <div id="engagement"> + {tab === "replies" ? ( + <div id="replies"> + {replies.map((p) => ( + <div key={p} className="reply-stat"> + <RP + host={poast.host} + id={p} + rter={undefined} + rtat={undefined} + rtid={undefined} + /> + </div> + ))} + </div> + ) : tab === "quotes" ? ( + <div id="quotes"> + {quotes.map((p) => ( + <div key={p.pid.id} className="quote-stat"> + <RP + host={p.pid.ship} + id={p.pid.id} + rter={undefined} + rtat={undefined} + rtid={undefined} + /> + </div> + ))} + </div> + ) : tab === "reposts" ? ( + <div id="reposts"> + {reposts.map((p) => ( + <div key={p.pid.id} className="repost-stat"> + <Avatar p={p.pid.ship} size={40} /> + </div> + ))} + </div> + ) : tab === "reacts" ? ( + <div id="reacts"> + {Object.keys(reacts).map((p) => ( + <div key={p} className="react-stat btw"> + <Avatar p={p} size={32} /> + {stringToReact(reacts[p])} + </div> + ))} + </div> + ) : null} + </div> + </div> + </Modal> + ); +} +export default StatsModal; diff --git a/gui/src/components/post/wrappers/Nostr.tsx b/gui/src/components/post/wrappers/Nostr.tsx new file mode 100644 index 0000000..2782fb8 --- /dev/null +++ b/gui/src/components/post/wrappers/Nostr.tsx @@ -0,0 +1,15 @@ +import type { NostrMetadata, NostrPost } from "@/types/nostrill"; +import Post from "../Post"; +import useLocalState from "@/state/state"; + +export default NostrPost; +function NostrPost({ data }: { data: NostrPost }) { + const { profiles } = useLocalState((s) => ({ profiles: s.profiles })); + const profile = profiles.get(data.event.pubkey); + + return <Post poast={data.post} profile={profile} />; +} + +export function NostrSnippet({ eventId, pubkey, relay }: NostrMetadata) { + return <div>wtf</div>; +} diff --git a/gui/src/components/post/wrappers/NostrIcon.tsx b/gui/src/components/post/wrappers/NostrIcon.tsx new file mode 100644 index 0000000..30fbfe9 --- /dev/null +++ b/gui/src/components/post/wrappers/NostrIcon.tsx @@ -0,0 +1,25 @@ +import Icon from "@/components/Icon"; +import useLocalState from "@/state/state"; +import toast from "react-hot-toast"; +import type { Poast } from "@/types/trill"; +export default function ({ poast }: { poast: Poast }) { + const { relays, api } = useLocalState((s) => ({ + relays: s.relays, + api: s.api, + })); + + async function sendToRelay(e: React.MouseEvent) { + e.stopPropagation(); + // + const urls = Object.keys(relays); + await api!.relayPost(poast.host, poast.id, urls); + toast.success("Post relayed"); + } + // TODO round up all helpers + + return ( + <div className="icon" role="link" onMouseUp={sendToRelay}> + <Icon name="nostr" size={20} title="relay to nostr" /> + </div> + ); +} diff --git a/gui/src/components/profile/Editor.tsx b/gui/src/components/profile/Editor.tsx new file mode 100644 index 0000000..2e4aebc --- /dev/null +++ b/gui/src/components/profile/Editor.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; +import type { UserProfile, UserType } from "@/types/nostrill"; +import useLocalState from "@/state/state"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import Avatar from "../Avatar"; + +interface ProfileEditorProps { + user: UserType; + userString: string; + profile: UserProfile | undefined; + onSave?: () => void; +} + +const ProfileEditor: React.FC<ProfileEditorProps> = ({ + user, + profile, + userString, + onSave, +}) => { + const { api, profiles } = useLocalState((s) => ({ + api: s.api, + pubkey: s.pubkey, + profiles: s.profiles, + })); + + // Initialize state with existing profile or defaults + const [name, setName] = useState(profile?.name || userString); + const [picture, setPicture] = useState(profile?.picture || ""); + const [about, setAbout] = useState(profile?.about || ""); + const [customFields, setCustomFields] = useState< + Array<{ key: string; value: string }> + >( + Object.entries(profile?.other || {}).map(([key, value]) => ({ + key, + value, + })), + ); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const handleAddCustomField = () => { + setCustomFields([...customFields, { key: "", value: "" }]); + }; + + const handleUpdateCustomField = ( + index: number, + field: "key" | "value", + newValue: string, + ) => { + const updated = [...customFields]; + updated[index][field] = newValue; + setCustomFields(updated); + }; + + const handleRemoveCustomField = (index: number) => { + setCustomFields(customFields.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + // Convert custom fields array to object + const other: Record<string, string> = {}; + customFields.forEach(({ key, value }) => { + if (key.trim()) { + other[key.trim()] = value; + } + }); + + const nprofile: UserProfile = { + name, + picture, + about, + other, + }; + + // Call API to save profile + if (api && typeof api.createProfile === "function") { + await api.createProfile(nprofile); + } else { + throw new Error("Profile update API not available"); + } + + toast.success("Profile updated successfully"); + setIsEditing(false); + onSave?.(); + } catch (error) { + toast.error("Failed to update profile"); + console.error("Failed to save profile:", error); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + // Reset to original values + const profile = profiles.get(userString); + if (profile) { + setName(profile.name || userString); + setPicture(profile.picture || ""); + setAbout(profile.about || ""); + setCustomFields( + Object.entries(profile.other || {}).map(([key, value]) => ({ + key, + value, + })), + ); + } + setIsEditing(false); + }; + console.log({ profile }); + console.log({ name, picture, customFields }); + + return ( + <div className="profile-editor"> + <div className="profile-header"> + <h2>Edit Profile</h2> + {!isEditing && ( + <button onClick={() => setIsEditing(true)} className="edit-btn"> + <Icon name="settings" size={16} /> + Edit + </button> + )} + </div> + + {isEditing ? ( + <div className="profile-form"> + <div className="form-group"> + <label htmlFor="name">Display Name</label> + <input + id="name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Your display name" + /> + </div> + + <div className="form-group"> + <label htmlFor="picture">Profile Picture URL</label> + <input + id="picture" + type="url" + value={picture} + onChange={(e) => setPicture(e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> + <div className="picture-preview"> + {picture ? ( + <img src={picture} /> + ) : ( + <Avatar + user={user} + userString={userString} + profile={profile} + size={120} + picOnly={true} + /> + )} + </div> + </div> + + <div className="form-group"> + <label htmlFor="about">About</label> + <textarea + id="about" + value={about} + onChange={(e) => setAbout(e.target.value)} + placeholder="Tell us about yourself..." + rows={4} + /> + </div> + + <div className="form-group custom-fields"> + <label>Custom Fields</label> + {customFields.map((field, index) => ( + <div key={index} className="custom-field-row"> + <input + type="text" + value={field.key} + onChange={(e) => + handleUpdateCustomField(index, "key", e.target.value) + } + placeholder="Field name" + className="field-key-input" + /> + <input + type="text" + value={field.value} + onChange={(e) => + handleUpdateCustomField(index, "value", e.target.value) + } + placeholder="Field value" + className="field-value-input" + /> + <button + onClick={() => handleRemoveCustomField(index)} + className="remove-field-btn" + title="Remove field" + > + × + </button> + </div> + ))} + <button onClick={handleAddCustomField} className="add-field-btn"> + + Add Custom Field + </button> + </div> + + <div className="form-actions"> + <button + onClick={handleSave} + disabled={isSaving} + className="save-btn" + > + {isSaving ? "Saving..." : "Save Profile"} + </button> + <button + onClick={handleCancel} + disabled={isSaving} + className="cancel-btn" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="profile-view"> + <div className="profile-picture"> + <Avatar + user={user} + userString={userString} + profile={profile} + size={120} + picOnly={true} + /> + </div> + + <div className="profile-info"> + <h3>{name}</h3> + {about && <p className="profile-about">{about}</p>} + + {customFields.length > 0 && ( + <div className="profile-custom-fields"> + <h4>Additional Info</h4> + {customFields.map(({ key, value }, index) => ( + <div key={index} className="custom-field-view"> + <span className="field-key">{key}:</span> + <span className="field-value">{value}</span> + </div> + ))} + </div> + )} + </div> + </div> + )} + </div> + ); +}; + +export default ProfileEditor; diff --git a/gui/src/components/profile/Profile.tsx b/gui/src/components/profile/Profile.tsx new file mode 100644 index 0000000..b5f22e9 --- /dev/null +++ b/gui/src/components/profile/Profile.tsx @@ -0,0 +1,67 @@ +import "@/styles/Profile.css"; +import type { UserProfile, UserType } from "@/types/nostrill"; +import useLocalState from "@/state/state"; +import Avatar from "../Avatar"; +import ProfileEditor from "./Editor"; + +interface Props { + user: UserType; + userString: string; + isMe: boolean; + onSave?: () => void; +} + +const Loader: React.FC<Props> = (props) => { + const { profiles } = useLocalState((s) => ({ + profiles: s.profiles, + })); + const profile = profiles.get(props.userString); + + if (props.isMe) return <ProfileEditor {...props} profile={profile} />; + else return <Profile profile={profile} {...props} />; +}; +function Profile({ + user, + userString, + profile, +}: { + user: UserType; + userString: string; + profile: UserProfile | undefined; +}) { + // Initialize state with existing profile or defaults + + // View-only mode for other users' profiles - no editing allowed + const customFields = profile?.other ? Object.entries(profile.other) : []; + return ( + <div className="profile view-mode"> + <div className="profile-picture"> + <Avatar + user={user} + userString={userString} + size={120} + picOnly={true} + profile={profile} + /> + </div> + <div className="profile-info"> + <h2>{profile?.name || userString}</h2> + {profile?.about && <p className="profile-about">{profile.about}</p>} + + {customFields.length > 0 && ( + <div className="profile-custom-fields"> + <h4>Additional Info</h4> + {customFields.map(([key, value], index) => ( + <div key={index} className="custom-field-view"> + <span className="field-key">{key}:</span> + <span className="field-value">{value}</span> + </div> + ))} + </div> + )} + </div> + </div> + ); +} + +export default Loader; diff --git a/gui/src/hooks/useWs.tsx b/gui/src/hooks/useWs.tsx new file mode 100644 index 0000000..b11c0aa --- /dev/null +++ b/gui/src/hooks/useWs.tsx @@ -0,0 +1,326 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +// --- Hook: useWebSocket ------------------------------------------------------ +// Handles: connect, open, message, error, close, reconnect (exp backoff + jitter), +// heartbeat, offline/online, tab visibility, send queue, clean teardown. + +export type WsStatus = "idle" | "connecting" | "open" | "closing" | "closed"; + +export interface UseWebSocketOptions { + url: string; + protocols?: string | string[]; + autoReconnect?: boolean; // default: true + maxRetries?: number; // default: Infinity + backoffInitialMs?: number; // default: 500 + backoffMaxMs?: number; // default: 10_000 + heartbeatIntervalMs?: number; // default: 25_000 (typical ALB/NGINX timeouts ~60s) + heartbeatMessage?: + | string + | ArrayBuffer + | Blob + | (() => string | ArrayBuffer | Blob); + // If provided, decides whether to reconnect on close (e.g., avoid on 1000 normal close) + shouldReconnectOnClose?: (ev: CloseEvent) => boolean; + // Optional passive listeners + onOpen?: (ev: Event) => void; + onMessage?: (ev: MessageEvent) => void; + onError?: (ev: Event) => void; + onClose?: (ev: CloseEvent) => void; +} + +export interface UseWebSocketApi { + status: WsStatus; + retryCount: number; + error: Event | CloseEvent | null; + bufferedAmount: number; // bytes currently queued in the socket buffer + lastMessage: MessageEvent | null; + // Sends immediately if OPEN, otherwise enqueues to flush on open + send: (data: string | ArrayBuffer | Blob) => boolean; // returns true if sent now + // attempt an immediate reconnect (resets backoff) + reconnectNow: () => void; + // graceful close (optionally with code & reason) + close: (code?: number, reason?: string) => void; +} + +function jitter(ms: number) { + const spread = ms * 0.2; // ±20% + return ms + (Math.random() * 2 - 1) * spread; +} + +export function useWebSocket(opts: UseWebSocketOptions): UseWebSocketApi { + const { + url, + protocols, + autoReconnect = true, + maxRetries = Number.POSITIVE_INFINITY, + backoffInitialMs = 500, + backoffMaxMs = 10_000, + heartbeatIntervalMs = 25_000, + heartbeatMessage = () => (typeof window !== "undefined" ? "ping" : "ping"), + shouldReconnectOnClose = (ev) => + ev.code !== 1000 && ev.code !== 1001 && ev.code !== 1005, + onOpen, + onMessage, + onError, + onClose, + } = opts; + + const wsRef = useRef<WebSocket | null>(null); + const heartbeatTimer = useRef<number | null>(null); + const reconnectTimer = useRef<number | null>(null); + const pendingQueueRef = useRef<(string | ArrayBuffer | Blob)[]>([]); + const retryCountRef = useRef(0); + const manualCloseRef = useRef(false); // track if close() was user-intended + + const [status, setStatus] = useState<WsStatus>("idle"); + const [retryCount, setRetryCount] = useState(0); + const [error, setError] = useState<Event | CloseEvent | null>(null); + const [lastMessage, setLastMessage] = useState<MessageEvent | null>(null); + const [bufferedAmount, setBufferedAmount] = useState(0); + + // --- Internal helpers ------------------------------------------------------ + const clearHeartbeat = () => { + if (heartbeatTimer.current) { + window.clearInterval(heartbeatTimer.current); + heartbeatTimer.current = null; + } + }; + + const clearReconnect = () => { + if (reconnectTimer.current) { + window.clearTimeout(reconnectTimer.current); + reconnectTimer.current = null; + } + }; + + const flushQueue = () => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const q = pendingQueueRef.current; + while (q.length) { + const item = q.shift()!; + ws.send(item); + } + setBufferedAmount(ws.bufferedAmount); + }; + + const scheduleReconnect = (_dueTo: "close" | "error") => { + if (!autoReconnect) return; + if (manualCloseRef.current) return; // user requested close -> do not reconnect + if (retryCountRef.current >= maxRetries) return; + + const attempt = retryCountRef.current + 1; + const backoff = Math.min( + backoffMaxMs, + backoffInitialMs * Math.pow(2, attempt - 1), + ); + const delay = Math.max(250, jitter(backoff)); + + clearReconnect(); + reconnectTimer.current = window.setTimeout(() => { + connect(); + }, delay); + }; + + const startHeartbeat = () => { + clearHeartbeat(); + if (!heartbeatIntervalMs) return; + heartbeatTimer.current = window.setInterval(() => { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + try { + const msg = + typeof heartbeatMessage === "function" + ? heartbeatMessage() + : heartbeatMessage; + ws.send(msg); + } catch {} + }, heartbeatIntervalMs); + }; + + const bindSocketEvents = (ws: WebSocket) => { + ws.addEventListener("open", (ev) => { + setStatus("open"); + setError(null); + retryCountRef.current = 0; + setRetryCount(0); + startHeartbeat(); + flushQueue(); + onOpen?.(ev); + }); + + ws.addEventListener("message", (ev) => { + setLastMessage(ev); + setBufferedAmount(ws.bufferedAmount); + onMessage?.(ev); + }); + + ws.addEventListener("error", (ev) => { + setError(ev); + setStatus(ws.readyState === WebSocket.CLOSED ? "closed" : "connecting"); + onError?.(ev); + scheduleReconnect("error"); + }); + + ws.addEventListener("close", (ev) => { + setStatus("closed"); + setError(ev); + clearHeartbeat(); + onClose?.(ev); + if (!manualCloseRef.current && shouldReconnectOnClose(ev)) { + retryCountRef.current += 1; + setRetryCount(retryCountRef.current); + scheduleReconnect("close"); + } + }); + }; + + const connect = useCallback(() => { + try { + if ( + wsRef.current && + (wsRef.current.readyState === WebSocket.OPEN || + wsRef.current.readyState === WebSocket.CONNECTING) + ) { + return; // already connected/connecting + } + manualCloseRef.current = false; + setStatus("connecting"); + const ws = new WebSocket(url, protocols); + wsRef.current = ws; + bindSocketEvents(ws); + } catch (e) { + setError(e as Event); + scheduleReconnect("error"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [url, JSON.stringify(protocols)]); + + // Maintain connection on mount & url changes + useEffect(() => { + connect(); + return () => { + // teardown + clearReconnect(); + clearHeartbeat(); + const ws = wsRef.current; + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + try { + ws.close(1000, "component unmount"); + } catch {} + } + wsRef.current = null; + }; + }, [connect]); + + // Reconnect when browser regains connectivity + useEffect(() => { + const onOnline = () => { + if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) + connect(); + }; + const onOffline = () => { + // proactively close to reset state; will reconnect when back online + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + try { + ws.close(1011, "offline"); + } catch {} + } + }; + window.addEventListener("online", onOnline); + window.addEventListener("offline", onOffline); + return () => { + window.removeEventListener("online", onOnline); + window.removeEventListener("offline", onOffline); + }; + }, [connect]); + + // Reconnect when tab becomes visible (helps with long-sleeped mobile tabs) + useEffect(() => { + const handler = () => { + if (!document.hidden) { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) connect(); + } + }; + document.addEventListener("visibilitychange", handler); + return () => document.removeEventListener("visibilitychange", handler); + }, [connect]); + + const send: UseWebSocketApi["send"] = useCallback((data) => { + const ws = wsRef.current; + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(data); + setBufferedAmount(ws.bufferedAmount); + return true; + } + pendingQueueRef.current.push(data); + return false; + }, []); + + const reconnectNow = useCallback(() => { + retryCountRef.current = 0; + setRetryCount(0); + clearReconnect(); + const ws = wsRef.current; + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + try { + ws.close(1012, "manual reconnect"); + } catch {} + } else { + connect(); + } + }, [connect]); + + const close: UseWebSocketApi["close"] = useCallback( + (code = 1000, reason = "client close") => { + manualCloseRef.current = true; + clearReconnect(); + clearHeartbeat(); + const ws = wsRef.current; + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + try { + ws.close(code, reason); + } catch {} + setStatus("closing"); + } + }, + [], + ); + + return useMemo( + () => ({ + status, + retryCount, + error, + bufferedAmount, + lastMessage, + send, + reconnectNow, + close, + }), + [ + status, + retryCount, + error, + bufferedAmount, + lastMessage, + send, + reconnectNow, + close, + ], + ); +} diff --git a/gui/src/logic/api.ts b/gui/src/logic/api.ts new file mode 100644 index 0000000..124be56 --- /dev/null +++ b/gui/src/logic/api.ts @@ -0,0 +1,15 @@ +import Urbit from "urbit-api"; + +export const URL = import.meta.env.PROD ? "" : "http://localhost:8090"; + +export async function start(): Promise<Urbit> { + const airlock = new Urbit(URL, ""); + const res = await fetch(URL + "/~/host"); + const ship = await res.text(); + airlock.ship = ship.slice(1); + airlock.our = ship; + airlock.desk = "nostrill"; + await airlock.poke({ app: "hood", mark: "helm-hi", json: "opening airlock" }); + await airlock.eventSource(); + return airlock; +} diff --git a/gui/src/logic/bunts.ts b/gui/src/logic/bunts.ts new file mode 100644 index 0000000..dfa70e3 --- /dev/null +++ b/gui/src/logic/bunts.ts @@ -0,0 +1,51 @@ +import type { Engagement, List, Lock } from "@/types/trill"; + +export const openLock: Lock = { + rank: { caveats: [], locked: false, public: true }, + luk: { caveats: [], locked: false, public: true }, + ship: { caveats: [], locked: false, public: true }, + tags: { caveats: [], locked: false, public: true }, + custom: { fn: null, public: false }, +}; + +export const engagementBunt: Engagement = { + reacts: {}, + quoted: [], + shared: [], +}; + +export const pushStateBunt = { + followers: [], + gate: { + lock: openLock, + mute: openLock, + begs: [], + "post-begs": [], + backlog: 0, + }, +}; + +export const harkStateBunt = { + unread: {}, + engagement: [], +}; + +export const pullStateBunt = { + following: [], + begs: [], + "post-begs": [], +}; +export const listBunt: List = { + symbol: "", + name: "", + desc: "", + icon: "", + cover: "", + members: [], + public: true, +}; + +// export const palsBunt: Pals = { +// incoming: {}, +// outgoing: {} +// } diff --git a/gui/src/logic/constants.ts b/gui/src/logic/constants.ts new file mode 100644 index 0000000..fcf5573 --- /dev/null +++ b/gui/src/logic/constants.ts @@ -0,0 +1,36 @@ +import type { Poast } from "@/types/trill"; + +export const versionNum = "0.1.0"; +export const TIMEOUT = 15_000; + +export const ChatPostCount = 50; +export const FeedPostCount = 50; +export const RumorShip = "~londev-dozzod-sortug"; +export const RumorShip2 = "~paldev"; + +export function isRumor(poast: Poast) { + return poast.author === RumorShip || poast.author === RumorShip2; +} + +export const MOBILE_BROWSER_REGEX = + /Android|webOS|iPhone|iPad|iPod|BlackBerry/i; +export const AUDIO_REGEX = new RegExp(/https:\/\/.+\.(mp3|wav|ogg)\b/gim); +export const VIDEO_REGEX = new RegExp(/https:\/\/.+\.(mov|mp4|ogv)\b/gim); +export const TWITTER_REGEX = new RegExp( + /https:\/\/(twitter|x)\.com\/.+\/status\/\d+/gim, +); + +export const REF_REGEX = new RegExp( + /urbit:\/\/[a-z0-9-]+\/~[a-z-_]+\/[a-z0-9-_]+/gim, +); +export const RADIO_REGEX = new RegExp(/urbit:\/\/radio\/~[a-z-_]+/gim); + +export const IMAGE_REGEX = new RegExp( + /https:\/\/.+\.(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)\b/gim, +); + +export const SHIP_REGEX = new RegExp(/\B~[a-z-]+/); +export const HASHTAGS_REGEX = new RegExp(/#[a-z-]+/g); + +export const DEFAULT_DATE = { year: 1970, month: 1, day: 1 }; +export const RADIO = "📻"; diff --git a/gui/src/logic/emojis.json b/gui/src/logic/emojis.json new file mode 100644 index 0000000..599f707 --- /dev/null +++ b/gui/src/logic/emojis.json @@ -0,0 +1,3613 @@ +{ + "100": "💯", + "1000": "1000", + "1234": "🔢", + "white_heavy_check_mark": "✅", + "done": "✅", + "pants": "👖", + "squirrel": "🐿️", + "partyparrot": "🦜", + "party-parrot": "🦜", + "60fps_parrot": "🦜", + "charmander": "🔥", + "java": "☕", + "slack": "S", + "discord": "D", + "github": "G", + "jira": "J", + "shame": "😳", + "airbyte-100": "💯", + "airbyte-fire": "🔥", + "firee": "🔥", + "meow_party": "🎉", + "meowparty": "🎉", + "partyblob": "🎉", + "party-blob": "🎉", + "party_blob": "🎉", + "octavia-hmm": "🤔", + "octavia-hello": "👋", + "ory_love": "❤️", + "grinning": "😀", + "smiley": "😃", + "smile": "😄", + "grin": "😁", + "laughing": "😆", + "satisfied": "😆", + "sweat_smile": "😅", + "rolling_on_the_floor_laughing": "🤣", + "joy": "😂", + "slightly_smiling_face": "🙂", + "upside_down_face": "🙃", + "melting_face": "🫠", + "wink": "😉", + "blush": "😊", + "innocent": "😇", + "smiling_face_with_3_hearts": "🥰", + "heart_eyes": "😍", + "star-struck": "🤩", + "grinning_face_with_star_eyes": "🤩", + "kissing_heart": "😘", + "kissing": "😗", + "relaxed": "☺️", + "kissing_closed_eyes": "😚", + "kissing_smiling_eyes": "😙", + "smiling_face_with_tear": "🥲", + "yum": "😋", + "stuck_out_tongue": "😛", + "stuck_out_tongue_winking_eye": "😜", + "zany_face": "🤪", + "grinning_face_with_one_large_and_one_small_eye": "🤪", + "stuck_out_tongue_closed_eyes": "😝", + "money_mouth_face": "🤑", + "hugging_face": "🤗", + "face_with_hand_over_mouth": "🤭", + "smiling_face_with_smiling_eyes_and_hand_covering_mouth": "🤭", + "face_with_open_eyes_and_hand_over_mouth": "🫢", + "face_with_peeking_eye": "🫣", + "shushing_face": "🤫", + "face_with_finger_covering_closed_lips": "🤫", + "thinking_face": "🤔", + "saluting_face": "🫡", + "zipper_mouth_face": "🤐", + "face_with_raised_eyebrow": "🤨", + "face_with_one_eyebrow_raised": "🤨", + "neutral_face": "😐", + "expressionless": "😑", + "no_mouth": "😶", + "dotted_line_face": "🫥", + "face_in_clouds": "😶🌫️", + "smirk": "😏", + "unamused": "😒", + "face_with_rolling_eyes": "🙄", + "grimacing": "😬", + "face_exhaling": "😮💨", + "lying_face": "🤥", + "relieved": "😌", + "pensive": "😔", + "sleepy": "😪", + "drooling_face": "🤤", + "sleeping": "😴", + "mask": "😷", + "face_with_thermometer": "🤒", + "face_with_head_bandage": "🤕", + "nauseated_face": "🤢", + "face_vomiting": "🤮", + "face_with_open_mouth_vomiting": "🤮", + "sneezing_face": "🤧", + "hot_face": "🥵", + "cold_face": "🥶", + "woozy_face": "🥴", + "dizzy_face": "😵", + "face_with_spiral_eyes": "😵💫", + "exploding_head": "🤯", + "wow_fb": "🤯", + "shocked_face_with_exploding_head": "🤯", + "face_with_cowboy_hat": "🤠", + "partying_face": "🥳", + "disguised_face": "🥸", + "sunglasses": "😎", + "nerd_face": "🤓", + "face_with_monocle": "🧐", + "confused": "😕", + "face_with_diagonal_mouth": "🫤", + "worried": "😟", + "slightly_frowning_face": "🙁", + "white_frowning_face": "☹️", + "open_mouth": "😮", + "hushed": "😯", + "astonished": "😲", + "flushed": "😳", + "pleading_face": "🥺", + "face_holding_back_tears": "🥹", + "frowning": "😦", + "anguished": "😧", + "fearful": "😨", + "cold_sweat": "😰", + "disappointed_relieved": "😥", + "cry": "😢", + "sob": "😭", + "scream": "😱", + "confounded": "😖", + "persevere": "😣", + "disappointed": "😞", + "sweat": "😓", + "weary": "😩", + "tired_face": "😫", + "yawning_face": "🥱", + "triumph": "😤", + "rage": "😡", + "jakesidsmithmadness": "😡", + "angry": "😠", + "face_with_symbols_on_mouth": "🤬", + "serious_face_with_symbols_covering_mouth": "🤬", + "smiling_imp": "😈", + "imp": "👿", + "skull": "💀", + "skull_and_crossbones": "☠️", + "hankey": "💩", + "shit": "💩", + "clown_face": "🤡", + "japanese_ogre": "👹", + "japanese_goblin": "👺", + "ghost": "👻", + "alien": "👽", + "space_invader": "👾", + "robot_face": "🤖", + "android": "🤖", + "smiley_cat": "😺", + "smile_cat": "😸", + "joy_cat": "😹", + "heart_eyes_cat": "😻", + "smirk_cat": "😼", + "kissing_cat": "😽", + "scream_cat": "🙀", + "crying_cat_face": "😿", + "pouting_cat": "😾", + "see_no_evil": "🙈", + "hear_no_evil": "🙉", + "speak_no_evil": "🙊", + "kiss": "💋", + "love_letter": "💌", + "cupid": "💘", + "gift_heart": "💝", + "sparkling_heart": "💖", + "heartpulse": "💗", + "heartbeat": "💓", + "revolving_hearts": "💞", + "two_hearts": "💕", + "heart_decoration": "💟", + "heavy_heart_exclamation_mark_ornament": "❣️", + "broken_heart": "💔", + "heart_on_fire": "❤️🔥", + "mending_heart": "❤️🩹", + "heart": "❤️", + "kodee_love": "❤️", + "kodee-love": "❤️", + "orange_heart": "🧡", + "yellow_heart": "💛", + "green_heart": "💚", + "blue_heart": "💙", + "purple_heart": "💜", + "brown_heart": "🤎", + "black_heart": "🖤", + "white_heart": "🤍", + "anger": "💢", + "boom": "💥", + "collision": "💥", + "dizzy": "💫", + "sweat_drops": "💦", + "dash": "💨", + "hole": "🕳️", + "bomb": "💣", + "speech_balloon": "💬", + "eye-in-speech-bubble": "👁️🗨️", + "left_speech_bubble": "🗨️", + "right_anger_bubble": "🗯️", + "thought_balloon": "💭", + "zzz": "💤", + "wave_animated": "👋", + "wave": "👋", + "wave::skin-tone-2": "👋🏻", + "wave::skin-tone-3": "👋🏼", + "wave::skin-tone-4": "👋🏽", + "wave::skin-tone-5": "👋🏾", + "wave::skin-tone-6": "👋🏿", + "raised_back_of_hand": "🤚", + "raised_back_of_hand::skin-tone-2": "🤚🏻", + "raised_back_of_hand::skin-tone-3": "🤚🏼", + "raised_back_of_hand::skin-tone-4": "🤚🏽", + "raised_back_of_hand::skin-tone-5": "🤚🏾", + "raised_back_of_hand::skin-tone-6": "🤚🏿", + "raised_hand_with_fingers_splayed": "🖐️", + "raised_hand_with_fingers_splayed::skin-tone-2": "🖐🏻", + "raised_hand_with_fingers_splayed::skin-tone-3": "🖐🏼", + "raised_hand_with_fingers_splayed::skin-tone-4": "🖐🏽", + "raised_hand_with_fingers_splayed::skin-tone-5": "🖐🏾", + "raised_hand_with_fingers_splayed::skin-tone-6": "🖐🏿", + "hand": "✋", + "raised_hand": "✋", + "hand::skin-tone-2": "✋🏻", + "raised_hand::skin-tone-2": "✋🏻", + "hand::skin-tone-3": "✋🏼", + "raised_hand::skin-tone-3": "✋🏼", + "hand::skin-tone-4": "✋🏽", + "raised_hand::skin-tone-4": "✋🏽", + "hand::skin-tone-5": "✋🏾", + "raised_hand::skin-tone-5": "✋🏾", + "hand::skin-tone-6": "✋🏿", + "raised_hand::skin-tone-6": "✋🏿", + "spock-hand": "🖖", + "spock-hand::skin-tone-2": "🖖🏻", + "spock-hand::skin-tone-3": "🖖🏼", + "spock-hand::skin-tone-4": "🖖🏽", + "spock-hand::skin-tone-5": "🖖🏾", + "spock-hand::skin-tone-6": "🖖🏿", + "rightwards_hand": "🫱", + "rightwards_hand::skin-tone-2": "🫱🏻", + "rightwards_hand::skin-tone-3": "🫱🏼", + "rightwards_hand::skin-tone-4": "🫱🏽", + "rightwards_hand::skin-tone-5": "🫱🏾", + "rightwards_hand::skin-tone-6": "🫱🏿", + "leftwards_hand": "🫲", + "leftwards_hand::skin-tone-2": "🫲🏻", + "leftwards_hand::skin-tone-3": "🫲🏼", + "leftwards_hand::skin-tone-4": "🫲🏽", + "leftwards_hand::skin-tone-5": "🫲🏾", + "leftwards_hand::skin-tone-6": "🫲🏿", + "palm_down_hand": "🫳", + "palm_down_hand::skin-tone-2": "🫳🏻", + "palm_down_hand::skin-tone-3": "🫳🏼", + "palm_down_hand::skin-tone-4": "🫳🏽", + "palm_down_hand::skin-tone-5": "🫳🏾", + "palm_down_hand::skin-tone-6": "🫳🏿", + "palm_up_hand": "🫴", + "palm_up_hand::skin-tone-2": "🫴🏻", + "palm_up_hand::skin-tone-3": "🫴🏼", + "palm_up_hand::skin-tone-4": "🫴🏽", + "palm_up_hand::skin-tone-5": "🫴🏾", + "palm_up_hand::skin-tone-6": "🫴🏿", + "ok_hand": "👌", + "ok_hand::skin-tone-2": "👌🏻", + "ok_hand::skin-tone-3": "👌🏼", + "ok_hand::skin-tone-4": "👌🏽", + "ok_hand::skin-tone-5": "👌🏾", + "ok_hand::skin-tone-6": "👌🏿", + "nice": "👌", + "nice::skin-tone-2": "👌🏻", + "nice::skin-tone-3": "👌🏼", + "nice::skin-tone-4": "👌🏽", + "nice::skin-tone-5": "👌🏾", + "nice::skin-tone-6": "👌🏿", + "pinched_fingers": "🤌", + "pinched_fingers::skin-tone-2": "🤌🏻", + "pinched_fingers::skin-tone-3": "🤌🏼", + "pinched_fingers::skin-tone-4": "🤌🏽", + "pinched_fingers::skin-tone-5": "🤌🏾", + "pinched_fingers::skin-tone-6": "🤌🏿", + "pinching_hand": "🤏", + "pinching_hand::skin-tone-2": "🤏🏻", + "pinching_hand::skin-tone-3": "🤏🏼", + "pinching_hand::skin-tone-4": "🤏🏽", + "pinching_hand::skin-tone-5": "🤏🏾", + "pinching_hand::skin-tone-6": "🤏🏿", + "v": "✌️", + "v::skin-tone-2": "✌🏻", + "v::skin-tone-3": "✌🏼", + "v::skin-tone-4": "✌🏽", + "v::skin-tone-5": "✌🏾", + "v::skin-tone-6": "✌🏿", + "crossed_fingers": "🤞", + "hand_with_index_and_middle_fingers_crossed": "🤞", + "crossed_fingers::skin-tone-2": "🤞🏻", + "hand_with_index_and_middle_fingers_crossed::skin-tone-2": "🤞🏻", + "crossed_fingers::skin-tone-3": "🤞🏼", + "hand_with_index_and_middle_fingers_crossed::skin-tone-3": "🤞🏼", + "crossed_fingers::skin-tone-4": "🤞🏽", + "hand_with_index_and_middle_fingers_crossed::skin-tone-4": "🤞🏽", + "crossed_fingers::skin-tone-5": "🤞🏾", + "hand_with_index_and_middle_fingers_crossed::skin-tone-5": "🤞🏾", + "crossed_fingers::skin-tone-6": "🤞🏿", + "hand_with_index_and_middle_fingers_crossed::skin-tone-6": "🤞🏿", + "hand_with_index_finger_and_thumb_crossed": "🫰", + "hand_with_index_finger_and_thumb_crossed::skin-tone-2": "🫰🏻", + "hand_with_index_finger_and_thumb_crossed::skin-tone-3": "🫰🏼", + "hand_with_index_finger_and_thumb_crossed::skin-tone-4": "🫰🏽", + "hand_with_index_finger_and_thumb_crossed::skin-tone-5": "🫰🏾", + "hand_with_index_finger_and_thumb_crossed::skin-tone-6": "🫰🏿", + "i_love_you_hand_sign": "🤟", + "i_love_you_hand_sign::skin-tone-2": "🤟🏻", + "i_love_you_hand_sign::skin-tone-3": "🤟🏼", + "i_love_you_hand_sign::skin-tone-4": "🤟🏽", + "i_love_you_hand_sign::skin-tone-5": "🤟🏾", + "i_love_you_hand_sign::skin-tone-6": "🤟🏿", + "the_horns": "🤘", + "sign_of_the_horns": "🤘", + "the_horns::skin-tone-2": "🤘🏻", + "sign_of_the_horns::skin-tone-2": "🤘🏻", + "the_horns::skin-tone-3": "🤘🏼", + "sign_of_the_horns::skin-tone-3": "🤘🏼", + "the_horns::skin-tone-4": "🤘🏽", + "sign_of_the_horns::skin-tone-4": "🤘🏽", + "the_horns::skin-tone-5": "🤘🏾", + "sign_of_the_horns::skin-tone-5": "🤘🏾", + "the_horns::skin-tone-6": "🤘🏿", + "sign_of_the_horns::skin-tone-6": "🤘🏿", + "call_me_hand": "🤙", + "call_me_hand::skin-tone-2": "🤙🏻", + "call_me_hand::skin-tone-3": "🤙🏼", + "call_me_hand::skin-tone-4": "🤙🏽", + "call_me_hand::skin-tone-5": "🤙🏾", + "call_me_hand::skin-tone-6": "🤙🏿", + "point_left": "👈", + "point_left::skin-tone-2": "👈🏻", + "point_left::skin-tone-3": "👈🏼", + "point_left::skin-tone-4": "👈🏽", + "point_left::skin-tone-5": "👈🏾", + "point_left::skin-tone-6": "👈🏿", + "point_right": "👉", + "point_right::skin-tone-2": "👉🏻", + "point_right::skin-tone-3": "👉🏼", + "point_right::skin-tone-4": "👉🏽", + "point_right::skin-tone-5": "👉🏾", + "point_right::skin-tone-6": "👉🏿", + "point_up_2": "👆", + "point_up_2::skin-tone-2": "👆🏻", + "point_up_2::skin-tone-3": "👆🏼", + "point_up_2::skin-tone-4": "👆🏽", + "point_up_2::skin-tone-5": "👆🏾", + "point_up_2::skin-tone-6": "👆🏿", + "middle_finger": "🖕", + "reversed_hand_with_middle_finger_extended": "🖕", + "middle_finger::skin-tone-2": "🖕🏻", + "reversed_hand_with_middle_finger_extended::skin-tone-2": "🖕🏻", + "middle_finger::skin-tone-3": "🖕🏼", + "reversed_hand_with_middle_finger_extended::skin-tone-3": "🖕🏼", + "middle_finger::skin-tone-4": "🖕🏽", + "reversed_hand_with_middle_finger_extended::skin-tone-4": "🖕🏽", + "middle_finger::skin-tone-5": "🖕🏾", + "reversed_hand_with_middle_finger_extended::skin-tone-5": "🖕🏾", + "middle_finger::skin-tone-6": "🖕🏿", + "reversed_hand_with_middle_finger_extended::skin-tone-6": "🖕🏿", + "point_down": "👇", + "point_down::skin-tone-2": "👇🏻", + "point_down::skin-tone-3": "👇🏼", + "point_down::skin-tone-4": "👇🏽", + "point_down::skin-tone-5": "👇🏾", + "point_down::skin-tone-6": "👇🏿", + "point_up": "☝️", + "point_up::skin-tone-2": "☝🏻", + "point_up::skin-tone-3": "☝🏼", + "point_up::skin-tone-4": "☝🏽", + "point_up::skin-tone-5": "☝🏾", + "point_up::skin-tone-6": "☝🏿", + "index_pointing_at_the_viewer": "🫵", + "index_pointing_at_the_viewer::skin-tone-2": "🫵🏻", + "index_pointing_at_the_viewer::skin-tone-3": "🫵🏼", + "index_pointing_at_the_viewer::skin-tone-4": "🫵🏽", + "index_pointing_at_the_viewer::skin-tone-5": "🫵🏾", + "index_pointing_at_the_viewer::skin-tone-6": "🫵🏿", + "+1": "👍", + "thumbsup": "👍", + "+1::skin-tone-2": "👍🏻", + "thumbsup::skin-tone-2": "👍🏻", + "+1::skin-tone-3": "👍🏼", + "thumbsup::skin-tone-3": "👍🏼", + "+1::skin-tone-4": "👍🏽", + "thumbsup::skin-tone-4": "👍🏽", + "+1::skin-tone-5": "👍🏾", + "thumbsup::skin-tone-5": "👍🏾", + "+1::skin-tone-6": "👍🏿", + "thumbsup::skin-tone-6": "👍🏿", + "-1": "👎", + "thumbsdown": "👎", + "-1::skin-tone-2": "👎🏻", + "thumbsdown::skin-tone-2": "👎🏻", + "-1::skin-tone-3": "👎🏼", + "thumbsdown::skin-tone-3": "👎🏼", + "-1::skin-tone-4": "👎🏽", + "thumbsdown::skin-tone-4": "👎🏽", + "-1::skin-tone-5": "👎🏾", + "thumbsdown::skin-tone-5": "👎🏾", + "-1::skin-tone-6": "👎🏿", + "thumbsdown::skin-tone-6": "👎🏿", + "fist": "✊", + "fist::skin-tone-2": "✊🏻", + "fist::skin-tone-3": "✊🏼", + "fist::skin-tone-4": "✊🏽", + "fist::skin-tone-5": "✊🏾", + "fist::skin-tone-6": "✊🏿", + "facepunch": "👊", + "punch": "👊", + "facepunch::skin-tone-2": "👊🏻", + "punch::skin-tone-2": "👊🏻", + "facepunch::skin-tone-3": "👊🏼", + "punch::skin-tone-3": "👊🏼", + "facepunch::skin-tone-4": "👊🏽", + "punch::skin-tone-4": "👊🏽", + "facepunch::skin-tone-5": "👊🏾", + "punch::skin-tone-5": "👊🏾", + "facepunch::skin-tone-6": "👊🏿", + "punch::skin-tone-6": "👊🏿", + "left-facing_fist": "🤛", + "left-facing_fist::skin-tone-2": "🤛🏻", + "left-facing_fist::skin-tone-3": "🤛🏼", + "left-facing_fist::skin-tone-4": "🤛🏽", + "left-facing_fist::skin-tone-5": "🤛🏾", + "left-facing_fist::skin-tone-6": "🤛🏿", + "right-facing_fist": "🤜", + "right-facing_fist::skin-tone-2": "🤜🏻", + "right-facing_fist::skin-tone-3": "🤜🏼", + "right-facing_fist::skin-tone-4": "🤜🏽", + "right-facing_fist::skin-tone-5": "🤜🏾", + "right-facing_fist::skin-tone-6": "🤜🏿", + "clap": "👏", + "clap::skin-tone-2": "👏🏻", + "clap::skin-tone-3": "👏🏼", + "clap::skin-tone-4": "👏🏽", + "clap::skin-tone-5": "👏🏾", + "clap::skin-tone-6": "👏🏿", + "raised_hands": "🙌", + "raised_hands::skin-tone-2": "🙌🏻", + "raised_hands::skin-tone-3": "🙌🏼", + "raised_hands::skin-tone-4": "🙌🏽", + "raised_hands::skin-tone-5": "🙌🏾", + "raised_hands::skin-tone-6": "🙌🏿", + "heart_hands": "🫶", + "heart_hands::skin-tone-2": "🫶🏻", + "heart_hands::skin-tone-3": "🫶🏼", + "heart_hands::skin-tone-4": "🫶🏽", + "heart_hands::skin-tone-5": "🫶🏾", + "heart_hands::skin-tone-6": "🫶🏿", + "open_hands": "👐", + "open_hands::skin-tone-2": "👐🏻", + "open_hands::skin-tone-3": "👐🏼", + "open_hands::skin-tone-4": "👐🏽", + "open_hands::skin-tone-5": "👐🏾", + "open_hands::skin-tone-6": "👐🏿", + "palms_up_together": "🤲", + "palms_up_together::skin-tone-2": "🤲🏻", + "palms_up_together::skin-tone-3": "🤲🏼", + "palms_up_together::skin-tone-4": "🤲🏽", + "palms_up_together::skin-tone-5": "🤲🏾", + "palms_up_together::skin-tone-6": "🤲🏿", + "handshake": "🤝", + "handshake::skin-tone-2": "🤝🏻", + "handshake::skin-tone-3": "🤝🏼", + "handshake::skin-tone-4": "🤝🏽", + "handshake::skin-tone-5": "🤝🏾", + "handshake::skin-tone-6": "🤝🏿", + "pray": "🙏", + "pray::skin-tone-2": "🙏🏻", + "pray::skin-tone-3": "🙏🏼", + "pray::skin-tone-4": "🙏🏽", + "pray::skin-tone-5": "🙏🏾", + "pray::skin-tone-6": "🙏🏿", + "writing_hand": "✍️", + "writing_hand::skin-tone-2": "✍🏻", + "writing_hand::skin-tone-3": "✍🏼", + "writing_hand::skin-tone-4": "✍🏽", + "writing_hand::skin-tone-5": "✍🏾", + "writing_hand::skin-tone-6": "✍🏿", + "nail_care": "💅", + "nail_care::skin-tone-2": "💅🏻", + "nail_care::skin-tone-3": "💅🏼", + "nail_care::skin-tone-4": "💅🏽", + "nail_care::skin-tone-5": "💅🏾", + "nail_care::skin-tone-6": "💅🏿", + "selfie": "🤳", + "selfie::skin-tone-2": "🤳🏻", + "selfie::skin-tone-3": "🤳🏼", + "selfie::skin-tone-4": "🤳🏽", + "selfie::skin-tone-5": "🤳🏾", + "selfie::skin-tone-6": "🤳🏿", + "muscle": "💪", + "muscle::skin-tone-2": "💪🏻", + "muscle::skin-tone-3": "💪🏼", + "muscle::skin-tone-4": "💪🏽", + "muscle::skin-tone-5": "💪🏾", + "muscle::skin-tone-6": "💪🏿", + "mechanical_arm": "🦾", + "mechanical_leg": "🦿", + "leg": "🦵", + "leg::skin-tone-2": "🦵🏻", + "leg::skin-tone-3": "🦵🏼", + "leg::skin-tone-4": "🦵🏽", + "leg::skin-tone-5": "🦵🏾", + "leg::skin-tone-6": "🦵🏿", + "foot": "🦶", + "foot::skin-tone-2": "🦶🏻", + "foot::skin-tone-3": "🦶🏼", + "foot::skin-tone-4": "🦶🏽", + "foot::skin-tone-5": "🦶🏾", + "foot::skin-tone-6": "🦶🏿", + "ear": "👂", + "ear::skin-tone-2": "👂🏻", + "ear::skin-tone-3": "👂🏼", + "ear::skin-tone-4": "👂🏽", + "ear::skin-tone-5": "👂🏾", + "ear::skin-tone-6": "👂🏿", + "ear_with_hearing_aid": "🦻", + "ear_with_hearing_aid::skin-tone-2": "🦻🏻", + "ear_with_hearing_aid::skin-tone-3": "🦻🏼", + "ear_with_hearing_aid::skin-tone-4": "🦻🏽", + "ear_with_hearing_aid::skin-tone-5": "🦻🏾", + "ear_with_hearing_aid::skin-tone-6": "🦻🏿", + "nose": "👃", + "nose::skin-tone-2": "👃🏻", + "nose::skin-tone-3": "👃🏼", + "nose::skin-tone-4": "👃🏽", + "nose::skin-tone-5": "👃🏾", + "nose::skin-tone-6": "👃🏿", + "brain": "🧠", + "anatomical_heart": "🫀", + "lungs": "🫁", + "tooth": "🦷", + "bone": "🦴", + "eyes": "👀", + "dag-eyes": "👀", + "eye": "👁️", + "tongue": "👅", + "lips": "👄", + "biting_lip": "🫦", + "baby": "👶", + "baby::skin-tone-2": "👶🏻", + "baby::skin-tone-3": "👶🏼", + "baby::skin-tone-4": "👶🏽", + "baby::skin-tone-5": "👶🏾", + "baby::skin-tone-6": "👶🏿", + "child": "🧒", + "child::skin-tone-2": "🧒🏻", + "child::skin-tone-3": "🧒🏼", + "child::skin-tone-4": "🧒🏽", + "child::skin-tone-5": "🧒🏾", + "child::skin-tone-6": "🧒🏿", + "boy": "👦", + "boy::skin-tone-2": "👦🏻", + "boy::skin-tone-3": "👦🏼", + "boy::skin-tone-4": "👦🏽", + "boy::skin-tone-5": "👦🏾", + "boy::skin-tone-6": "👦🏿", + "girl": "👧", + "girl::skin-tone-2": "👧🏻", + "girl::skin-tone-3": "👧🏼", + "girl::skin-tone-4": "👧🏽", + "girl::skin-tone-5": "👧🏾", + "girl::skin-tone-6": "👧🏿", + "adult": "🧑", + "adult::skin-tone-2": "🧑🏻", + "adult::skin-tone-3": "🧑🏼", + "adult::skin-tone-4": "🧑🏽", + "adult::skin-tone-5": "🧑🏾", + "adult::skin-tone-6": "🧑🏿", + "person_with_blond_hair": "👱", + "person_with_blond_hair::skin-tone-2": "👱🏻", + "person_with_blond_hair::skin-tone-3": "👱🏼", + "person_with_blond_hair::skin-tone-4": "👱🏽", + "person_with_blond_hair::skin-tone-5": "👱🏾", + "person_with_blond_hair::skin-tone-6": "👱🏿", + "man": "👨", + "man::skin-tone-2": "👨🏻", + "man::skin-tone-3": "👨🏼", + "man::skin-tone-4": "👨🏽", + "man::skin-tone-5": "👨🏾", + "man::skin-tone-6": "👨🏿", + "bearded_person": "🧔", + "bearded_person::skin-tone-2": "🧔🏻", + "bearded_person::skin-tone-3": "🧔🏼", + "bearded_person::skin-tone-4": "🧔🏽", + "bearded_person::skin-tone-5": "🧔🏾", + "bearded_person::skin-tone-6": "🧔🏿", + "man_with_beard": "🧔♂️", + "man_with_beard::skin-tone-2": "🧔🏻♂️", + "man_with_beard::skin-tone-3": "🧔🏼♂️", + "man_with_beard::skin-tone-4": "🧔🏽♂️", + "man_with_beard::skin-tone-5": "🧔🏾♂️", + "man_with_beard::skin-tone-6": "🧔🏿♂️", + "woman_with_beard": "🧔♀️", + "woman_with_beard::skin-tone-2": "🧔🏻♀️", + "woman_with_beard::skin-tone-3": "🧔🏼♀️", + "woman_with_beard::skin-tone-4": "🧔🏽♀️", + "woman_with_beard::skin-tone-5": "🧔🏾♀️", + "woman_with_beard::skin-tone-6": "🧔🏿♀️", + "red_haired_man": "👨🦰", + "red_haired_man::skin-tone-2": "👨🏻🦰", + "red_haired_man::skin-tone-3": "👨🏼🦰", + "red_haired_man::skin-tone-4": "👨🏽🦰", + "red_haired_man::skin-tone-5": "👨🏾🦰", + "red_haired_man::skin-tone-6": "👨🏿🦰", + "curly_haired_man": "👨🦱", + "curly_haired_man::skin-tone-2": "👨🏻🦱", + "curly_haired_man::skin-tone-3": "👨🏼🦱", + "curly_haired_man::skin-tone-4": "👨🏽🦱", + "curly_haired_man::skin-tone-5": "👨🏾🦱", + "curly_haired_man::skin-tone-6": "👨🏿🦱", + "white_haired_man": "👨🦳", + "white_haired_man::skin-tone-2": "👨🏻🦳", + "white_haired_man::skin-tone-3": "👨🏼🦳", + "white_haired_man::skin-tone-4": "👨🏽🦳", + "white_haired_man::skin-tone-5": "👨🏾🦳", + "white_haired_man::skin-tone-6": "👨🏿🦳", + "bald_man": "👨🦲", + "bald_man::skin-tone-2": "👨🏻🦲", + "bald_man::skin-tone-3": "👨🏼🦲", + "bald_man::skin-tone-4": "👨🏽🦲", + "bald_man::skin-tone-5": "👨🏾🦲", + "bald_man::skin-tone-6": "👨🏿🦲", + "woman": "👩", + "woman::skin-tone-2": "👩🏻", + "woman::skin-tone-3": "👩🏼", + "woman::skin-tone-4": "👩🏽", + "woman::skin-tone-5": "👩🏾", + "woman::skin-tone-6": "👩🏿", + "red_haired_woman": "👩🦰", + "red_haired_woman::skin-tone-2": "👩🏻🦰", + "red_haired_woman::skin-tone-3": "👩🏼🦰", + "red_haired_woman::skin-tone-4": "👩🏽🦰", + "red_haired_woman::skin-tone-5": "👩🏾🦰", + "red_haired_woman::skin-tone-6": "👩🏿🦰", + "red_haired_person": "🧑🦰", + "red_haired_person::skin-tone-2": "🧑🏻🦰", + "red_haired_person::skin-tone-3": "🧑🏼🦰", + "red_haired_person::skin-tone-4": "🧑🏽🦰", + "red_haired_person::skin-tone-5": "🧑🏾🦰", + "red_haired_person::skin-tone-6": "🧑🏿🦰", + "curly_haired_woman": "👩🦱", + "curly_haired_woman::skin-tone-2": "👩🏻🦱", + "curly_haired_woman::skin-tone-3": "👩🏼🦱", + "curly_haired_woman::skin-tone-4": "👩🏽🦱", + "curly_haired_woman::skin-tone-5": "👩🏾🦱", + "curly_haired_woman::skin-tone-6": "👩🏿🦱", + "curly_haired_person": "🧑🦱", + "curly_haired_person::skin-tone-2": "🧑🏻🦱", + "curly_haired_person::skin-tone-3": "🧑🏼🦱", + "curly_haired_person::skin-tone-4": "🧑🏽🦱", + "curly_haired_person::skin-tone-5": "🧑🏾🦱", + "curly_haired_person::skin-tone-6": "🧑🏿🦱", + "white_haired_woman": "👩🦳", + "white_haired_woman::skin-tone-2": "👩🏻🦳", + "white_haired_woman::skin-tone-3": "👩🏼🦳", + "white_haired_woman::skin-tone-4": "👩🏽🦳", + "white_haired_woman::skin-tone-5": "👩🏾🦳", + "white_haired_woman::skin-tone-6": "👩🏿🦳", + "white_haired_person": "🧑🦳", + "white_haired_person::skin-tone-2": "🧑🏻🦳", + "white_haired_person::skin-tone-3": "🧑🏼🦳", + "white_haired_person::skin-tone-4": "🧑🏽🦳", + "white_haired_person::skin-tone-5": "🧑🏾🦳", + "white_haired_person::skin-tone-6": "🧑🏿🦳", + "bald_woman": "👩🦲", + "bald_woman::skin-tone-2": "👩🏻🦲", + "bald_woman::skin-tone-3": "👩🏼🦲", + "bald_woman::skin-tone-4": "👩🏽🦲", + "bald_woman::skin-tone-5": "👩🏾🦲", + "bald_woman::skin-tone-6": "👩🏿🦲", + "bald_person": "🧑🦲", + "bald_person::skin-tone-2": "🧑🏻🦲", + "bald_person::skin-tone-3": "🧑🏼🦲", + "bald_person::skin-tone-4": "🧑🏽🦲", + "bald_person::skin-tone-5": "🧑🏾🦲", + "bald_person::skin-tone-6": "🧑🏿🦲", + "blond-haired-woman": "👱♀️", + "blond-haired-woman::skin-tone-2": "👱🏻♀️", + "blond-haired-woman::skin-tone-3": "👱🏼♀️", + "blond-haired-woman::skin-tone-4": "👱🏽♀️", + "blond-haired-woman::skin-tone-5": "👱🏾♀️", + "blond-haired-woman::skin-tone-6": "👱🏿♀️", + "blond-haired-man": "👱♂️", + "blond-haired-man::skin-tone-2": "👱🏻♂️", + "blond-haired-man::skin-tone-3": "👱🏼♂️", + "blond-haired-man::skin-tone-4": "👱🏽♂️", + "blond-haired-man::skin-tone-5": "👱🏾♂️", + "blond-haired-man::skin-tone-6": "👱🏿♂️", + "older_adult": "🧓", + "older_adult::skin-tone-2": "🧓🏻", + "older_adult::skin-tone-3": "🧓🏼", + "older_adult::skin-tone-4": "🧓🏽", + "older_adult::skin-tone-5": "🧓🏾", + "older_adult::skin-tone-6": "🧓🏿", + "older_man": "👴", + "older_man::skin-tone-2": "👴🏻", + "older_man::skin-tone-3": "👴🏼", + "older_man::skin-tone-4": "👴🏽", + "older_man::skin-tone-5": "👴🏾", + "older_man::skin-tone-6": "👴🏿", + "older_woman": "👵", + "older_woman::skin-tone-2": "👵🏻", + "older_woman::skin-tone-3": "👵🏼", + "older_woman::skin-tone-4": "👵🏽", + "older_woman::skin-tone-5": "👵🏾", + "older_woman::skin-tone-6": "👵🏿", + "person_frowning": "🙍", + "person_frowning::skin-tone-2": "🙍🏻", + "person_frowning::skin-tone-3": "🙍🏼", + "person_frowning::skin-tone-4": "🙍🏽", + "person_frowning::skin-tone-5": "🙍🏾", + "person_frowning::skin-tone-6": "🙍🏿", + "man-frowning": "🙍♂️", + "man-frowning::skin-tone-2": "🙍🏻♂️", + "man-frowning::skin-tone-3": "🙍🏼♂️", + "man-frowning::skin-tone-4": "🙍🏽♂️", + "man-frowning::skin-tone-5": "🙍🏾♂️", + "man-frowning::skin-tone-6": "🙍🏿♂️", + "woman-frowning": "🙍♀️", + "woman-frowning::skin-tone-2": "🙍🏻♀️", + "woman-frowning::skin-tone-3": "🙍🏼♀️", + "woman-frowning::skin-tone-4": "🙍🏽♀️", + "woman-frowning::skin-tone-5": "🙍🏾♀️", + "woman-frowning::skin-tone-6": "🙍🏿♀️", + "person_with_pouting_face": "🙎", + "person_with_pouting_face::skin-tone-2": "🙎🏻", + "person_with_pouting_face::skin-tone-3": "🙎🏼", + "person_with_pouting_face::skin-tone-4": "🙎🏽", + "person_with_pouting_face::skin-tone-5": "🙎🏾", + "person_with_pouting_face::skin-tone-6": "🙎🏿", + "man-pouting": "🙎♂️", + "man-pouting::skin-tone-2": "🙎🏻♂️", + "man-pouting::skin-tone-3": "🙎🏼♂️", + "man-pouting::skin-tone-4": "🙎🏽♂️", + "man-pouting::skin-tone-5": "🙎🏾♂️", + "man-pouting::skin-tone-6": "🙎🏿♂️", + "woman-pouting": "🙎♀️", + "woman-pouting::skin-tone-2": "🙎🏻♀️", + "woman-pouting::skin-tone-3": "🙎🏼♀️", + "woman-pouting::skin-tone-4": "🙎🏽♀️", + "woman-pouting::skin-tone-5": "🙎🏾♀️", + "woman-pouting::skin-tone-6": "🙎🏿♀️", + "no_good": "🙅", + "no_good::skin-tone-2": "🙅🏻", + "no_good::skin-tone-3": "🙅🏼", + "no_good::skin-tone-4": "🙅🏽", + "no_good::skin-tone-5": "🙅🏾", + "no_good::skin-tone-6": "🙅🏿", + "man-gesturing-no": "🙅♂️", + "man-gesturing-no::skin-tone-2": "🙅🏻♂️", + "man-gesturing-no::skin-tone-3": "🙅🏼♂️", + "man-gesturing-no::skin-tone-4": "🙅🏽♂️", + "man-gesturing-no::skin-tone-5": "🙅🏾♂️", + "man-gesturing-no::skin-tone-6": "🙅🏿♂️", + "woman-gesturing-no": "🙅♀️", + "woman-gesturing-no::skin-tone-2": "🙅🏻♀️", + "woman-gesturing-no::skin-tone-3": "🙅🏼♀️", + "woman-gesturing-no::skin-tone-4": "🙅🏽♀️", + "woman-gesturing-no::skin-tone-5": "🙅🏾♀️", + "woman-gesturing-no::skin-tone-6": "🙅🏿♀️", + "ok_woman": "🙆", + "ok_woman::skin-tone-2": "🙆🏻", + "ok_woman::skin-tone-3": "🙆🏼", + "ok_woman::skin-tone-4": "🙆🏽", + "ok_woman::skin-tone-5": "🙆🏾", + "ok_woman::skin-tone-6": "🙆🏿", + "man-gesturing-ok": "🙆♂️", + "man-gesturing-ok::skin-tone-2": "🙆🏻♂️", + "man-gesturing-ok::skin-tone-3": "🙆🏼♂️", + "man-gesturing-ok::skin-tone-4": "🙆🏽♂️", + "man-gesturing-ok::skin-tone-5": "🙆🏾♂️", + "man-gesturing-ok::skin-tone-6": "🙆🏿♂️", + "woman-gesturing-ok": "🙆♀️", + "woman-gesturing-ok::skin-tone-2": "🙆🏻♀️", + "woman-gesturing-ok::skin-tone-3": "🙆🏼♀️", + "woman-gesturing-ok::skin-tone-4": "🙆🏽♀️", + "woman-gesturing-ok::skin-tone-5": "🙆🏾♀️", + "woman-gesturing-ok::skin-tone-6": "🙆🏿♀️", + "information_desk_person": "💁", + "information_desk_person::skin-tone-2": "💁🏻", + "information_desk_person::skin-tone-3": "💁🏼", + "information_desk_person::skin-tone-4": "💁🏽", + "information_desk_person::skin-tone-5": "💁🏾", + "information_desk_person::skin-tone-6": "💁🏿", + "man-tipping-hand": "💁♂️", + "man-tipping-hand::skin-tone-2": "💁🏻♂️", + "man-tipping-hand::skin-tone-3": "💁🏼♂️", + "man-tipping-hand::skin-tone-4": "💁🏽♂️", + "man-tipping-hand::skin-tone-5": "💁🏾♂️", + "man-tipping-hand::skin-tone-6": "💁🏿♂️", + "woman-tipping-hand": "💁♀️", + "woman-tipping-hand::skin-tone-2": "💁🏻♀️", + "woman-tipping-hand::skin-tone-3": "💁🏼♀️", + "woman-tipping-hand::skin-tone-4": "💁🏽♀️", + "woman-tipping-hand::skin-tone-5": "💁🏾♀️", + "woman-tipping-hand::skin-tone-6": "💁🏿♀️", + "raising_hand": "🙋", + "raising_hand::skin-tone-2": "🙋🏻", + "raising_hand::skin-tone-3": "🙋🏼", + "raising_hand::skin-tone-4": "🙋🏽", + "raising_hand::skin-tone-5": "🙋🏾", + "raising_hand::skin-tone-6": "🙋🏿", + "man-raising-hand": "🙋♂️", + "man-raising-hand::skin-tone-2": "🙋🏻♂️", + "man-raising-hand::skin-tone-3": "🙋🏼♂️", + "man-raising-hand::skin-tone-4": "🙋🏽♂️", + "man-raising-hand::skin-tone-5": "🙋🏾♂️", + "man-raising-hand::skin-tone-6": "🙋🏿♂️", + "woman-raising-hand": "🙋♀️", + "woman-raising-hand::skin-tone-2": "🙋🏻♀️", + "woman-raising-hand::skin-tone-3": "🙋🏼♀️", + "woman-raising-hand::skin-tone-4": "🙋🏽♀️", + "woman-raising-hand::skin-tone-5": "🙋🏾♀️", + "woman-raising-hand::skin-tone-6": "🙋🏿♀️", + "deaf_person": "🧏", + "deaf_person::skin-tone-2": "🧏🏻", + "deaf_person::skin-tone-3": "🧏🏼", + "deaf_person::skin-tone-4": "🧏🏽", + "deaf_person::skin-tone-5": "🧏🏾", + "deaf_person::skin-tone-6": "🧏🏿", + "deaf_man": "🧏♂️", + "deaf_man::skin-tone-2": "🧏🏻♂️", + "deaf_man::skin-tone-3": "🧏🏼♂️", + "deaf_man::skin-tone-4": "🧏🏽♂️", + "deaf_man::skin-tone-5": "🧏🏾♂️", + "deaf_man::skin-tone-6": "🧏🏿♂️", + "deaf_woman": "🧏♀️", + "deaf_woman::skin-tone-2": "🧏🏻♀️", + "deaf_woman::skin-tone-3": "🧏🏼♀️", + "deaf_woman::skin-tone-4": "🧏🏽♀️", + "deaf_woman::skin-tone-5": "🧏🏾♀️", + "deaf_woman::skin-tone-6": "🧏🏿♀️", + "nod-nicholson": "🙇", + "daggy-celebrate": "🎉", + "bow": "🙇", + "bow::skin-tone-2": "🙇🏻", + "bow::skin-tone-3": "🙇🏼", + "bow::skin-tone-4": "🙇🏽", + "bow::skin-tone-5": "🙇🏾", + "bow::skin-tone-6": "🙇🏿", + "man-bowing": "🙇♂️", + "man-bowing::skin-tone-2": "🙇🏻♂️", + "man-bowing::skin-tone-3": "🙇🏼♂️", + "man-bowing::skin-tone-4": "🙇🏽♂️", + "man-bowing::skin-tone-5": "🙇🏾♂️", + "man-bowing::skin-tone-6": "🙇🏿♂️", + "woman-bowing": "🙇♀️", + "woman-bowing::skin-tone-2": "🙇🏻♀️", + "woman-bowing::skin-tone-3": "🙇🏼♀️", + "woman-bowing::skin-tone-4": "🙇🏽♀️", + "woman-bowing::skin-tone-5": "🙇🏾♀️", + "woman-bowing::skin-tone-6": "🙇🏿♀️", + "face_palm": "🤦", + "face_palm::skin-tone-2": "🤦🏻", + "face_palm::skin-tone-3": "🤦🏼", + "face_palm::skin-tone-4": "🤦🏽", + "face_palm::skin-tone-5": "🤦🏾", + "face_palm::skin-tone-6": "🤦🏿", + "man-facepalming": "🤦♂️", + "man-facepalming::skin-tone-2": "🤦🏻♂️", + "man-facepalming::skin-tone-3": "🤦🏼♂️", + "man-facepalming::skin-tone-4": "🤦🏽♂️", + "man-facepalming::skin-tone-5": "🤦🏾♂️", + "man-facepalming::skin-tone-6": "🤦🏿♂️", + "woman-facepalming": "🤦♀️", + "woman-facepalming::skin-tone-2": "🤦🏻♀️", + "woman-facepalming::skin-tone-3": "🤦🏼♀️", + "woman-facepalming::skin-tone-4": "🤦🏽♀️", + "woman-facepalming::skin-tone-5": "🤦🏾♀️", + "woman-facepalming::skin-tone-6": "🤦🏿♀️", + "shrug": "🤷", + "shrug::skin-tone-2": "🤷🏻", + "shrug::skin-tone-3": "🤷🏼", + "shrug::skin-tone-4": "🤷🏽", + "shrug::skin-tone-5": "🤷🏾", + "shrug::skin-tone-6": "🤷🏿", + "man-shrugging": "🤷♂️", + "man-shrugging::skin-tone-2": "🤷🏻♂️", + "man-shrugging::skin-tone-3": "🤷🏼♂️", + "man-shrugging::skin-tone-4": "🤷🏽♂️", + "man-shrugging::skin-tone-5": "🤷🏾♂️", + "man-shrugging::skin-tone-6": "🤷🏿♂️", + "woman-shrugging": "🤷♀️", + "woman-shrugging::skin-tone-2": "🤷🏻♀️", + "woman-shrugging::skin-tone-3": "🤷🏼♀️", + "woman-shrugging::skin-tone-4": "🤷🏽♀️", + "woman-shrugging::skin-tone-5": "🤷🏾♀️", + "woman-shrugging::skin-tone-6": "🤷🏿♀️", + "health_worker": "🧑⚕️", + "health_worker::skin-tone-2": "🧑🏻⚕️", + "health_worker::skin-tone-3": "🧑🏼⚕️", + "health_worker::skin-tone-4": "🧑🏽⚕️", + "health_worker::skin-tone-5": "🧑🏾⚕️", + "health_worker::skin-tone-6": "🧑🏿⚕️", + "male-doctor": "👨⚕️", + "male-doctor::skin-tone-2": "👨🏻⚕️", + "male-doctor::skin-tone-3": "👨🏼⚕️", + "male-doctor::skin-tone-4": "👨🏽⚕️", + "male-doctor::skin-tone-5": "👨🏾⚕️", + "male-doctor::skin-tone-6": "👨🏿⚕️", + "female-doctor": "👩⚕️", + "female-doctor::skin-tone-2": "👩🏻⚕️", + "female-doctor::skin-tone-3": "👩🏼⚕️", + "female-doctor::skin-tone-4": "👩🏽⚕️", + "female-doctor::skin-tone-5": "👩🏾⚕️", + "female-doctor::skin-tone-6": "👩🏿⚕️", + "student": "🧑🎓", + "student::skin-tone-2": "🧑🏻🎓", + "student::skin-tone-3": "🧑🏼🎓", + "student::skin-tone-4": "🧑🏽🎓", + "student::skin-tone-5": "🧑🏾🎓", + "student::skin-tone-6": "🧑🏿🎓", + "male-student": "👨🎓", + "male-student::skin-tone-2": "👨🏻🎓", + "male-student::skin-tone-3": "👨🏼🎓", + "male-student::skin-tone-4": "👨🏽🎓", + "male-student::skin-tone-5": "👨🏾🎓", + "male-student::skin-tone-6": "👨🏿🎓", + "female-student": "👩🎓", + "female-student::skin-tone-2": "👩🏻🎓", + "female-student::skin-tone-3": "👩🏼🎓", + "female-student::skin-tone-4": "👩🏽🎓", + "female-student::skin-tone-5": "👩🏾🎓", + "female-student::skin-tone-6": "👩🏿🎓", + "teacher": "🧑🏫", + "teacher::skin-tone-2": "🧑🏻🏫", + "teacher::skin-tone-3": "🧑🏼🏫", + "teacher::skin-tone-4": "🧑🏽🏫", + "teacher::skin-tone-5": "🧑🏾🏫", + "teacher::skin-tone-6": "🧑🏿🏫", + "male-teacher": "👨🏫", + "male-teacher::skin-tone-2": "👨🏻🏫", + "male-teacher::skin-tone-3": "👨🏼🏫", + "male-teacher::skin-tone-4": "👨🏽🏫", + "male-teacher::skin-tone-5": "👨🏾🏫", + "male-teacher::skin-tone-6": "👨🏿🏫", + "female-teacher": "👩🏫", + "female-teacher::skin-tone-2": "👩🏻🏫", + "female-teacher::skin-tone-3": "👩🏼🏫", + "female-teacher::skin-tone-4": "👩🏽🏫", + "female-teacher::skin-tone-5": "👩🏾🏫", + "female-teacher::skin-tone-6": "👩🏿🏫", + "judge": "🧑⚖️", + "judge::skin-tone-2": "🧑🏻⚖️", + "judge::skin-tone-3": "🧑🏼⚖️", + "judge::skin-tone-4": "🧑🏽⚖️", + "judge::skin-tone-5": "🧑🏾⚖️", + "judge::skin-tone-6": "🧑🏿⚖️", + "male-judge": "👨⚖️", + "male-judge::skin-tone-2": "👨🏻⚖️", + "male-judge::skin-tone-3": "👨🏼⚖️", + "male-judge::skin-tone-4": "👨🏽⚖️", + "male-judge::skin-tone-5": "👨🏾⚖️", + "male-judge::skin-tone-6": "👨🏿⚖️", + "female-judge": "👩⚖️", + "female-judge::skin-tone-2": "👩🏻⚖️", + "female-judge::skin-tone-3": "👩🏼⚖️", + "female-judge::skin-tone-4": "👩🏽⚖️", + "female-judge::skin-tone-5": "👩🏾⚖️", + "female-judge::skin-tone-6": "👩🏿⚖️", + "farmer": "🧑🌾", + "farmer::skin-tone-2": "🧑🏻🌾", + "farmer::skin-tone-3": "🧑🏼🌾", + "farmer::skin-tone-4": "🧑🏽🌾", + "farmer::skin-tone-5": "🧑🏾🌾", + "farmer::skin-tone-6": "🧑🏿🌾", + "male-farmer": "👨🌾", + "male-farmer::skin-tone-2": "👨🏻🌾", + "male-farmer::skin-tone-3": "👨🏼🌾", + "male-farmer::skin-tone-4": "👨🏽🌾", + "male-farmer::skin-tone-5": "👨🏾🌾", + "male-farmer::skin-tone-6": "👨🏿🌾", + "female-farmer": "👩🌾", + "female-farmer::skin-tone-2": "👩🏻🌾", + "female-farmer::skin-tone-3": "👩🏼🌾", + "female-farmer::skin-tone-4": "👩🏽🌾", + "female-farmer::skin-tone-5": "👩🏾🌾", + "female-farmer::skin-tone-6": "👩🏿🌾", + "cook": "🧑🍳", + "cook::skin-tone-2": "🧑🏻🍳", + "cook::skin-tone-3": "🧑🏼🍳", + "cook::skin-tone-4": "🧑🏽🍳", + "cook::skin-tone-5": "🧑🏾🍳", + "cook::skin-tone-6": "🧑🏿🍳", + "male-cook": "👨🍳", + "male-cook::skin-tone-2": "👨🏻🍳", + "male-cook::skin-tone-3": "👨🏼🍳", + "male-cook::skin-tone-4": "👨🏽🍳", + "male-cook::skin-tone-5": "👨🏾🍳", + "male-cook::skin-tone-6": "👨🏿🍳", + "female-cook": "👩🍳", + "female-cook::skin-tone-2": "👩🏻🍳", + "female-cook::skin-tone-3": "👩🏼🍳", + "female-cook::skin-tone-4": "👩🏽🍳", + "female-cook::skin-tone-5": "👩🏾🍳", + "female-cook::skin-tone-6": "👩🏿🍳", + "mechanic": "🧑🔧", + "mechanic::skin-tone-2": "🧑🏻🔧", + "mechanic::skin-tone-3": "🧑🏼🔧", + "mechanic::skin-tone-4": "🧑🏽🔧", + "mechanic::skin-tone-5": "🧑🏾🔧", + "mechanic::skin-tone-6": "🧑🏿🔧", + "male-mechanic": "👨🔧", + "male-mechanic::skin-tone-2": "👨🏻🔧", + "male-mechanic::skin-tone-3": "👨🏼🔧", + "male-mechanic::skin-tone-4": "👨🏽🔧", + "male-mechanic::skin-tone-5": "👨🏾🔧", + "male-mechanic::skin-tone-6": "👨🏿🔧", + "female-mechanic": "👩🔧", + "female-mechanic::skin-tone-2": "👩🏻🔧", + "female-mechanic::skin-tone-3": "👩🏼🔧", + "female-mechanic::skin-tone-4": "👩🏽🔧", + "female-mechanic::skin-tone-5": "👩🏾🔧", + "female-mechanic::skin-tone-6": "👩🏿🔧", + "factory_worker": "🧑🏭", + "factory_worker::skin-tone-2": "🧑🏻🏭", + "factory_worker::skin-tone-3": "🧑🏼🏭", + "factory_worker::skin-tone-4": "🧑🏽🏭", + "factory_worker::skin-tone-5": "🧑🏾🏭", + "factory_worker::skin-tone-6": "🧑🏿🏭", + "male-factory-worker": "👨🏭", + "male-factory-worker::skin-tone-2": "👨🏻🏭", + "male-factory-worker::skin-tone-3": "👨🏼🏭", + "male-factory-worker::skin-tone-4": "👨🏽🏭", + "male-factory-worker::skin-tone-5": "👨🏾🏭", + "male-factory-worker::skin-tone-6": "👨🏿🏭", + "female-factory-worker": "👩🏭", + "female-factory-worker::skin-tone-2": "👩🏻🏭", + "female-factory-worker::skin-tone-3": "👩🏼🏭", + "female-factory-worker::skin-tone-4": "👩🏽🏭", + "female-factory-worker::skin-tone-5": "👩🏾🏭", + "female-factory-worker::skin-tone-6": "👩🏿🏭", + "office_worker": "🧑💼", + "office_worker::skin-tone-2": "🧑🏻💼", + "office_worker::skin-tone-3": "🧑🏼💼", + "office_worker::skin-tone-4": "🧑🏽💼", + "office_worker::skin-tone-5": "🧑🏾💼", + "office_worker::skin-tone-6": "🧑🏿💼", + "male-office-worker": "👨💼", + "male-office-worker::skin-tone-2": "👨🏻💼", + "male-office-worker::skin-tone-3": "👨🏼💼", + "male-office-worker::skin-tone-4": "👨🏽💼", + "male-office-worker::skin-tone-5": "👨🏾💼", + "male-office-worker::skin-tone-6": "👨🏿💼", + "female-office-worker": "👩💼", + "female-office-worker::skin-tone-2": "👩🏻💼", + "female-office-worker::skin-tone-3": "👩🏼💼", + "female-office-worker::skin-tone-4": "👩🏽💼", + "female-office-worker::skin-tone-5": "👩🏾💼", + "female-office-worker::skin-tone-6": "👩🏿💼", + "scientist": "🧑🔬", + "scientist::skin-tone-2": "🧑🏻🔬", + "scientist::skin-tone-3": "🧑🏼🔬", + "scientist::skin-tone-4": "🧑🏽🔬", + "scientist::skin-tone-5": "🧑🏾🔬", + "scientist::skin-tone-6": "🧑🏿🔬", + "male-scientist": "👨🔬", + "male-scientist::skin-tone-2": "👨🏻🔬", + "male-scientist::skin-tone-3": "👨🏼🔬", + "male-scientist::skin-tone-4": "👨🏽🔬", + "male-scientist::skin-tone-5": "👨🏾🔬", + "male-scientist::skin-tone-6": "👨🏿🔬", + "female-scientist": "👩🔬", + "female-scientist::skin-tone-2": "👩🏻🔬", + "female-scientist::skin-tone-3": "👩🏼🔬", + "female-scientist::skin-tone-4": "👩🏽🔬", + "female-scientist::skin-tone-5": "👩🏾🔬", + "female-scientist::skin-tone-6": "👩🏿🔬", + "technologist": "🧑💻", + "technologist::skin-tone-2": "🧑🏻💻", + "technologist::skin-tone-3": "🧑🏼💻", + "technologist::skin-tone-4": "🧑🏽💻", + "technologist::skin-tone-5": "🧑🏾💻", + "technologist::skin-tone-6": "🧑🏿💻", + "male-technologist": "👨💻", + "male-technologist::skin-tone-2": "👨🏻💻", + "male-technologist::skin-tone-3": "👨🏼💻", + "male-technologist::skin-tone-4": "👨🏽💻", + "male-technologist::skin-tone-5": "👨🏾💻", + "male-technologist::skin-tone-6": "👨🏿💻", + "female-technologist": "👩💻", + "female-technologist::skin-tone-2": "👩🏻💻", + "female-technologist::skin-tone-3": "👩🏼💻", + "female-technologist::skin-tone-4": "👩🏽💻", + "female-technologist::skin-tone-5": "👩🏾💻", + "female-technologist::skin-tone-6": "👩🏿💻", + "singer": "🧑🎤", + "singer::skin-tone-2": "🧑🏻🎤", + "singer::skin-tone-3": "🧑🏼🎤", + "singer::skin-tone-4": "🧑🏽🎤", + "singer::skin-tone-5": "🧑🏾🎤", + "singer::skin-tone-6": "🧑🏿🎤", + "male-singer": "👨🎤", + "male-singer::skin-tone-2": "👨🏻🎤", + "male-singer::skin-tone-3": "👨🏼🎤", + "male-singer::skin-tone-4": "👨🏽🎤", + "male-singer::skin-tone-5": "👨🏾🎤", + "male-singer::skin-tone-6": "👨🏿🎤", + "female-singer": "👩🎤", + "female-singer::skin-tone-2": "👩🏻🎤", + "female-singer::skin-tone-3": "👩🏼🎤", + "female-singer::skin-tone-4": "👩🏽🎤", + "female-singer::skin-tone-5": "👩🏾🎤", + "female-singer::skin-tone-6": "👩🏿🎤", + "artist": "🧑🎨", + "artist::skin-tone-2": "🧑🏻🎨", + "artist::skin-tone-3": "🧑🏼🎨", + "artist::skin-tone-4": "🧑🏽🎨", + "artist::skin-tone-5": "🧑🏾🎨", + "artist::skin-tone-6": "🧑🏿🎨", + "male-artist": "👨🎨", + "male-artist::skin-tone-2": "👨🏻🎨", + "male-artist::skin-tone-3": "👨🏼🎨", + "male-artist::skin-tone-4": "👨🏽🎨", + "male-artist::skin-tone-5": "👨🏾🎨", + "male-artist::skin-tone-6": "👨🏿🎨", + "female-artist": "👩🎨", + "female-artist::skin-tone-2": "👩🏻🎨", + "female-artist::skin-tone-3": "👩🏼🎨", + "female-artist::skin-tone-4": "👩🏽🎨", + "female-artist::skin-tone-5": "👩🏾🎨", + "female-artist::skin-tone-6": "👩🏿🎨", + "pilot": "🧑✈️", + "pilot::skin-tone-2": "🧑🏻✈️", + "pilot::skin-tone-3": "🧑🏼✈️", + "pilot::skin-tone-4": "🧑🏽✈️", + "pilot::skin-tone-5": "🧑🏾✈️", + "pilot::skin-tone-6": "🧑🏿✈️", + "male-pilot": "👨✈️", + "male-pilot::skin-tone-2": "👨🏻✈️", + "male-pilot::skin-tone-3": "👨🏼✈️", + "male-pilot::skin-tone-4": "👨🏽✈️", + "male-pilot::skin-tone-5": "👨🏾✈️", + "male-pilot::skin-tone-6": "👨🏿✈️", + "female-pilot": "👩✈️", + "female-pilot::skin-tone-2": "👩🏻✈️", + "female-pilot::skin-tone-3": "👩🏼✈️", + "female-pilot::skin-tone-4": "👩🏽✈️", + "female-pilot::skin-tone-5": "👩🏾✈️", + "female-pilot::skin-tone-6": "👩🏿✈️", + "astronaut": "🧑🚀", + "astronaut::skin-tone-2": "🧑🏻🚀", + "astronaut::skin-tone-3": "🧑🏼🚀", + "astronaut::skin-tone-4": "🧑🏽🚀", + "astronaut::skin-tone-5": "🧑🏾🚀", + "astronaut::skin-tone-6": "🧑🏿🚀", + "male-astronaut": "👨🚀", + "male-astronaut::skin-tone-2": "👨🏻🚀", + "male-astronaut::skin-tone-3": "👨🏼🚀", + "male-astronaut::skin-tone-4": "👨🏽🚀", + "male-astronaut::skin-tone-5": "👨🏾🚀", + "male-astronaut::skin-tone-6": "👨🏿🚀", + "female-astronaut": "👩🚀", + "female-astronaut::skin-tone-2": "👩🏻🚀", + "female-astronaut::skin-tone-3": "👩🏼🚀", + "female-astronaut::skin-tone-4": "👩🏽🚀", + "female-astronaut::skin-tone-5": "👩🏾🚀", + "female-astronaut::skin-tone-6": "👩🏿🚀", + "firefighter": "🧑🚒", + "firefighter::skin-tone-2": "🧑🏻🚒", + "firefighter::skin-tone-3": "🧑🏼🚒", + "firefighter::skin-tone-4": "🧑🏽🚒", + "firefighter::skin-tone-5": "🧑🏾🚒", + "firefighter::skin-tone-6": "🧑🏿🚒", + "male-firefighter": "👨🚒", + "male-firefighter::skin-tone-2": "👨🏻🚒", + "male-firefighter::skin-tone-3": "👨🏼🚒", + "male-firefighter::skin-tone-4": "👨🏽🚒", + "male-firefighter::skin-tone-5": "👨🏾🚒", + "male-firefighter::skin-tone-6": "👨🏿🚒", + "female-firefighter": "👩🚒", + "female-firefighter::skin-tone-2": "👩🏻🚒", + "female-firefighter::skin-tone-3": "👩🏼🚒", + "female-firefighter::skin-tone-4": "👩🏽🚒", + "female-firefighter::skin-tone-5": "👩🏾🚒", + "female-firefighter::skin-tone-6": "👩🏿🚒", + "cop": "👮", + "cop::skin-tone-2": "👮🏻", + "cop::skin-tone-3": "👮🏼", + "cop::skin-tone-4": "👮🏽", + "cop::skin-tone-5": "👮🏾", + "cop::skin-tone-6": "👮🏿", + "male-police-officer": "👮♂️", + "male-police-officer::skin-tone-2": "👮🏻♂️", + "male-police-officer::skin-tone-3": "👮🏼♂️", + "male-police-officer::skin-tone-4": "👮🏽♂️", + "male-police-officer::skin-tone-5": "👮🏾♂️", + "male-police-officer::skin-tone-6": "👮🏿♂️", + "female-police-officer": "👮♀️", + "female-police-officer::skin-tone-2": "👮🏻♀️", + "female-police-officer::skin-tone-3": "👮🏼♀️", + "female-police-officer::skin-tone-4": "👮🏽♀️", + "female-police-officer::skin-tone-5": "👮🏾♀️", + "female-police-officer::skin-tone-6": "👮🏿♀️", + "sleuth_or_spy": "🕵️", + "sleuth_or_spy::skin-tone-2": "🕵🏻", + "sleuth_or_spy::skin-tone-3": "🕵🏼", + "sleuth_or_spy::skin-tone-4": "🕵🏽", + "sleuth_or_spy::skin-tone-5": "🕵🏾", + "sleuth_or_spy::skin-tone-6": "🕵🏿", + "male-detective": "🕵️♂️", + "male-detective::skin-tone-2": "🕵🏻♂️", + "male-detective::skin-tone-3": "🕵🏼♂️", + "male-detective::skin-tone-4": "🕵🏽♂️", + "male-detective::skin-tone-5": "🕵🏾♂️", + "male-detective::skin-tone-6": "🕵🏿♂️", + "female-detective": "🕵️♀️", + "female-detective::skin-tone-2": "🕵🏻♀️", + "female-detective::skin-tone-3": "🕵🏼♀️", + "female-detective::skin-tone-4": "🕵🏽♀️", + "female-detective::skin-tone-5": "🕵🏾♀️", + "female-detective::skin-tone-6": "🕵🏿♀️", + "guardsman": "💂", + "guardsman::skin-tone-2": "💂🏻", + "guardsman::skin-tone-3": "💂🏼", + "guardsman::skin-tone-4": "💂🏽", + "guardsman::skin-tone-5": "💂🏾", + "guardsman::skin-tone-6": "💂🏿", + "male-guard": "💂♂️", + "male-guard::skin-tone-2": "💂🏻♂️", + "male-guard::skin-tone-3": "💂🏼♂️", + "male-guard::skin-tone-4": "💂🏽♂️", + "male-guard::skin-tone-5": "💂🏾♂️", + "male-guard::skin-tone-6": "💂🏿♂️", + "female-guard": "💂♀️", + "female-guard::skin-tone-2": "💂🏻♀️", + "female-guard::skin-tone-3": "💂🏼♀️", + "female-guard::skin-tone-4": "💂🏽♀️", + "female-guard::skin-tone-5": "💂🏾♀️", + "female-guard::skin-tone-6": "💂🏿♀️", + "ninja": "🥷", + "ninja::skin-tone-2": "🥷🏻", + "ninja::skin-tone-3": "🥷🏼", + "ninja::skin-tone-4": "🥷🏽", + "ninja::skin-tone-5": "🥷🏾", + "ninja::skin-tone-6": "🥷🏿", + "construction_worker": "👷", + "construction_worker::skin-tone-2": "👷🏻", + "construction_worker::skin-tone-3": "👷🏼", + "construction_worker::skin-tone-4": "👷🏽", + "construction_worker::skin-tone-5": "👷🏾", + "construction_worker::skin-tone-6": "👷🏿", + "male-construction-worker": "👷♂️", + "male-construction-worker::skin-tone-2": "👷🏻♂️", + "male-construction-worker::skin-tone-3": "👷🏼♂️", + "male-construction-worker::skin-tone-4": "👷🏽♂️", + "male-construction-worker::skin-tone-5": "👷🏾♂️", + "male-construction-worker::skin-tone-6": "👷🏿♂️", + "female-construction-worker": "👷♀️", + "female-construction-worker::skin-tone-2": "👷🏻♀️", + "female-construction-worker::skin-tone-3": "👷🏼♀️", + "female-construction-worker::skin-tone-4": "👷🏽♀️", + "female-construction-worker::skin-tone-5": "👷🏾♀️", + "female-construction-worker::skin-tone-6": "👷🏿♀️", + "person_with_crown": "🫅", + "person_with_crown::skin-tone-2": "🫅🏻", + "person_with_crown::skin-tone-3": "🫅🏼", + "person_with_crown::skin-tone-4": "🫅🏽", + "person_with_crown::skin-tone-5": "🫅🏾", + "person_with_crown::skin-tone-6": "🫅🏿", + "prince": "🤴", + "prince::skin-tone-2": "🤴🏻", + "prince::skin-tone-3": "🤴🏼", + "prince::skin-tone-4": "🤴🏽", + "prince::skin-tone-5": "🤴🏾", + "prince::skin-tone-6": "🤴🏿", + "princess": "👸", + "princess::skin-tone-2": "👸🏻", + "princess::skin-tone-3": "👸🏼", + "princess::skin-tone-4": "👸🏽", + "princess::skin-tone-5": "👸🏾", + "princess::skin-tone-6": "👸🏿", + "man_with_turban": "👳", + "man_with_turban::skin-tone-2": "👳🏻", + "man_with_turban::skin-tone-3": "👳🏼", + "man_with_turban::skin-tone-4": "👳🏽", + "man_with_turban::skin-tone-5": "👳🏾", + "man_with_turban::skin-tone-6": "👳🏿", + "man-wearing-turban": "👳♂️", + "man-wearing-turban::skin-tone-2": "👳🏻♂️", + "man-wearing-turban::skin-tone-3": "👳🏼♂️", + "man-wearing-turban::skin-tone-4": "👳🏽♂️", + "man-wearing-turban::skin-tone-5": "👳🏾♂️", + "man-wearing-turban::skin-tone-6": "👳🏿♂️", + "woman-wearing-turban": "👳♀️", + "woman-wearing-turban::skin-tone-2": "👳🏻♀️", + "woman-wearing-turban::skin-tone-3": "👳🏼♀️", + "woman-wearing-turban::skin-tone-4": "👳🏽♀️", + "woman-wearing-turban::skin-tone-5": "👳🏾♀️", + "woman-wearing-turban::skin-tone-6": "👳🏿♀️", + "man_with_gua_pi_mao": "👲", + "man_with_gua_pi_mao::skin-tone-2": "👲🏻", + "man_with_gua_pi_mao::skin-tone-3": "👲🏼", + "man_with_gua_pi_mao::skin-tone-4": "👲🏽", + "man_with_gua_pi_mao::skin-tone-5": "👲🏾", + "man_with_gua_pi_mao::skin-tone-6": "👲🏿", + "person_with_headscarf": "🧕", + "person_with_headscarf::skin-tone-2": "🧕🏻", + "person_with_headscarf::skin-tone-3": "🧕🏼", + "person_with_headscarf::skin-tone-4": "🧕🏽", + "person_with_headscarf::skin-tone-5": "🧕🏾", + "person_with_headscarf::skin-tone-6": "🧕🏿", + "person_in_tuxedo": "🤵", + "person_in_tuxedo::skin-tone-2": "🤵🏻", + "person_in_tuxedo::skin-tone-3": "🤵🏼", + "person_in_tuxedo::skin-tone-4": "🤵🏽", + "person_in_tuxedo::skin-tone-5": "🤵🏾", + "person_in_tuxedo::skin-tone-6": "🤵🏿", + "man_in_tuxedo": "🤵♂️", + "man_in_tuxedo::skin-tone-2": "🤵🏻♂️", + "man_in_tuxedo::skin-tone-3": "🤵🏼♂️", + "man_in_tuxedo::skin-tone-4": "🤵🏽♂️", + "man_in_tuxedo::skin-tone-5": "🤵🏾♂️", + "man_in_tuxedo::skin-tone-6": "🤵🏿♂️", + "woman_in_tuxedo": "🤵♀️", + "woman_in_tuxedo::skin-tone-2": "🤵🏻♀️", + "woman_in_tuxedo::skin-tone-3": "🤵🏼♀️", + "woman_in_tuxedo::skin-tone-4": "🤵🏽♀️", + "woman_in_tuxedo::skin-tone-5": "🤵🏾♀️", + "woman_in_tuxedo::skin-tone-6": "🤵🏿♀️", + "bride_with_veil": "👰", + "bride_with_veil::skin-tone-2": "👰🏻", + "bride_with_veil::skin-tone-3": "👰🏼", + "bride_with_veil::skin-tone-4": "👰🏽", + "bride_with_veil::skin-tone-5": "👰🏾", + "bride_with_veil::skin-tone-6": "👰🏿", + "man_with_veil": "👰♂️", + "man_with_veil::skin-tone-2": "👰🏻♂️", + "man_with_veil::skin-tone-3": "👰🏼♂️", + "man_with_veil::skin-tone-4": "👰🏽♂️", + "man_with_veil::skin-tone-5": "👰🏾♂️", + "man_with_veil::skin-tone-6": "👰🏿♂️", + "woman_with_veil": "👰♀️", + "woman_with_veil::skin-tone-2": "👰🏻♀️", + "woman_with_veil::skin-tone-3": "👰🏼♀️", + "woman_with_veil::skin-tone-4": "👰🏽♀️", + "woman_with_veil::skin-tone-5": "👰🏾♀️", + "woman_with_veil::skin-tone-6": "👰🏿♀️", + "pregnant_woman": "🤰", + "pregnant_woman::skin-tone-2": "🤰🏻", + "pregnant_woman::skin-tone-3": "🤰🏼", + "pregnant_woman::skin-tone-4": "🤰🏽", + "pregnant_woman::skin-tone-5": "🤰🏾", + "pregnant_woman::skin-tone-6": "🤰🏿", + "pregnant_man": "🫃", + "pregnant_man::skin-tone-2": "🫃🏻", + "pregnant_man::skin-tone-3": "🫃🏼", + "pregnant_man::skin-tone-4": "🫃🏽", + "pregnant_man::skin-tone-5": "🫃🏾", + "pregnant_man::skin-tone-6": "🫃🏿", + "pregnant_person": "🫄", + "pregnant_person::skin-tone-2": "🫄🏻", + "pregnant_person::skin-tone-3": "🫄🏼", + "pregnant_person::skin-tone-4": "🫄🏽", + "pregnant_person::skin-tone-5": "🫄🏾", + "pregnant_person::skin-tone-6": "🫄🏿", + "breast-feeding": "🤱", + "breast-feeding::skin-tone-2": "🤱🏻", + "breast-feeding::skin-tone-3": "🤱🏼", + "breast-feeding::skin-tone-4": "🤱🏽", + "breast-feeding::skin-tone-5": "🤱🏾", + "breast-feeding::skin-tone-6": "🤱🏿", + "woman_feeding_baby": "👩🍼", + "woman_feeding_baby::skin-tone-2": "👩🏻🍼", + "woman_feeding_baby::skin-tone-3": "👩🏼🍼", + "woman_feeding_baby::skin-tone-4": "👩🏽🍼", + "woman_feeding_baby::skin-tone-5": "👩🏾🍼", + "woman_feeding_baby::skin-tone-6": "👩🏿🍼", + "man_feeding_baby": "👨🍼", + "man_feeding_baby::skin-tone-2": "👨🏻🍼", + "man_feeding_baby::skin-tone-3": "👨🏼🍼", + "man_feeding_baby::skin-tone-4": "👨🏽🍼", + "man_feeding_baby::skin-tone-5": "👨🏾🍼", + "man_feeding_baby::skin-tone-6": "👨🏿🍼", + "person_feeding_baby": "🧑🍼", + "person_feeding_baby::skin-tone-2": "🧑🏻🍼", + "person_feeding_baby::skin-tone-3": "🧑🏼🍼", + "person_feeding_baby::skin-tone-4": "🧑🏽🍼", + "person_feeding_baby::skin-tone-5": "🧑🏾🍼", + "person_feeding_baby::skin-tone-6": "🧑🏿🍼", + "angel": "👼", + "angel::skin-tone-2": "👼🏻", + "angel::skin-tone-3": "👼🏼", + "angel::skin-tone-4": "👼🏽", + "angel::skin-tone-5": "👼🏾", + "angel::skin-tone-6": "👼🏿", + "dagsanta": "🎅", + "santa": "🎅", + "santa::skin-tone-2": "🎅🏻", + "santa::skin-tone-3": "🎅🏼", + "santa::skin-tone-4": "🎅🏽", + "santa::skin-tone-5": "🎅🏾", + "santa::skin-tone-6": "🎅🏿", + "mrs_claus": "🤶", + "mother_christmas": "🤶", + "mrs_claus::skin-tone-2": "🤶🏻", + "mother_christmas::skin-tone-2": "🤶🏻", + "mrs_claus::skin-tone-3": "🤶🏼", + "mother_christmas::skin-tone-3": "🤶🏼", + "mrs_claus::skin-tone-4": "🤶🏽", + "mother_christmas::skin-tone-4": "🤶🏽", + "mrs_claus::skin-tone-5": "🤶🏾", + "mother_christmas::skin-tone-5": "🤶🏾", + "mrs_claus::skin-tone-6": "🤶🏿", + "mother_christmas::skin-tone-6": "🤶🏿", + "mx_claus": "🧑🎄", + "mx_claus::skin-tone-2": "🧑🏻🎄", + "mx_claus::skin-tone-3": "🧑🏼🎄", + "mx_claus::skin-tone-4": "🧑🏽🎄", + "mx_claus::skin-tone-5": "🧑🏾🎄", + "mx_claus::skin-tone-6": "🧑🏿🎄", + "superhero": "🦸", + "superhero::skin-tone-2": "🦸🏻", + "superhero::skin-tone-3": "🦸🏼", + "superhero::skin-tone-4": "🦸🏽", + "superhero::skin-tone-5": "🦸🏾", + "superhero::skin-tone-6": "🦸🏿", + "male_superhero": "🦸♂️", + "male_superhero::skin-tone-2": "🦸🏻♂️", + "male_superhero::skin-tone-3": "🦸🏼♂️", + "male_superhero::skin-tone-4": "🦸🏽♂️", + "male_superhero::skin-tone-5": "🦸🏾♂️", + "male_superhero::skin-tone-6": "🦸🏿♂️", + "female_superhero": "🦸♀️", + "female_superhero::skin-tone-2": "🦸🏻♀️", + "female_superhero::skin-tone-3": "🦸🏼♀️", + "female_superhero::skin-tone-4": "🦸🏽♀️", + "female_superhero::skin-tone-5": "🦸🏾♀️", + "female_superhero::skin-tone-6": "🦸🏿♀️", + "supervillain": "🦹", + "supervillain::skin-tone-2": "🦹🏻", + "supervillain::skin-tone-3": "🦹🏼", + "supervillain::skin-tone-4": "🦹🏽", + "supervillain::skin-tone-5": "🦹🏾", + "supervillain::skin-tone-6": "🦹🏿", + "male_supervillain": "🦹♂️", + "male_supervillain::skin-tone-2": "🦹🏻♂️", + "male_supervillain::skin-tone-3": "🦹🏼♂️", + "male_supervillain::skin-tone-4": "🦹🏽♂️", + "male_supervillain::skin-tone-5": "🦹🏾♂️", + "male_supervillain::skin-tone-6": "🦹🏿♂️", + "female_supervillain": "🦹♀️", + "female_supervillain::skin-tone-2": "🦹🏻♀️", + "female_supervillain::skin-tone-3": "🦹🏼♀️", + "female_supervillain::skin-tone-4": "🦹🏽♀️", + "female_supervillain::skin-tone-5": "🦹🏾♀️", + "female_supervillain::skin-tone-6": "🦹🏿♀️", + "mage": "🧙", + "mage_ai": "🧙", + "mage::skin-tone-2": "🧙🏻", + "mage::skin-tone-3": "🧙🏼", + "mage::skin-tone-4": "🧙🏽", + "mage::skin-tone-5": "🧙🏾", + "mage::skin-tone-6": "🧙🏿", + "male_mage": "🧙♂️", + "male_mage::skin-tone-2": "🧙🏻♂️", + "male_mage::skin-tone-3": "🧙🏼♂️", + "male_mage::skin-tone-4": "🧙🏽♂️", + "male_mage::skin-tone-5": "🧙🏾♂️", + "male_mage::skin-tone-6": "🧙🏿♂️", + "female_mage": "🧙♀️", + "female_mage::skin-tone-2": "🧙🏻♀️", + "female_mage::skin-tone-3": "🧙🏼♀️", + "female_mage::skin-tone-4": "🧙🏽♀️", + "female_mage::skin-tone-5": "🧙🏾♀️", + "female_mage::skin-tone-6": "🧙🏿♀️", + "airflow": "A", + "fairy": "🧚", + "fairy::skin-tone-2": "🧚🏻", + "fairy::skin-tone-3": "🧚🏼", + "fairy::skin-tone-4": "🧚🏽", + "fairy::skin-tone-5": "🧚🏾", + "fairy::skin-tone-6": "🧚🏿", + "male_fairy": "🧚♂️", + "male_fairy::skin-tone-2": "🧚🏻♂️", + "male_fairy::skin-tone-3": "🧚🏼♂️", + "male_fairy::skin-tone-4": "🧚🏽♂️", + "male_fairy::skin-tone-5": "🧚🏾♂️", + "male_fairy::skin-tone-6": "🧚🏿♂️", + "female_fairy": "🧚♀️", + "female_fairy::skin-tone-2": "🧚🏻♀️", + "female_fairy::skin-tone-3": "🧚🏼♀️", + "female_fairy::skin-tone-4": "🧚🏽♀️", + "female_fairy::skin-tone-5": "🧚🏾♀️", + "female_fairy::skin-tone-6": "🧚🏿♀️", + "vampire": "🧛", + "vampire::skin-tone-2": "🧛🏻", + "vampire::skin-tone-3": "🧛🏼", + "vampire::skin-tone-4": "🧛🏽", + "vampire::skin-tone-5": "🧛🏾", + "vampire::skin-tone-6": "🧛🏿", + "male_vampire": "🧛♂️", + "male_vampire::skin-tone-2": "🧛🏻♂️", + "male_vampire::skin-tone-3": "🧛🏼♂️", + "male_vampire::skin-tone-4": "🧛🏽♂️", + "male_vampire::skin-tone-5": "🧛🏾♂️", + "male_vampire::skin-tone-6": "🧛🏿♂️", + "female_vampire": "🧛♀️", + "female_vampire::skin-tone-2": "🧛🏻♀️", + "female_vampire::skin-tone-3": "🧛🏼♀️", + "female_vampire::skin-tone-4": "🧛🏽♀️", + "female_vampire::skin-tone-5": "🧛🏾♀️", + "female_vampire::skin-tone-6": "🧛🏿♀️", + "merperson": "🧜", + "merperson::skin-tone-2": "🧜🏻", + "merperson::skin-tone-3": "🧜🏼", + "merperson::skin-tone-4": "🧜🏽", + "merperson::skin-tone-5": "🧜🏾", + "merperson::skin-tone-6": "🧜🏿", + "merman": "🧜♂️", + "merman::skin-tone-2": "🧜🏻♂️", + "merman::skin-tone-3": "🧜🏼♂️", + "merman::skin-tone-4": "🧜🏽♂️", + "merman::skin-tone-5": "🧜🏾♂️", + "merman::skin-tone-6": "🧜🏿♂️", + "mermaid": "🧜♀️", + "mermaid::skin-tone-2": "🧜🏻♀️", + "mermaid::skin-tone-3": "🧜🏼♀️", + "mermaid::skin-tone-4": "🧜🏽♀️", + "mermaid::skin-tone-5": "🧜🏾♀️", + "mermaid::skin-tone-6": "🧜🏿♀️", + "elf": "🧝", + "elf::skin-tone-2": "🧝🏻", + "elf::skin-tone-3": "🧝🏼", + "elf::skin-tone-4": "🧝🏽", + "elf::skin-tone-5": "🧝🏾", + "elf::skin-tone-6": "🧝🏿", + "male_elf": "🧝♂️", + "male_elf::skin-tone-2": "🧝🏻♂️", + "male_elf::skin-tone-3": "🧝🏼♂️", + "male_elf::skin-tone-4": "🧝🏽♂️", + "male_elf::skin-tone-5": "🧝🏾♂️", + "male_elf::skin-tone-6": "🧝🏿♂️", + "female_elf": "🧝♀️", + "female_elf::skin-tone-2": "🧝🏻♀️", + "female_elf::skin-tone-3": "🧝🏼♀️", + "female_elf::skin-tone-4": "🧝🏽♀️", + "female_elf::skin-tone-5": "🧝🏾♀️", + "female_elf::skin-tone-6": "🧝🏿♀️", + "genie": "🧞", + "male_genie": "🧞♂️", + "female_genie": "🧞♀️", + "zombie": "🧟", + "male_zombie": "🧟♂️", + "female_zombie": "🧟♀️", + "troll": "🧌", + "massage": "💆", + "massage::skin-tone-2": "💆🏻", + "massage::skin-tone-3": "💆🏼", + "massage::skin-tone-4": "💆🏽", + "massage::skin-tone-5": "💆🏾", + "massage::skin-tone-6": "💆🏿", + "man-getting-massage": "💆♂️", + "man-getting-massage::skin-tone-2": "💆🏻♂️", + "man-getting-massage::skin-tone-3": "💆🏼♂️", + "man-getting-massage::skin-tone-4": "💆🏽♂️", + "man-getting-massage::skin-tone-5": "💆🏾♂️", + "man-getting-massage::skin-tone-6": "💆🏿♂️", + "woman-getting-massage": "💆♀️", + "woman-getting-massage::skin-tone-2": "💆🏻♀️", + "woman-getting-massage::skin-tone-3": "💆🏼♀️", + "woman-getting-massage::skin-tone-4": "💆🏽♀️", + "woman-getting-massage::skin-tone-5": "💆🏾♀️", + "woman-getting-massage::skin-tone-6": "💆🏿♀️", + "haircut": "💇", + "haircut::skin-tone-2": "💇🏻", + "haircut::skin-tone-3": "💇🏼", + "haircut::skin-tone-4": "💇🏽", + "haircut::skin-tone-5": "💇🏾", + "haircut::skin-tone-6": "💇🏿", + "man-getting-haircut": "💇♂️", + "man-getting-haircut::skin-tone-2": "💇🏻♂️", + "man-getting-haircut::skin-tone-3": "💇🏼♂️", + "man-getting-haircut::skin-tone-4": "💇🏽♂️", + "man-getting-haircut::skin-tone-5": "💇🏾♂️", + "man-getting-haircut::skin-tone-6": "💇🏿♂️", + "woman-getting-haircut": "💇♀️", + "woman-getting-haircut::skin-tone-2": "💇🏻♀️", + "woman-getting-haircut::skin-tone-3": "💇🏼♀️", + "woman-getting-haircut::skin-tone-4": "💇🏽♀️", + "woman-getting-haircut::skin-tone-5": "💇🏾♀️", + "woman-getting-haircut::skin-tone-6": "💇🏿♀️", + "lfg": "🚶", + "walking": "🚶", + "walking::skin-tone-2": "🚶🏻", + "walking::skin-tone-3": "🚶🏼", + "walking::skin-tone-4": "🚶🏽", + "walking::skin-tone-5": "🚶🏾", + "walking::skin-tone-6": "🚶🏿", + "man-walking": "🚶♂️", + "man-walking::skin-tone-2": "🚶🏻♂️", + "man-walking::skin-tone-3": "🚶🏼♂️", + "man-walking::skin-tone-4": "🚶🏽♂️", + "man-walking::skin-tone-5": "🚶🏾♂️", + "man-walking::skin-tone-6": "🚶🏿♂️", + "woman-walking": "🚶♀️", + "woman-walking::skin-tone-2": "🚶🏻♀️", + "woman-walking::skin-tone-3": "🚶🏼♀️", + "woman-walking::skin-tone-4": "🚶🏽♀️", + "woman-walking::skin-tone-5": "🚶🏾♀️", + "woman-walking::skin-tone-6": "🚶🏿♀️", + "standing_person": "🧍", + "standing_person::skin-tone-2": "🧍🏻", + "standing_person::skin-tone-3": "🧍🏼", + "standing_person::skin-tone-4": "🧍🏽", + "standing_person::skin-tone-5": "🧍🏾", + "standing_person::skin-tone-6": "🧍🏿", + "man_standing": "🧍♂️", + "man_standing::skin-tone-2": "🧍🏻♂️", + "man_standing::skin-tone-3": "🧍🏼♂️", + "man_standing::skin-tone-4": "🧍🏽♂️", + "man_standing::skin-tone-5": "🧍🏾♂️", + "man_standing::skin-tone-6": "🧍🏿♂️", + "woman_standing": "🧍♀️", + "woman_standing::skin-tone-2": "🧍🏻♀️", + "woman_standing::skin-tone-3": "🧍🏼♀️", + "woman_standing::skin-tone-4": "🧍🏽♀️", + "woman_standing::skin-tone-5": "🧍🏾♀️", + "woman_standing::skin-tone-6": "🧍🏿♀️", + "kneeling_person": "🧎", + "kneeling_person::skin-tone-2": "🧎🏻", + "kneeling_person::skin-tone-3": "🧎🏼", + "kneeling_person::skin-tone-4": "🧎🏽", + "kneeling_person::skin-tone-5": "🧎🏾", + "kneeling_person::skin-tone-6": "🧎🏿", + "man_kneeling": "🧎♂️", + "man_kneeling::skin-tone-2": "🧎🏻♂️", + "man_kneeling::skin-tone-3": "🧎🏼♂️", + "man_kneeling::skin-tone-4": "🧎🏽♂️", + "man_kneeling::skin-tone-5": "🧎🏾♂️", + "man_kneeling::skin-tone-6": "🧎🏿♂️", + "woman_kneeling": "🧎♀️", + "woman_kneeling::skin-tone-2": "🧎🏻♀️", + "woman_kneeling::skin-tone-3": "🧎🏼♀️", + "woman_kneeling::skin-tone-4": "🧎🏽♀️", + "woman_kneeling::skin-tone-5": "🧎🏾♀️", + "woman_kneeling::skin-tone-6": "🧎🏿♀️", + "person_with_probing_cane": "🧑🦯", + "person_with_probing_cane::skin-tone-2": "🧑🏻🦯", + "person_with_probing_cane::skin-tone-3": "🧑🏼🦯", + "person_with_probing_cane::skin-tone-4": "🧑🏽🦯", + "person_with_probing_cane::skin-tone-5": "🧑🏾🦯", + "person_with_probing_cane::skin-tone-6": "🧑🏿🦯", + "man_with_probing_cane": "👨🦯", + "man_with_probing_cane::skin-tone-2": "👨🏻🦯", + "man_with_probing_cane::skin-tone-3": "👨🏼🦯", + "man_with_probing_cane::skin-tone-4": "👨🏽🦯", + "man_with_probing_cane::skin-tone-5": "👨🏾🦯", + "man_with_probing_cane::skin-tone-6": "👨🏿🦯", + "woman_with_probing_cane": "👩🦯", + "woman_with_probing_cane::skin-tone-2": "👩🏻🦯", + "woman_with_probing_cane::skin-tone-3": "👩🏼🦯", + "woman_with_probing_cane::skin-tone-4": "👩🏽🦯", + "woman_with_probing_cane::skin-tone-5": "👩🏾🦯", + "woman_with_probing_cane::skin-tone-6": "👩🏿🦯", + "person_in_motorized_wheelchair": "🧑🦼", + "person_in_motorized_wheelchair::skin-tone-2": "🧑🏻🦼", + "person_in_motorized_wheelchair::skin-tone-3": "🧑🏼🦼", + "person_in_motorized_wheelchair::skin-tone-4": "🧑🏽🦼", + "person_in_motorized_wheelchair::skin-tone-5": "🧑🏾🦼", + "person_in_motorized_wheelchair::skin-tone-6": "🧑🏿🦼", + "man_in_motorized_wheelchair": "👨🦼", + "man_in_motorized_wheelchair::skin-tone-2": "👨🏻🦼", + "man_in_motorized_wheelchair::skin-tone-3": "👨🏼🦼", + "man_in_motorized_wheelchair::skin-tone-4": "👨🏽🦼", + "man_in_motorized_wheelchair::skin-tone-5": "👨🏾🦼", + "man_in_motorized_wheelchair::skin-tone-6": "👨🏿🦼", + "woman_in_motorized_wheelchair": "👩🦼", + "woman_in_motorized_wheelchair::skin-tone-2": "👩🏻🦼", + "woman_in_motorized_wheelchair::skin-tone-3": "👩🏼🦼", + "woman_in_motorized_wheelchair::skin-tone-4": "👩🏽🦼", + "woman_in_motorized_wheelchair::skin-tone-5": "👩🏾🦼", + "woman_in_motorized_wheelchair::skin-tone-6": "👩🏿🦼", + "person_in_manual_wheelchair": "🧑🦽", + "person_in_manual_wheelchair::skin-tone-2": "🧑🏻🦽", + "person_in_manual_wheelchair::skin-tone-3": "🧑🏼🦽", + "person_in_manual_wheelchair::skin-tone-4": "🧑🏽🦽", + "person_in_manual_wheelchair::skin-tone-5": "🧑🏾🦽", + "person_in_manual_wheelchair::skin-tone-6": "🧑🏿🦽", + "man_in_manual_wheelchair": "👨🦽", + "man_in_manual_wheelchair::skin-tone-2": "👨🏻🦽", + "man_in_manual_wheelchair::skin-tone-3": "👨🏼🦽", + "man_in_manual_wheelchair::skin-tone-4": "👨🏽🦽", + "man_in_manual_wheelchair::skin-tone-5": "👨🏾🦽", + "man_in_manual_wheelchair::skin-tone-6": "👨🏿🦽", + "woman_in_manual_wheelchair": "👩🦽", + "woman_in_manual_wheelchair::skin-tone-2": "👩🏻🦽", + "woman_in_manual_wheelchair::skin-tone-3": "👩🏼🦽", + "woman_in_manual_wheelchair::skin-tone-4": "👩🏽🦽", + "woman_in_manual_wheelchair::skin-tone-5": "👩🏾🦽", + "woman_in_manual_wheelchair::skin-tone-6": "👩🏿🦽", + "runner": "🏃", + "running": "🏃", + "runner::skin-tone-2": "🏃🏻", + "running::skin-tone-2": "🏃🏻", + "runner::skin-tone-3": "🏃🏼", + "running::skin-tone-3": "🏃🏼", + "runner::skin-tone-4": "🏃🏽", + "running::skin-tone-4": "🏃🏽", + "runner::skin-tone-5": "🏃🏾", + "running::skin-tone-5": "🏃🏾", + "runner::skin-tone-6": "🏃🏿", + "running::skin-tone-6": "🏃🏿", + "man-running": "🏃♂️", + "man-running::skin-tone-2": "🏃🏻♂️", + "man-running::skin-tone-3": "🏃🏼♂️", + "man-running::skin-tone-4": "🏃🏽♂️", + "man-running::skin-tone-5": "🏃🏾♂️", + "man-running::skin-tone-6": "🏃🏿♂️", + "woman-running": "🏃♀️", + "woman-running::skin-tone-2": "🏃🏻♀️", + "woman-running::skin-tone-3": "🏃🏼♀️", + "woman-running::skin-tone-4": "🏃🏽♀️", + "woman-running::skin-tone-5": "🏃🏾♀️", + "woman-running::skin-tone-6": "🏃🏿♀️", + "dancer": "💃", + "dancer::skin-tone-2": "💃🏻", + "dancer::skin-tone-3": "💃🏼", + "dancer::skin-tone-4": "💃🏽", + "dancer::skin-tone-5": "💃🏾", + "dancer::skin-tone-6": "💃🏿", + "man_dancing": "🕺", + "man_dancing::skin-tone-2": "🕺🏻", + "man_dancing::skin-tone-3": "🕺🏼", + "man_dancing::skin-tone-4": "🕺🏽", + "man_dancing::skin-tone-5": "🕺🏾", + "man_dancing::skin-tone-6": "🕺🏿", + "man_in_business_suit_levitating": "🕴️", + "man_in_business_suit_levitating::skin-tone-2": "🕴🏻", + "man_in_business_suit_levitating::skin-tone-3": "🕴🏼", + "man_in_business_suit_levitating::skin-tone-4": "🕴🏽", + "man_in_business_suit_levitating::skin-tone-5": "🕴🏾", + "man_in_business_suit_levitating::skin-tone-6": "🕴🏿", + "dancers": "👯", + "men-with-bunny-ears-partying": "👯♂️", + "man-with-bunny-ears-partying": "👯♂️", + "women-with-bunny-ears-partying": "👯♀️", + "woman-with-bunny-ears-partying": "👯♀️", + "person_in_steamy_room": "🧖", + "person_in_steamy_room::skin-tone-2": "🧖🏻", + "person_in_steamy_room::skin-tone-3": "🧖🏼", + "person_in_steamy_room::skin-tone-4": "🧖🏽", + "person_in_steamy_room::skin-tone-5": "🧖🏾", + "person_in_steamy_room::skin-tone-6": "🧖🏿", + "man_in_steamy_room": "🧖♂️", + "man_in_steamy_room::skin-tone-2": "🧖🏻♂️", + "man_in_steamy_room::skin-tone-3": "🧖🏼♂️", + "man_in_steamy_room::skin-tone-4": "🧖🏽♂️", + "man_in_steamy_room::skin-tone-5": "🧖🏾♂️", + "man_in_steamy_room::skin-tone-6": "🧖🏿♂️", + "woman_in_steamy_room": "🧖♀️", + "woman_in_steamy_room::skin-tone-2": "🧖🏻♀️", + "woman_in_steamy_room::skin-tone-3": "🧖🏼♀️", + "woman_in_steamy_room::skin-tone-4": "🧖🏽♀️", + "woman_in_steamy_room::skin-tone-5": "🧖🏾♀️", + "woman_in_steamy_room::skin-tone-6": "🧖🏿♀️", + "person_climbing": "🧗", + "person_climbing::skin-tone-2": "🧗🏻", + "person_climbing::skin-tone-3": "🧗🏼", + "person_climbing::skin-tone-4": "🧗🏽", + "person_climbing::skin-tone-5": "🧗🏾", + "person_climbing::skin-tone-6": "🧗🏿", + "man_climbing": "🧗♂️", + "man_climbing::skin-tone-2": "🧗🏻♂️", + "man_climbing::skin-tone-3": "🧗🏼♂️", + "man_climbing::skin-tone-4": "🧗🏽♂️", + "man_climbing::skin-tone-5": "🧗🏾♂️", + "man_climbing::skin-tone-6": "🧗🏿♂️", + "woman_climbing": "🧗♀️", + "woman_climbing::skin-tone-2": "🧗🏻♀️", + "woman_climbing::skin-tone-3": "🧗🏼♀️", + "woman_climbing::skin-tone-4": "🧗🏽♀️", + "woman_climbing::skin-tone-5": "🧗🏾♀️", + "woman_climbing::skin-tone-6": "🧗🏿♀️", + "fencer": "🤺", + "horse_racing": "🏇", + "horse_racing::skin-tone-2": "🏇🏻", + "horse_racing::skin-tone-3": "🏇🏼", + "horse_racing::skin-tone-4": "🏇🏽", + "horse_racing::skin-tone-5": "🏇🏾", + "horse_racing::skin-tone-6": "🏇🏿", + "skier": "⛷️", + "snowboarder": "🏂", + "snowboarder::skin-tone-2": "🏂🏻", + "snowboarder::skin-tone-3": "🏂🏼", + "snowboarder::skin-tone-4": "🏂🏽", + "snowboarder::skin-tone-5": "🏂🏾", + "snowboarder::skin-tone-6": "🏂🏿", + "golfer": "🏌️", + "golfer::skin-tone-2": "🏌🏻", + "golfer::skin-tone-3": "🏌🏼", + "golfer::skin-tone-4": "🏌🏽", + "golfer::skin-tone-5": "🏌🏾", + "golfer::skin-tone-6": "🏌🏿", + "man-golfing": "🏌️♂️", + "man-golfing::skin-tone-2": "🏌🏻♂️", + "man-golfing::skin-tone-3": "🏌🏼♂️", + "man-golfing::skin-tone-4": "🏌🏽♂️", + "man-golfing::skin-tone-5": "🏌🏾♂️", + "man-golfing::skin-tone-6": "🏌🏿♂️", + "woman-golfing": "🏌️♀️", + "woman-golfing::skin-tone-2": "🏌🏻♀️", + "woman-golfing::skin-tone-3": "🏌🏼♀️", + "woman-golfing::skin-tone-4": "🏌🏽♀️", + "woman-golfing::skin-tone-5": "🏌🏾♀️", + "woman-golfing::skin-tone-6": "🏌🏿♀️", + "surfer": "🏄", + "surfer::skin-tone-2": "🏄🏻", + "surfer::skin-tone-3": "🏄🏼", + "surfer::skin-tone-4": "🏄🏽", + "surfer::skin-tone-5": "🏄🏾", + "surfer::skin-tone-6": "🏄🏿", + "man-surfing": "🏄♂️", + "man-surfing::skin-tone-2": "🏄🏻♂️", + "man-surfing::skin-tone-3": "🏄🏼♂️", + "man-surfing::skin-tone-4": "🏄🏽♂️", + "man-surfing::skin-tone-5": "🏄🏾♂️", + "man-surfing::skin-tone-6": "🏄🏿♂️", + "woman-surfing": "🏄♀️", + "woman-surfing::skin-tone-2": "🏄🏻♀️", + "woman-surfing::skin-tone-3": "🏄🏼♀️", + "woman-surfing::skin-tone-4": "🏄🏽♀️", + "woman-surfing::skin-tone-5": "🏄🏾♀️", + "woman-surfing::skin-tone-6": "🏄🏿♀️", + "rowboat": "🚣", + "rowboat::skin-tone-2": "🚣🏻", + "rowboat::skin-tone-3": "🚣🏼", + "rowboat::skin-tone-4": "🚣🏽", + "rowboat::skin-tone-5": "🚣🏾", + "rowboat::skin-tone-6": "🚣🏿", + "man-rowing-boat": "🚣♂️", + "man-rowing-boat::skin-tone-2": "🚣🏻♂️", + "man-rowing-boat::skin-tone-3": "🚣🏼♂️", + "man-rowing-boat::skin-tone-4": "🚣🏽♂️", + "man-rowing-boat::skin-tone-5": "🚣🏾♂️", + "man-rowing-boat::skin-tone-6": "🚣🏿♂️", + "woman-rowing-boat": "🚣♀️", + "woman-rowing-boat::skin-tone-2": "🚣🏻♀️", + "woman-rowing-boat::skin-tone-3": "🚣🏼♀️", + "woman-rowing-boat::skin-tone-4": "🚣🏽♀️", + "woman-rowing-boat::skin-tone-5": "🚣🏾♀️", + "woman-rowing-boat::skin-tone-6": "🚣🏿♀️", + "swimmer": "🏊", + "swimmer::skin-tone-2": "🏊🏻", + "swimmer::skin-tone-3": "🏊🏼", + "swimmer::skin-tone-4": "🏊🏽", + "swimmer::skin-tone-5": "🏊🏾", + "swimmer::skin-tone-6": "🏊🏿", + "man-swimming": "🏊♂️", + "man-swimming::skin-tone-2": "🏊🏻♂️", + "man-swimming::skin-tone-3": "🏊🏼♂️", + "man-swimming::skin-tone-4": "🏊🏽♂️", + "man-swimming::skin-tone-5": "🏊🏾♂️", + "man-swimming::skin-tone-6": "🏊🏿♂️", + "woman-swimming": "🏊♀️", + "woman-swimming::skin-tone-2": "🏊🏻♀️", + "woman-swimming::skin-tone-3": "🏊🏼♀️", + "woman-swimming::skin-tone-4": "🏊🏽♀️", + "woman-swimming::skin-tone-5": "🏊🏾♀️", + "woman-swimming::skin-tone-6": "🏊🏿♀️", + "person_with_ball": "⛹️", + "person_with_ball::skin-tone-2": "⛹🏻", + "person_with_ball::skin-tone-3": "⛹🏼", + "person_with_ball::skin-tone-4": "⛹🏽", + "person_with_ball::skin-tone-5": "⛹🏾", + "person_with_ball::skin-tone-6": "⛹🏿", + "man-bouncing-ball": "⛹️♂️", + "man-bouncing-ball::skin-tone-2": "⛹🏻♂️", + "man-bouncing-ball::skin-tone-3": "⛹🏼♂️", + "man-bouncing-ball::skin-tone-4": "⛹🏽♂️", + "man-bouncing-ball::skin-tone-5": "⛹🏾♂️", + "man-bouncing-ball::skin-tone-6": "⛹🏿♂️", + "woman-bouncing-ball": "⛹️♀️", + "woman-bouncing-ball::skin-tone-2": "⛹🏻♀️", + "woman-bouncing-ball::skin-tone-3": "⛹🏼♀️", + "woman-bouncing-ball::skin-tone-4": "⛹🏽♀️", + "woman-bouncing-ball::skin-tone-5": "⛹🏾♀️", + "woman-bouncing-ball::skin-tone-6": "⛹🏿♀️", + "weight_lifter": "🏋️", + "weight_lifter::skin-tone-2": "🏋🏻", + "weight_lifter::skin-tone-3": "🏋🏼", + "weight_lifter::skin-tone-4": "🏋🏽", + "weight_lifter::skin-tone-5": "🏋🏾", + "weight_lifter::skin-tone-6": "🏋🏿", + "man-lifting-weights": "🏋️♂️", + "man-lifting-weights::skin-tone-2": "🏋🏻♂️", + "man-lifting-weights::skin-tone-3": "🏋🏼♂️", + "man-lifting-weights::skin-tone-4": "🏋🏽♂️", + "man-lifting-weights::skin-tone-5": "🏋🏾♂️", + "man-lifting-weights::skin-tone-6": "🏋🏿♂️", + "woman-lifting-weights": "🏋️♀️", + "woman-lifting-weights::skin-tone-2": "🏋🏻♀️", + "woman-lifting-weights::skin-tone-3": "🏋🏼♀️", + "woman-lifting-weights::skin-tone-4": "🏋🏽♀️", + "woman-lifting-weights::skin-tone-5": "🏋🏾♀️", + "woman-lifting-weights::skin-tone-6": "🏋🏿♀️", + "bicyclist": "🚴", + "bicyclist::skin-tone-2": "🚴🏻", + "bicyclist::skin-tone-3": "🚴🏼", + "bicyclist::skin-tone-4": "🚴🏽", + "bicyclist::skin-tone-5": "🚴🏾", + "bicyclist::skin-tone-6": "🚴🏿", + "man-biking": "🚴♂️", + "man-biking::skin-tone-2": "🚴🏻♂️", + "man-biking::skin-tone-3": "🚴🏼♂️", + "man-biking::skin-tone-4": "🚴🏽♂️", + "man-biking::skin-tone-5": "🚴🏾♂️", + "man-biking::skin-tone-6": "🚴🏿♂️", + "woman-biking": "🚴♀️", + "woman-biking::skin-tone-2": "🚴🏻♀️", + "woman-biking::skin-tone-3": "🚴🏼♀️", + "woman-biking::skin-tone-4": "🚴🏽♀️", + "woman-biking::skin-tone-5": "🚴🏾♀️", + "woman-biking::skin-tone-6": "🚴🏿♀️", + "mountain_bicyclist": "🚵", + "mountain_bicyclist::skin-tone-2": "🚵🏻", + "mountain_bicyclist::skin-tone-3": "🚵🏼", + "mountain_bicyclist::skin-tone-4": "🚵🏽", + "mountain_bicyclist::skin-tone-5": "🚵🏾", + "mountain_bicyclist::skin-tone-6": "🚵🏿", + "man-mountain-biking": "🚵♂️", + "man-mountain-biking::skin-tone-2": "🚵🏻♂️", + "man-mountain-biking::skin-tone-3": "🚵🏼♂️", + "man-mountain-biking::skin-tone-4": "🚵🏽♂️", + "man-mountain-biking::skin-tone-5": "🚵🏾♂️", + "man-mountain-biking::skin-tone-6": "🚵🏿♂️", + "woman-mountain-biking": "🚵♀️", + "woman-mountain-biking::skin-tone-2": "🚵🏻♀️", + "woman-mountain-biking::skin-tone-3": "🚵🏼♀️", + "woman-mountain-biking::skin-tone-4": "🚵🏽♀️", + "woman-mountain-biking::skin-tone-5": "🚵🏾♀️", + "woman-mountain-biking::skin-tone-6": "🚵🏿♀️", + "person_doing_cartwheel": "🤸", + "person_doing_cartwheel::skin-tone-2": "🤸🏻", + "person_doing_cartwheel::skin-tone-3": "🤸🏼", + "person_doing_cartwheel::skin-tone-4": "🤸🏽", + "person_doing_cartwheel::skin-tone-5": "🤸🏾", + "person_doing_cartwheel::skin-tone-6": "🤸🏿", + "man-cartwheeling": "🤸♂️", + "man-cartwheeling::skin-tone-2": "🤸🏻♂️", + "man-cartwheeling::skin-tone-3": "🤸🏼♂️", + "man-cartwheeling::skin-tone-4": "🤸🏽♂️", + "man-cartwheeling::skin-tone-5": "🤸🏾♂️", + "man-cartwheeling::skin-tone-6": "🤸🏿♂️", + "woman-cartwheeling": "🤸♀️", + "woman-cartwheeling::skin-tone-2": "🤸🏻♀️", + "woman-cartwheeling::skin-tone-3": "🤸🏼♀️", + "woman-cartwheeling::skin-tone-4": "🤸🏽♀️", + "woman-cartwheeling::skin-tone-5": "🤸🏾♀️", + "woman-cartwheeling::skin-tone-6": "🤸🏿♀️", + "wrestlers": "🤼", + "man-wrestling": "🤼♂️", + "woman-wrestling": "🤼♀️", + "water_polo": "🤽", + "water_polo::skin-tone-2": "🤽🏻", + "water_polo::skin-tone-3": "🤽🏼", + "water_polo::skin-tone-4": "🤽🏽", + "water_polo::skin-tone-5": "🤽🏾", + "water_polo::skin-tone-6": "🤽🏿", + "man-playing-water-polo": "🤽♂️", + "man-playing-water-polo::skin-tone-2": "🤽🏻♂️", + "man-playing-water-polo::skin-tone-3": "🤽🏼♂️", + "man-playing-water-polo::skin-tone-4": "🤽🏽♂️", + "man-playing-water-polo::skin-tone-5": "🤽🏾♂️", + "man-playing-water-polo::skin-tone-6": "🤽🏿♂️", + "woman-playing-water-polo": "🤽♀️", + "woman-playing-water-polo::skin-tone-2": "🤽🏻♀️", + "woman-playing-water-polo::skin-tone-3": "🤽🏼♀️", + "woman-playing-water-polo::skin-tone-4": "🤽🏽♀️", + "woman-playing-water-polo::skin-tone-5": "🤽🏾♀️", + "woman-playing-water-polo::skin-tone-6": "🤽🏿♀️", + "handball": "🤾", + "handball::skin-tone-2": "🤾🏻", + "handball::skin-tone-3": "🤾🏼", + "handball::skin-tone-4": "🤾🏽", + "handball::skin-tone-5": "🤾🏾", + "handball::skin-tone-6": "🤾🏿", + "man-playing-handball": "🤾♂️", + "man-playing-handball::skin-tone-2": "🤾🏻♂️", + "man-playing-handball::skin-tone-3": "🤾🏼♂️", + "man-playing-handball::skin-tone-4": "🤾🏽♂️", + "man-playing-handball::skin-tone-5": "🤾🏾♂️", + "man-playing-handball::skin-tone-6": "🤾🏿♂️", + "woman-playing-handball": "🤾♀️", + "woman-playing-handball::skin-tone-2": "🤾🏻♀️", + "woman-playing-handball::skin-tone-3": "🤾🏼♀️", + "woman-playing-handball::skin-tone-4": "🤾🏽♀️", + "woman-playing-handball::skin-tone-5": "🤾🏾♀️", + "woman-playing-handball::skin-tone-6": "🤾🏿♀️", + "juggling": "🤹", + "juggling::skin-tone-2": "🤹🏻", + "juggling::skin-tone-3": "🤹🏼", + "juggling::skin-tone-4": "🤹🏽", + "juggling::skin-tone-5": "🤹🏾", + "juggling::skin-tone-6": "🤹🏿", + "man-juggling": "🤹♂️", + "man-juggling::skin-tone-2": "🤹🏻♂️", + "man-juggling::skin-tone-3": "🤹🏼♂️", + "man-juggling::skin-tone-4": "🤹🏽♂️", + "man-juggling::skin-tone-5": "🤹🏾♂️", + "man-juggling::skin-tone-6": "🤹🏿♂️", + "woman-juggling": "🤹♀️", + "woman-juggling::skin-tone-2": "🤹🏻♀️", + "woman-juggling::skin-tone-3": "🤹🏼♀️", + "woman-juggling::skin-tone-4": "🤹🏽♀️", + "woman-juggling::skin-tone-5": "🤹🏾♀️", + "woman-juggling::skin-tone-6": "🤹🏿♀️", + "person_in_lotus_position": "🧘", + "person_in_lotus_position::skin-tone-2": "🧘🏻", + "person_in_lotus_position::skin-tone-3": "🧘🏼", + "person_in_lotus_position::skin-tone-4": "🧘🏽", + "person_in_lotus_position::skin-tone-5": "🧘🏾", + "person_in_lotus_position::skin-tone-6": "🧘🏿", + "man_in_lotus_position": "🧘♂️", + "man_in_lotus_position::skin-tone-2": "🧘🏻♂️", + "man_in_lotus_position::skin-tone-3": "🧘🏼♂️", + "man_in_lotus_position::skin-tone-4": "🧘🏽♂️", + "man_in_lotus_position::skin-tone-5": "🧘🏾♂️", + "man_in_lotus_position::skin-tone-6": "🧘🏿♂️", + "woman_in_lotus_position": "🧘♀️", + "woman_in_lotus_position::skin-tone-2": "🧘🏻♀️", + "woman_in_lotus_position::skin-tone-3": "🧘🏼♀️", + "woman_in_lotus_position::skin-tone-4": "🧘🏽♀️", + "woman_in_lotus_position::skin-tone-5": "🧘🏾♀️", + "woman_in_lotus_position::skin-tone-6": "🧘🏿♀️", + "bath": "🛀", + "bath::skin-tone-2": "🛀🏻", + "bath::skin-tone-3": "🛀🏼", + "bath::skin-tone-4": "🛀🏽", + "bath::skin-tone-5": "🛀🏾", + "bath::skin-tone-6": "🛀🏿", + "sleeping_accommodation": "🛌", + "sleeping_accommodation::skin-tone-2": "🛌🏻", + "sleeping_accommodation::skin-tone-3": "🛌🏼", + "sleeping_accommodation::skin-tone-4": "🛌🏽", + "sleeping_accommodation::skin-tone-5": "🛌🏾", + "sleeping_accommodation::skin-tone-6": "🛌🏿", + "people_holding_hands": "🧑🤝🧑", + "people_holding_hands::skin-tone-2": "🧑🏻🤝🧑🏻", + "people_holding_hands::skin-tone-3": "🧑🏼🤝🧑🏼", + "people_holding_hands::skin-tone-4": "🧑🏽🤝🧑🏽", + "people_holding_hands::skin-tone-5": "🧑🏾🤝🧑🏾", + "people_holding_hands::skin-tone-6": "🧑🏿🤝🧑🏿", + "two_women_holding_hands": "👭", + "women_holding_hands": "👭", + "two_women_holding_hands::skin-tone-2": "👭🏻", + "women_holding_hands::skin-tone-2": "👭🏻", + "two_women_holding_hands::skin-tone-3": "👭🏼", + "women_holding_hands::skin-tone-3": "👭🏼", + "two_women_holding_hands::skin-tone-4": "👭🏽", + "women_holding_hands::skin-tone-4": "👭🏽", + "two_women_holding_hands::skin-tone-5": "👭🏾", + "women_holding_hands::skin-tone-5": "👭🏾", + "two_women_holding_hands::skin-tone-6": "👭🏿", + "women_holding_hands::skin-tone-6": "👭🏿", + "man_and_woman_holding_hands": "👫", + "couple": "👫", + "man_and_woman_holding_hands::skin-tone-2": "👫🏻", + "couple::skin-tone-2": "👫🏻", + "man_and_woman_holding_hands::skin-tone-3": "👫🏼", + "couple::skin-tone-3": "👫🏼", + "man_and_woman_holding_hands::skin-tone-4": "👫🏽", + "couple::skin-tone-4": "👫🏽", + "man_and_woman_holding_hands::skin-tone-5": "👫🏾", + "couple::skin-tone-5": "👫🏾", + "man_and_woman_holding_hands::skin-tone-6": "👫🏿", + "couple::skin-tone-6": "👫🏿", + "two_men_holding_hands": "👬", + "men_holding_hands": "👬", + "two_men_holding_hands::skin-tone-2": "👬🏻", + "men_holding_hands::skin-tone-2": "👬🏻", + "two_men_holding_hands::skin-tone-3": "👬🏼", + "men_holding_hands::skin-tone-3": "👬🏼", + "two_men_holding_hands::skin-tone-4": "👬🏽", + "men_holding_hands::skin-tone-4": "👬🏽", + "two_men_holding_hands::skin-tone-5": "👬🏾", + "men_holding_hands::skin-tone-5": "👬🏾", + "two_men_holding_hands::skin-tone-6": "👬🏿", + "men_holding_hands::skin-tone-6": "👬🏿", + "couplekiss": "💏", + "couplekiss::skin-tone-2": "💏🏻", + "couplekiss::skin-tone-3": "💏🏼", + "couplekiss::skin-tone-4": "💏🏽", + "couplekiss::skin-tone-5": "💏🏾", + "couplekiss::skin-tone-6": "💏🏿", + "woman-kiss-man": "👩❤️💋👨", + "woman-kiss-man::skin-tone-2": "👩🏻❤️💋👨🏻", + "woman-kiss-man::skin-tone-3": "👩🏼❤️💋👨🏼", + "woman-kiss-man::skin-tone-4": "👩🏽❤️💋👨🏽", + "woman-kiss-man::skin-tone-5": "👩🏾❤️💋👨🏾", + "woman-kiss-man::skin-tone-6": "👩🏿❤️💋👨🏿", + "man-kiss-man": "👨❤️💋👨", + "man-kiss-man::skin-tone-2": "👨🏻❤️💋👨🏻", + "man-kiss-man::skin-tone-3": "👨🏼❤️💋👨🏼", + "man-kiss-man::skin-tone-4": "👨🏽❤️💋👨🏽", + "man-kiss-man::skin-tone-5": "👨🏾❤️💋👨🏾", + "man-kiss-man::skin-tone-6": "👨🏿❤️💋👨🏿", + "woman-kiss-woman": "👩❤️💋👩", + "woman-kiss-woman::skin-tone-2": "👩🏻❤️💋👩🏻", + "woman-kiss-woman::skin-tone-3": "👩🏼❤️💋👩🏼", + "woman-kiss-woman::skin-tone-4": "👩🏽❤️💋👩🏽", + "woman-kiss-woman::skin-tone-5": "👩🏾❤️💋👩🏾", + "woman-kiss-woman::skin-tone-6": "👩🏿❤️💋👩🏿", + "couple_with_heart": "💑", + "couple_with_heart::skin-tone-2": "💑🏻", + "couple_with_heart::skin-tone-3": "💑🏼", + "couple_with_heart::skin-tone-4": "💑🏽", + "couple_with_heart::skin-tone-5": "💑🏾", + "couple_with_heart::skin-tone-6": "💑🏿", + "woman-heart-man": "👩❤️👨", + "woman-heart-man::skin-tone-2": "👩🏻❤️👨🏻", + "woman-heart-man::skin-tone-3": "👩🏼❤️👨🏼", + "woman-heart-man::skin-tone-4": "👩🏽❤️👨🏽", + "woman-heart-man::skin-tone-5": "👩🏾❤️👨🏾", + "woman-heart-man::skin-tone-6": "👩🏿❤️👨🏿", + "man-heart-man": "👨❤️👨", + "man-heart-man::skin-tone-2": "👨🏻❤️👨🏻", + "man-heart-man::skin-tone-3": "👨🏼❤️👨🏼", + "man-heart-man::skin-tone-4": "👨🏽❤️👨🏽", + "man-heart-man::skin-tone-5": "👨🏾❤️👨🏾", + "man-heart-man::skin-tone-6": "👨🏿❤️👨🏿", + "woman-heart-woman": "👩❤️👩", + "woman-heart-woman::skin-tone-2": "👩🏻❤️👩🏻", + "woman-heart-woman::skin-tone-3": "👩🏼❤️👩🏼", + "woman-heart-woman::skin-tone-4": "👩🏽❤️👩🏽", + "woman-heart-woman::skin-tone-5": "👩🏾❤️👩🏾", + "woman-heart-woman::skin-tone-6": "👩🏿❤️👩🏿", + "family": "👪", + "man-woman-boy": "👨👩👦", + "man-woman-girl": "👨👩👧", + "man-woman-girl-boy": "👨👩👧👦", + "man-woman-boy-boy": "👨👩👦👦", + "man-woman-girl-girl": "👨👩👧👧", + "man-man-boy": "👨👨👦", + "man-man-girl": "👨👨👧", + "man-man-girl-boy": "👨👨👧👦", + "man-man-boy-boy": "👨👨👦👦", + "man-man-girl-girl": "👨👨👧👧", + "woman-woman-boy": "👩👩👦", + "woman-woman-girl": "👩👩👧", + "woman-woman-girl-boy": "👩👩👧👦", + "woman-woman-boy-boy": "👩👩👦👦", + "woman-woman-girl-girl": "👩👩👧👧", + "man-boy": "👨👦", + "man-boy-boy": "👨👦👦", + "man-girl": "👨👧", + "man-girl-boy": "👨👧👦", + "man-girl-girl": "👨👧👧", + "woman-boy": "👩👦", + "woman-boy-boy": "👩👦👦", + "woman-girl": "👩👧", + "woman-girl-boy": "👩👧👦", + "woman-girl-girl": "👩👧👧", + "speaking_head_in_silhouette": "🗣️", + "bust_in_silhouette": "👤", + "busts_in_silhouette": "👥", + "people_hugging": "🫂", + "footprints": "👣", + "monkey_face": "🐵", + "monkey": "🐒", + "gorilla": "🦍", + "orangutan": "🦧", + "dog": "🐶", + "dog2": "🐕", + "guide_dog": "🦮", + "service_dog": "🐕🦺", + "poodle": "🐩", + "wolf": "🐺", + "fox_face": "🦊", + "raccoon": "🦝", + "cat": "🐱", + "cat2": "🐈", + "black_cat": "🐈⬛", + "lion_face": "🦁", + "tiger": "🐯", + "tiger2": "🐅", + "leopard": "🐆", + "horse": "🐴", + "racehorse": "🐎", + "unicorn_face": "🦄", + "zebra_face": "🦓", + "deer": "🦌", + "bison": "🦬", + "cow": "🐮", + "ox": "🐂", + "water_buffalo": "🐃", + "cow2": "🐄", + "pig": "🐷", + "pig2": "🐖", + "boar": "🐗", + "pig_nose": "🐽", + "ram": "🐏", + "sheep": "🐑", + "goat": "🐐", + "dromedary_camel": "🐪", + "camel": "🐫", + "llama": "🦙", + "giraffe_face": "🦒", + "elephant": "🐘", + "mammoth": "🦣", + "rhinoceros": "🦏", + "hippopotamus": "🦛", + "mouse": "🐭", + "mouse2": "🐁", + "rat": "🐀", + "hamster": "🐹", + "rabbit": "🐰", + "rabbit2": "🐇", + "chipmunk": "🐿️", + "beaver": "🦫", + "hedgehog": "🦔", + "bat": "🦇", + "bear": "🐻", + "polar_bear": "🐻❄️", + "koala": "🐨", + "panda_face": "🐼", + "sloth": "🦥", + "otter": "🦦", + "skunk": "🦨", + "kangaroo": "🦘", + "badger": "🦡", + "feet": "🐾", + "paw_prints": "🐾", + "turkey": "🦃", + "chicken": "🐔", + "rooster": "🐓", + "hatching_chick": "🐣", + "baby_chick": "🐤", + "hatched_chick": "🐥", + "bird": "🐦", + "penguin": "🐧", + "dove_of_peace": "🕊️", + "eagle": "🦅", + "duck": "🦆", + "swan": "🦢", + "owl": "🦉", + "dodo": "🦤", + "feather": "🪶", + "flamingo": "🦩", + "peacock": "🦚", + "parrot": "🦜", + "frog": "🐸", + "crocodile": "🐊", + "turtle": "🐢", + "lizard": "🦎", + "snake": "🐍", + "dragon_face": "🐲", + "dragon": "🐉", + "sauropod": "🦕", + "t-rex": "🦖", + "philosoraptor": "🦖", + "whale": "🐳", + "whale2": "🐋", + "dolphin": "🐬", + "flipper": "🐬", + "seal": "🦭", + "fish": "🐟", + "tropical_fish": "🐠", + "blowfish": "🐡", + "shark": "🦈", + "octopus": "🐙", + "shell": "🐚", + "coral": "🪸", + "snail": "🐌", + "butterfly": "🦋", + "bug": "🐛", + "ant": "🐜", + "bee": "🐝", + "honeybee": "🐝", + "beetle": "🪲", + "ladybug": "🐞", + "lady_beetle": "🐞", + "cricket": "🦗", + "cockroach": "🪳", + "spider": "🕷️", + "spider_web": "🕸️", + "scorpion": "🦂", + "mosquito": "🦟", + "fly": "🪰", + "worm": "🪱", + "microbe": "🦠", + "bouquet": "💐", + "cherry_blossom": "🌸", + "white_flower": "💮", + "lotus": "🪷", + "rosette": "🏵️", + "rose": "🌹", + "wilted_flower": "🥀", + "hibiscus": "🌺", + "sunflower": "🌻", + "blossom": "🌼", + "tulip": "🌷", + "seedling": "🌱", + "potted_plant": "🪴", + "evergreen_tree": "🌲", + "deciduous_tree": "🌳", + "palm_tree": "🌴", + "cactus": "🌵", + "ear_of_rice": "🌾", + "herb": "🌿", + "shamrock": "☘️", + "four_leaf_clover": "🍀", + "maple_leaf": "🍁", + "fallen_leaf": "🍂", + "leaves": "🍃", + "empty_nest": "🪹", + "nest_with_eggs": "🪺", + "grapes": "🍇", + "melon": "🍈", + "watermelon": "🍉", + "tangerine": "🍊", + "lemon": "🍋", + "banana": "🍌", + "pineapple": "🍍", + "mango": "🥭", + "apple": "🍎", + "green_apple": "🍏", + "pear": "🍐", + "peach": "🍑", + "cherries": "🍒", + "strawberry": "🍓", + "blueberries": "🫐", + "kiwifruit": "🥝", + "tomato": "🍅", + "olive": "🫒", + "coconut": "🥥", + "avocado": "🥑", + "eggplant": "🍆", + "potato": "🥔", + "carrot": "🥕", + "corn": "🌽", + "hot_pepper": "🌶️", + "bell_pepper": "🫑", + "cucumber": "🥒", + "leafy_green": "🥬", + "broccoli": "🥦", + "garlic": "🧄", + "onion": "🧅", + "mushroom": "🍄", + "peanuts": "🥜", + "beans": "🫘", + "chestnut": "🌰", + "bread": "🍞", + "croissant": "🥐", + "baguette_bread": "🥖", + "flatbread": "🫓", + "pretzel": "🥨", + "bagel": "🥯", + "pancakes": "🥞", + "waffle": "🧇", + "cheese_wedge": "🧀", + "meat_on_bone": "🍖", + "poultry_leg": "🍗", + "cut_of_meat": "🥩", + "bacon": "🥓", + "hamburger": "🍔", + "fries": "🍟", + "pizza": "🍕", + "hotdog": "🌭", + "sandwich": "🥪", + "taco": "🌮", + "burrito": "🌯", + "tamale": "🫔", + "stuffed_flatbread": "🥙", + "falafel": "🧆", + "egg": "🥚", + "fried_egg": "🍳", + "cooking": "🍳", + "shallow_pan_of_food": "🥘", + "stew": "🍲", + "fondue": "🫕", + "bowl_with_spoon": "🥣", + "green_salad": "🥗", + "popcorn": "🍿", + "butter": "🧈", + "salt": "🧂", + "canned_food": "🥫", + "bento": "🍱", + "rice_cracker": "🍘", + "rice_ball": "🍙", + "rice": "🍚", + "curry": "🍛", + "ramen": "🍜", + "spaghetti": "🍝", + "sweet_potato": "🍠", + "oden": "🍢", + "sushi": "🍣", + "fried_shrimp": "🍤", + "fish_cake": "🍥", + "moon_cake": "🥮", + "dango": "🍡", + "dumpling": "🥟", + "fortune_cookie": "🥠", + "takeout_box": "🥡", + "crab": "🦀", + "lobster": "🦞", + "shrimp": "🦐", + "squid": "🦑", + "oyster": "🦪", + "icecream": "🍦", + "shaved_ice": "🍧", + "ice_cream": "🍨", + "doughnut": "🍩", + "cookie": "🍪", + "birthday": "🎂", + "cake": "🍰", + "cupcake": "🧁", + "pie": "🥧", + "chocolate_bar": "🍫", + "candy": "🍬", + "lollipop": "🍭", + "custard": "🍮", + "honey_pot": "🍯", + "baby_bottle": "🍼", + "glass_of_milk": "🥛", + "coffee": "☕", + "teapot": "🫖", + "tea": "🍵", + "sake": "🍶", + "champagne": "🍾", + "wine_glass": "🍷", + "cocktail": "🍸", + "tropical_drink": "🍹", + "beer": "🍺", + "beers": "🍻", + "clinking_glasses": "🥂", + "tumbler_glass": "🥃", + "pouring_liquid": "🫗", + "cup_with_straw": "🥤", + "bubble_tea": "🧋", + "beverage_box": "🧃", + "mate_drink": "🧉", + "ice_cube": "🧊", + "chopsticks": "🥢", + "knife_fork_plate": "🍽️", + "fork_and_knife": "🍴", + "spoon": "🥄", + "hocho": "🔪", + "knife": "🔪", + "jar": "🫙", + "amphora": "🏺", + "earth_africa": "🌍", + "earth_americas": "🌎", + "earth_asia": "🌏", + "globe_with_meridians": "🌐", + "world_map": "🗺️", + "japan": "🗾", + "compass": "🧭", + "snow_capped_mountain": "🏔️", + "mountain": "⛰️", + "volcano": "🌋", + "mount_fuji": "🗻", + "camping": "🏕️", + "beach_with_umbrella": "🏖️", + "desert": "🏜️", + "desert_island": "🏝️", + "national_park": "🏞️", + "stadium": "🏟️", + "classical_building": "🏛️", + "building_construction": "🏗️", + "bricks": "🧱", + "databricks": "🧱", + "pydata": "🪨", + "rock": "🪨", + "wood": "🪵", + "hut": "🛖", + "house_buildings": "🏘️", + "derelict_house_building": "🏚️", + "house": "🏠", + "house_with_garden": "🏡", + "office": "🏢", + "post_office": "🏣", + "european_post_office": "🏤", + "hospital": "🏥", + "bank": "🏦", + "hotel": "🏨", + "love_hotel": "🏩", + "convenience_store": "🏪", + "school": "🏫", + "department_store": "🏬", + "factory": "🏭", + "japanese_castle": "🏯", + "european_castle": "🏰", + "wedding": "💒", + "tokyo_tower": "🗼", + "statue_of_liberty": "🗽", + "church": "⛪", + "mosque": "🕌", + "hindu_temple": "🛕", + "synagogue": "🕍", + "shinto_shrine": "⛩️", + "kaaba": "🕋", + "fountain": "⛲", + "tent": "⛺", + "foggy": "🌁", + "night_with_stars": "🌃", + "cityscape": "🏙️", + "sunrise_over_mountains": "🌄", + "sunrise": "🌅", + "city_sunset": "🌆", + "city_sunrise": "🌇", + "bridge_at_night": "🌉", + "hotsprings": "♨️", + "carousel_horse": "🎠", + "playground_slide": "🛝", + "ferris_wheel": "🎡", + "roller_coaster": "🎢", + "barber": "💈", + "circus_tent": "🎪", + "steam_locomotive": "🚂", + "railway_car": "🚃", + "bullettrain_side": "🚄", + "bullettrain_front": "🚅", + "train2": "🚆", + "metro": "🚇", + "light_rail": "🚈", + "station": "🚉", + "tram": "🚊", + "monorail": "🚝", + "mountain_railway": "🚞", + "train": "🚋", + "bus": "🚌", + "oncoming_bus": "🚍", + "trolleybus": "🚎", + "minibus": "🚐", + "ambulance": "🚑", + "fire_engine": "🚒", + "police_car": "🚓", + "oncoming_police_car": "🚔", + "taxi": "🚕", + "oncoming_taxi": "🚖", + "car": "🚗", + "red_car": "🚗", + "oncoming_automobile": "🚘", + "blue_car": "🚙", + "pickup_truck": "🛻", + "truck": "🚚", + "articulated_lorry": "🚛", + "tractor": "🚜", + "racing_car": "🏎️", + "racing_motorcycle": "🏍️", + "motor_scooter": "🛵", + "manual_wheelchair": "🦽", + "motorized_wheelchair": "🦼", + "auto_rickshaw": "🛺", + "bike": "🚲", + "scooter": "🛴", + "skateboard": "🛹", + "roller_skate": "🛼", + "busstop": "🚏", + "motorway": "🛣️", + "railway_track": "🛤️", + "oil_drum": "🛢️", + "fuelpump": "⛽", + "wheel": "🛞", + "rotating_light": "🚨", + "traffic_light": "🚥", + "vertical_traffic_light": "🚦", + "octagonal_sign": "🛑", + "construction": "🚧", + "anchor": "⚓", + "ring_buoy": "🛟", + "boat": "⛵", + "sailboat": "⛵", + "canoe": "🛶", + "speedboat": "🚤", + "passenger_ship": "🛳️", + "ferry": "⛴️", + "motor_boat": "🛥️", + "ship": "🚢", + "airplane": "✈️", + "small_airplane": "🛩️", + "airplane_departure": "🛫", + "airplane_arriving": "🛬", + "parachute": "🪂", + "seat": "💺", + "helicopter": "🚁", + "suspension_railway": "🚟", + "mountain_cableway": "🚠", + "aerial_tramway": "🚡", + "satellite": "🛰️", + "rocket": "🚀", + "rocketing": "🚀", + "rocking": "🚀", + "ahhhhhhhhh": "🚀", + "flying_saucer": "🛸", + "bellhop_bell": "🛎️", + "luggage": "🧳", + "hourglass": "⌛", + "hourglass_flowing_sand": "⏳", + "watch": "⌚", + "alarm_clock": "⏰", + "stopwatch": "⏱️", + "timer_clock": "⏲️", + "mantelpiece_clock": "🕰️", + "clock12": "🕛", + "clock1230": "🕧", + "clock1": "🕐", + "clock130": "🕜", + "clock2": "🕑", + "clock230": "🕝", + "clock3": "🕒", + "clock330": "🕞", + "clock4": "🕓", + "clock430": "🕟", + "clock5": "🕔", + "clock530": "🕠", + "clock6": "🕕", + "clock630": "🕡", + "clock7": "🕖", + "clock730": "🕢", + "clock8": "🕗", + "clock830": "🕣", + "clock9": "🕘", + "clock930": "🕤", + "clock10": "🕙", + "clock1030": "🕥", + "clock11": "🕚", + "clock1130": "🕦", + "new_moon": "🌑", + "waxing_crescent_moon": "🌒", + "first_quarter_moon": "🌓", + "moon": "🌔", + "waxing_gibbous_moon": "🌔", + "full_moon": "🌕", + "waning_gibbous_moon": "🌖", + "last_quarter_moon": "🌗", + "waning_crescent_moon": "🌘", + "crescent_moon": "🌙", + "new_moon_with_face": "🌚", + "first_quarter_moon_with_face": "🌛", + "last_quarter_moon_with_face": "🌜", + "thermometer": "🌡️", + "sunny": "☀️", + "full_moon_with_face": "🌝", + "sun_with_face": "🌞", + "ringed_planet": "🪐", + "star": "⭐", + "star2": "🌟", + "stars": "🌠", + "milky_way": "🌌", + "cloud": "☁️", + "partly_sunny": "⛅", + "thunder_cloud_and_rain": "⛈️", + "mostly_sunny": "🌤️", + "sun_small_cloud": "🌤️", + "barely_sunny": "🌥️", + "sun_behind_cloud": "🌥️", + "partly_sunny_rain": "🌦️", + "sun_behind_rain_cloud": "🌦️", + "rain_cloud": "🌧️", + "snow_cloud": "🌨️", + "lightning": "🌩️", + "lightning_cloud": "🌩️", + "tornado": "🌪️", + "tornado_cloud": "🌪️", + "fog": "🌫️", + "wind_blowing_face": "🌬️", + "cyclone": "🌀", + "rainbow": "🌈", + "rainbow-daggy": "🌈", + "closed_umbrella": "🌂", + "umbrella": "☂️", + "umbrella_with_rain_drops": "☔", + "umbrella_on_ground": "⛱️", + "zap": "⚡", + "snowflake": "❄️", + "snowman": "☃️", + "snowman_without_snow": "⛄", + "comet": "☄️", + "fire": "🔥", + "tuzki_onfire": "🔥", + "tuzki-onfire": "🔥", + "droplet": "💧", + "ocean": "🌊", + "jack_o_lantern": "🎃", + "christmas_tree": "🎄", + "fireworks": "🎆", + "sparkler": "🎇", + "firecracker": "🧨", + "sparkles": "✨", + "balloon": "🎈", + "tada": "🎉", + "confetti_ball": "🎊", + "tanabata_tree": "🎋", + "bamboo": "🎍", + "dolls": "🎎", + "flags": "🎏", + "wind_chime": "🎐", + "rice_scene": "🎑", + "red_envelope": "🧧", + "ribbon": "🎀", + "gift": "🎁", + "reminder_ribbon": "🎗️", + "admission_tickets": "🎟️", + "ticket": "🎫", + "medal": "🎖️", + "trophy": "🏆", + "sports_medal": "🏅", + "first_place_medal": "🥇", + "second_place_medal": "🥈", + "third_place_medal": "🥉", + "soccer": "⚽", + "baseball": "⚾", + "softball": "🥎", + "basketball": "🏀", + "volleyball": "🏐", + "football": "🏈", + "rugby_football": "🏉", + "tennis": "🎾", + "flying_disc": "🥏", + "bowling": "🎳", + "cricket_bat_and_ball": "🏏", + "field_hockey_stick_and_ball": "🏑", + "ice_hockey_stick_and_puck": "🏒", + "lacrosse": "🥍", + "table_tennis_paddle_and_ball": "🏓", + "badminton_racquet_and_shuttlecock": "🏸", + "boxing_glove": "🥊", + "martial_arts_uniform": "🥋", + "goal_net": "🥅", + "golf": "⛳", + "ice_skate": "⛸️", + "fishing_pole_and_fish": "🎣", + "diving_mask": "🤿", + "running_shirt_with_sash": "🎽", + "ski": "🎿", + "sled": "🛷", + "curling_stone": "🥌", + "dart": "🎯", + "yo-yo": "🪀", + "kite": "🪁", + "8ball": "🎱", + "crystal_ball": "🔮", + "magic_wand": "🪄", + "nazar_amulet": "🧿", + "hamsa": "🪬", + "video_game": "🎮", + "joystick": "🕹️", + "slot_machine": "🎰", + "game_die": "🎲", + "jigsaw": "🧩", + "teddy_bear": "🧸", + "pinata": "🪅", + "mirror_ball": "🪩", + "nesting_dolls": "🪆", + "spades": "♠️", + "hearts": "♥️", + "diamonds": "♦️", + "clubs": "♣️", + "chess_pawn": "♟️", + "black_joker": "🃏", + "mahjong": "🀄", + "flower_playing_cards": "🎴", + "performing_arts": "🎭", + "frame_with_picture": "🖼️", + "art": "🎨", + "thread": "🧵", + "sewing_needle": "🪡", + "yarn": "🧶", + "knot": "🪢", + "eyeglasses": "👓", + "dark_sunglasses": "🕶️", + "goggles": "🥽", + "lab_coat": "🥼", + "safety_vest": "🦺", + "necktie": "👔", + "shirt": "👕", + "tshirt": "👕", + "jeans": "👖", + "scarf": "🧣", + "gloves": "🧤", + "coat": "🧥", + "socks": "🧦", + "dress": "👗", + "kimono": "👘", + "sari": "🥻", + "one-piece_swimsuit": "🩱", + "briefs": "🩲", + "shorts": "🩳", + "bikini": "👙", + "womans_clothes": "👚", + "purse": "👛", + "handbag": "👜", + "pouch": "👝", + "shopping_bags": "🛍️", + "school_satchel": "🎒", + "thong_sandal": "🩴", + "mans_shoe": "👞", + "shoe": "👞", + "athletic_shoe": "👟", + "hiking_boot": "🥾", + "womans_flat_shoe": "🥿", + "high_heel": "👠", + "sandal": "👡", + "ballet_shoes": "🩰", + "boot": "👢", + "crown": "👑", + "womans_hat": "👒", + "tophat": "🎩", + "mortar_board": "🎓", + "billed_cap": "🧢", + "military_helmet": "🪖", + "helmet_with_white_cross": "⛑️", + "prayer_beads": "📿", + "lipstick": "💄", + "ring": "💍", + "gem": "💎", + "mute": "🔇", + "speaker": "🔈", + "sound": "🔉", + "loud_sound": "🔊", + "loudspeaker": "📢", + "mega": "📣", + "postal_horn": "📯", + "bell": "🔔", + "no_bell": "🔕", + "musical_score": "🎼", + "musical_note": "🎵", + "notes": "🎶", + "studio_microphone": "🎙️", + "level_slider": "🎚️", + "control_knobs": "🎛️", + "microphone": "🎤", + "headphones": "🎧", + "radio": "📻", + "saxophone": "🎷", + "accordion": "🪗", + "guitar": "🎸", + "musical_keyboard": "🎹", + "trumpet": "🎺", + "violin": "🎻", + "banjo": "🪕", + "drum_with_drumsticks": "🥁", + "long_drum": "🪘", + "iphone": "📱", + "calling": "📲", + "phone": "☎️", + "telephone": "☎️", + "telephone_receiver": "📞", + "pager": "📟", + "fax": "📠", + "battery": "🔋", + "low_battery": "🪫", + "electric_plug": "🔌", + "computer": "💻", + "desktop_computer": "🖥️", + "printer": "🖨️", + "keyboard": "⌨️", + "three_button_mouse": "🖱️", + "trackball": "🖲️", + "minidisc": "💽", + "floppy_disk": "💾", + "cd": "💿", + "dvd": "📀", + "abacus": "🧮", + "movie_camera": "🎥", + "film_frames": "🎞️", + "film_projector": "📽️", + "webassembly": ".wasm", + "clapper": "🎬", + "tv": "📺", + "camera": "📷", + "camera_with_flash": "📸", + "video_camera": "📹", + "vhs": "📼", + "mag": "🔍", + "mag_right": "🔎", + "candle": "🕯️", + "bulb": "💡", + "flashlight": "🔦", + "izakaya_lantern": "🏮", + "lantern": "🏮", + "diya_lamp": "🪔", + "notebook_with_decorative_cover": "📔", + "closed_book": "📕", + "book": "📖", + "open_book": "📖", + "green_book": "📗", + "blue_book": "📘", + "orange_book": "📙", + "books": "📚", + "notebook": "📓", + "ledger": "📒", + "page_with_curl": "📃", + "scroll": "📜", + "page_facing_up": "📄", + "newspaper": "📰", + "rolled_up_newspaper": "🗞️", + "bookmark_tabs": "📑", + "bookmark": "🔖", + "label": "🏷️", + "moneybag": "💰", + "coin": "🪙", + "yen": "💴", + "dollar": "💵", + "euro": "💶", + "pound": "💷", + "money_with_wings": "💸", + "money_flying": "💸", + "credit_card": "💳", + "receipt": "🧾", + "chart": "💹", + "email": "✉️", + "envelope": "✉️", + "e-mail": "📧", + "incoming_envelope": "📨", + "envelope_with_arrow": "📩", + "outbox_tray": "📤", + "inbox_tray": "📥", + "package": "📦", + "mailbox": "📫", + "mailbox_closed": "📪", + "mailbox_with_mail": "📬", + "mailbox_with_no_mail": "📭", + "postbox": "📮", + "ballot_box_with_ballot": "🗳️", + "pencil2": "✏️", + "black_nib": "✒️", + "lower_left_fountain_pen": "🖋️", + "lower_left_ballpoint_pen": "🖊️", + "lower_left_paintbrush": "🖌️", + "lower_left_crayon": "🖍️", + "memo": "📝", + "pencil": "📝", + "briefcase": "💼", + "file_folder": "📁", + "open_file_folder": "📂", + "card_index_dividers": "🗂️", + "date": "📅", + "calendar": "📆", + "spiral_note_pad": "🗒️", + "spiral_calendar_pad": "🗓️", + "card_index": "📇", + "chart_with_upwards_trend": "📈", + "chart_with_downwards_trend": "📉", + "bar_chart": "📊", + "clipboard": "📋", + "pushpin": "📌", + "kodee_pin": "📌", + "kodee-pin": "📌", + "round_pushpin": "📍", + "paperclip": "📎", + "linked_paperclips": "🖇️", + "straight_ruler": "📏", + "triangular_ruler": "📐", + "scissors": "✂️", + "card_file_box": "🗃️", + "file_cabinet": "🗄️", + "wastebasket": "🗑️", + "lock": "🔒", + "unlock": "🔓", + "lock_with_ink_pen": "🔏", + "closed_lock_with_key": "🔐", + "key": "🔑", + "old_key": "🗝️", + "hammer": "🔨", + "axe": "🪓", + "pick": "⛏️", + "hammer_and_pick": "⚒️", + "hammer_and_wrench": "🛠️", + "dagger_knife": "🗡️", + "crossed_swords": "⚔️", + "gun": "🔫", + "boomerang": "🪃", + "bow_and_arrow": "🏹", + "shield": "🛡️", + "carpentry_saw": "🪚", + "wrench": "🔧", + "screwdriver": "🪛", + "nut_and_bolt": "🔩", + "gear": "⚙️", + "compression": "🗜️", + "scales": "⚖️", + "probing_cane": "🦯", + "link": "🔗", + "chains": "⛓️", + "hook": "🪝", + "toolbox": "🧰", + "magnet": "🧲", + "ladder": "🪜", + "alembic": "⚗️", + "test_tube": "🧪", + "petri_dish": "🧫", + "dna": "🧬", + "microscope": "🔬", + "telescope": "🔭", + "satellite_antenna": "📡", + "syringe": "💉", + "drop_of_blood": "🩸", + "pill": "💊", + "adhesive_bandage": "🩹", + "crutch": "🩼", + "stethoscope": "🩺", + "x-ray": "🩻", + "door": "🚪", + "elevator": "🛗", + "mirror": "🪞", + "window": "🪟", + "bed": "🛏️", + "couch_and_lamp": "🛋️", + "chair": "🪑", + "toilet": "🚽", + "plunger": "🪠", + "shower": "🚿", + "bathtub": "🛁", + "mouse_trap": "🪤", + "razor": "🪒", + "lotion_bottle": "🧴", + "safety_pin": "🧷", + "broom": "🧹", + "basket": "🧺", + "roll_of_paper": "🧻", + "bucket": "🪣", + "soap": "🧼", + "bubbles": "🫧", + "toothbrush": "🪥", + "sponge": "🧽", + "fire_extinguisher": "🧯", + "shopping_trolley": "🛒", + "smoking": "🚬", + "coffin": "⚰️", + "headstone": "🪦", + "funeral_urn": "⚱️", + "moyai": "🗿", + "placard": "🪧", + "identification_card": "🪪", + "atm": "🏧", + "put_litter_in_its_place": "🚮", + "potable_water": "🚰", + "wheelchair": "♿", + "mens": "🚹", + "womens": "🚺", + "restroom": "🚻", + "baby_symbol": "🚼", + "wc": "🚾", + "passport_control": "🛂", + "customs": "🛃", + "baggage_claim": "🛄", + "left_luggage": "🛅", + "warning": "⚠️", + "children_crossing": "🚸", + "no_entry": "⛔", + "no_entry_sign": "🚫", + "no_bicycles": "🚳", + "no_smoking": "🚭", + "do_not_litter": "🚯", + "non-potable_water": "🚱", + "no_pedestrians": "🚷", + "no_mobile_phones": "📵", + "underage": "🔞", + "radioactive_sign": "☢️", + "biohazard_sign": "☣️", + "arrow_up": "⬆️", + "arrow_upper_right": "↗️", + "arrow_right": "➡️", + "arrow_lower_right": "↘️", + "arrow_down": "⬇️", + "arrow_lower_left": "↙️", + "arrow_left": "⬅️", + "arrow_upper_left": "↖️", + "arrow_up_down": "↕️", + "left_right_arrow": "↔️", + "leftwards_arrow_with_hook": "↩️", + "arrow_right_hook": "↪️", + "arrow_heading_up": "⤴️", + "arrow_heading_down": "⤵️", + "arrows_clockwise": "🔃", + "arrows_counterclockwise": "🔄", + "back": "🔙", + "end": "🔚", + "on": "🔛", + "soon": "🔜", + "top": "🔝", + "place_of_worship": "🛐", + "atom_symbol": "⚛️", + "om_symbol": "🕉️", + "star_of_david": "✡️", + "wheel_of_dharma": "☸️", + "yin_yang": "☯️", + "latin_cross": "✝️", + "orthodox_cross": "☦️", + "star_and_crescent": "☪️", + "peace_symbol": "☮️", + "menorah_with_nine_branches": "🕎", + "six_pointed_star": "🔯", + "aries": "♈", + "taurus": "♉", + "gemini": "♊", + "cancer": "♋", + "leo": "♌", + "virgo": "♍", + "libra": "♎", + "scorpius": "♏", + "sagittarius": "♐", + "capricorn": "♑", + "aquarius": "♒", + "pisces": "♓", + "ophiuchus": "⛎", + "twisted_rightwards_arrows": "🔀", + "repeat": "🔁", + "repeat_one": "🔂", + "arrow_forward": "▶️", + "fast_forward": "⏩", + "black_right_pointing_double_triangle_with_vertical_bar": "⏭️", + "black_right_pointing_triangle_with_double_vertical_bar": "⏯️", + "arrow_backward": "◀️", + "rewind": "⏪", + "black_left_pointing_double_triangle_with_vertical_bar": "⏮️", + "arrow_up_small": "🔼", + "arrow_double_up": "⏫", + "arrow_down_small": "🔽", + "arrow_double_down": "⏬", + "double_vertical_bar": "⏸️", + "black_square_for_stop": "⏹️", + "black_circle_for_record": "⏺️", + "eject": "⏏️", + "cinema": "🎦", + "low_brightness": "🔅", + "high_brightness": "🔆", + "signal_strength": "📶", + "vibration_mode": "📳", + "mobile_phone_off": "📴", + "female_sign": "♀️", + "male_sign": "♂️", + "transgender_symbol": "⚧️", + "heavy_multiplication_x": "✖️", + "heavy_plus_sign": "➕", + "heavy_minus_sign": "➖", + "heavy_division_sign": "➗", + "heavy_equals_sign": "🟰", + "infinity": "♾️", + "bangbang": "‼️", + "interrobang": "⁉️", + "question": "❓", + "grey_question": "❔", + "grey_exclamation": "❕", + "exclamation": "❗", + "heavy_exclamation_mark": "❗", + "wavy_dash": "〰️", + "currency_exchange": "💱", + "heavy_dollar_sign": "💲", + "medical_symbol": "⚕️", + "staff_of_aesculapius": "⚕️", + "recycle": "♻️", + "fleur_de_lis": "⚜️", + "trident": "🔱", + "name_badge": "📛", + "beginner": "🔰", + "o": "⭕", + "white_check_mark": "✅", + "ballot_box_with_check": "☑️", + "heavy_check_mark": "✔️", + "x": "❌", + "negative_squared_cross_mark": "❎", + "curly_loop": "➰", + "loop": "➿", + "part_alternation_mark": "〽️", + "eight_spoked_asterisk": "✳️", + "eight_pointed_black_star": "✴️", + "sparkle": "❇️", + "copyright": "©️", + "registered": "®️", + "tm": "™️", + "hash": "#️⃣", + "keycap_star": "*️⃣", + "zero": "0️⃣", + "one": "1️⃣", + "two": "2️⃣", + "three": "3️⃣", + "four": "4️⃣", + "five": "5️⃣", + "six": "6️⃣", + "seven": "7️⃣", + "eight": "8️⃣", + "nine": "9️⃣", + "keycap_ten": "🔟", + "capital_abcd": "🔠", + "fivetran": "5️⃣", + "abcd": "🔡", + "symbols": "🔣", + "abc": "🔤", + "a": "🅰️", + "ab": "🆎", + "b": "🅱️", + "cl": "🆑", + "cool": "🆒", + "free": "🆓", + "information_source": "ℹ️", + "id": "🆔", + "m": "Ⓜ️", + "new": "🆕", + "ng": "🆖", + "o2": "🅾️", + "ok": "🆗", + "parking": "🅿️", + "sos": "🆘", + "up": "🆙", + "vs": "🆚", + "koko": "🈁", + "sa": "🈂️", + "u6708": "🈷️", + "u6709": "🈶", + "u6307": "🈯", + "ideograph_advantage": "🉐", + "u5272": "🈹", + "u7121": "🈚", + "u7981": "🈲", + "accept": "🉑", + "u7533": "🈸", + "u5408": "🈴", + "u7a7a": "🈳", + "congratulations": "㊗️", + "secret": "㊙️", + "u55b6": "🈺", + "u6e80": "🈵", + "red_circle": "🔴", + "large_orange_circle": "🟠", + "large_yellow_circle": "🟡", + "large_green_circle": "🟢", + "large_blue_circle": "🔵", + "large_purple_circle": "🟣", + "large_brown_circle": "🟤", + "black_circle": "⚫", + "white_circle": "⚪", + "large_red_square": "🟥", + "large_orange_square": "🟧", + "large_yellow_square": "🟨", + "large_green_square": "🟩", + "large_blue_square": "🟦", + "large_purple_square": "🟪", + "large_brown_square": "🟫", + "black_large_square": "⬛", + "white_large_square": "⬜", + "black_medium_square": "◼️", + "white_medium_square": "◻️", + "black_medium_small_square": "◾", + "white_medium_small_square": "◽", + "black_small_square": "▪️", + "white_small_square": "▫️", + "large_orange_diamond": "🔶", + "large_blue_diamond": "🔷", + "small_orange_diamond": "🔸", + "small_blue_diamond": "🔹", + "small_red_triangle": "🔺", + "small_red_triangle_down": "🔻", + "diamond_shape_with_a_dot_inside": "💠", + "radio_button": "🔘", + "white_square_button": "🔳", + "black_square_button": "🔲", + "checkered_flag": "🏁", + "triangular_flag_on_post": "🚩", + "crossed_flags": "🎌", + "waving_black_flag": "🏴", + "waving_white_flag": "🏳️", + "rainbow-flag": "🏳️🌈", + "transgender_flag": "🏳️⚧️", + "pirate_flag": "🏴☠️", + "flag-ac": "🇦🇨", + "flag-ad": "🇦🇩", + "flag-ae": "🇦🇪", + "flag-af": "🇦🇫", + "flag-ag": "🇦🇬", + "flag-ai": "🇦🇮", + "flag-al": "🇦🇱", + "flag-am": "🇦🇲", + "flag-ao": "🇦🇴", + "flag-aq": "🇦🇶", + "flag-ar": "🇦🇷", + "flag-as": "🇦🇸", + "flag-at": "🇦🇹", + "flag-au": "🇦🇺", + "flag-aw": "🇦🇼", + "flag-ax": "🇦🇽", + "flag-az": "🇦🇿", + "flag-ba": "🇧🇦", + "flag-bb": "🇧🇧", + "flag-bd": "🇧🇩", + "flag-be": "🇧🇪", + "flag-bf": "🇧🇫", + "flag-bg": "🇧🇬", + "flag-bh": "🇧🇭", + "flag-bi": "🇧🇮", + "flag-bj": "🇧🇯", + "flag-bl": "🇧🇱", + "flag-bm": "🇧🇲", + "flag-bn": "🇧🇳", + "flag-bo": "🇧🇴", + "flag-bq": "🇧🇶", + "flag-br": "🇧🇷", + "flag-bs": "🇧🇸", + "flag-bt": "🇧🇹", + "flag-bv": "🇧🇻", + "flag-bw": "🇧🇼", + "flag-by": "🇧🇾", + "flag-bz": "🇧🇿", + "flag-ca": "🇨🇦", + "flag-cc": "🇨🇨", + "flag-cd": "🇨🇩", + "flag-cf": "🇨🇫", + "flag-cg": "🇨🇬", + "flag-ch": "🇨🇭", + "flag-ci": "🇨🇮", + "flag-ck": "🇨🇰", + "flag-cl": "🇨🇱", + "flag-cm": "🇨🇲", + "cn": "🇨🇳", + "flag-cn": "🇨🇳", + "flag-co": "🇨🇴", + "flag-cp": "🇨🇵", + "flag-cr": "🇨🇷", + "flag-cu": "🇨🇺", + "flag-cv": "🇨🇻", + "flag-cw": "🇨🇼", + "flag-cx": "🇨🇽", + "flag-cy": "🇨🇾", + "flag-cz": "🇨🇿", + "de": "🇩🇪", + "flag-de": "🇩🇪", + "flag-dg": "🇩🇬", + "flag-dj": "🇩🇯", + "flag-dk": "🇩🇰", + "flag-dm": "🇩🇲", + "flag-do": "🇩🇴", + "flag-dz": "🇩🇿", + "flag-ea": "🇪🇦", + "flag-ec": "🇪🇨", + "flag-ee": "🇪🇪", + "flag-eg": "🇪🇬", + "flag-eh": "🇪🇭", + "flag-er": "🇪🇷", + "es": "🇪🇸", + "flag-es": "🇪🇸", + "flag-et": "🇪🇹", + "flag-eu": "🇪🇺", + "flag-fi": "🇫🇮", + "flag-fj": "🇫🇯", + "flag-fk": "🇫🇰", + "flag-fm": "🇫🇲", + "flag-fo": "🇫🇴", + "fr": "🇫🇷", + "flag-fr": "🇫🇷", + "flag-ga": "🇬🇦", + "gb": "🇬🇧", + "flag-gb": "🇬🇧", + "flag-gd": "🇬🇩", + "flag-ge": "🇬🇪", + "flag-gf": "🇬🇫", + "flag-gg": "🇬🇬", + "flag-gh": "🇬🇭", + "flag-gi": "🇬🇮", + "flag-gl": "🇬🇱", + "flag-gm": "🇬🇲", + "flag-gn": "🇬🇳", + "flag-gp": "🇬🇵", + "flag-gq": "🇬🇶", + "flag-gr": "🇬🇷", + "flag-gs": "🇬🇸", + "flag-gt": "🇬🇹", + "flag-gu": "🇬🇺", + "flag-gw": "🇬🇼", + "flag-gy": "🇬🇾", + "flag-hk": "🇭🇰", + "flag-hm": "🇭🇲", + "flag-hn": "🇭🇳", + "flag-hr": "🇭🇷", + "flag-ht": "🇭🇹", + "flag-hu": "🇭🇺", + "flag-ic": "🇮🇨", + "flag-id": "🇮🇩", + "flag-ie": "🇮🇪", + "flag-il": "🇮🇱", + "flag-im": "🇮🇲", + "flag-in": "🇮🇳", + "flag-io": "🇮🇴", + "flag-iq": "🇮🇶", + "flag-ir": "🇮🇷", + "flag-is": "🇮🇸", + "it": "🇮🇹", + "flag-it": "🇮🇹", + "flag-je": "🇯🇪", + "flag-jm": "🇯🇲", + "flag-jo": "🇯🇴", + "jp": "🇯🇵", + "flag-jp": "🇯🇵", + "flag-ke": "🇰🇪", + "flag-kg": "🇰🇬", + "flag-kh": "🇰🇭", + "flag-ki": "🇰🇮", + "flag-km": "🇰🇲", + "flag-kn": "🇰🇳", + "flag-kp": "🇰🇵", + "kr": "🇰🇷", + "flag-kr": "🇰🇷", + "flag-kw": "🇰🇼", + "flag-ky": "🇰🇾", + "flag-kz": "🇰🇿", + "flag-la": "🇱🇦", + "flag-lb": "🇱🇧", + "flag-lc": "🇱🇨", + "flag-li": "🇱🇮", + "flag-lk": "🇱🇰", + "flag-lr": "🇱🇷", + "flag-ls": "🇱🇸", + "flag-lt": "🇱🇹", + "flag-lu": "🇱🇺", + "flag-lv": "🇱🇻", + "flag-ly": "🇱🇾", + "flag-ma": "🇲🇦", + "flag-mc": "🇲🇨", + "flag-md": "🇲🇩", + "flag-me": "🇲🇪", + "flag-mf": "🇲🇫", + "flag-mg": "🇲🇬", + "flag-mh": "🇲🇭", + "flag-mk": "🇲🇰", + "flag-ml": "🇲🇱", + "flag-mm": "🇲🇲", + "flag-mn": "🇲🇳", + "flag-mo": "🇲🇴", + "flag-mp": "🇲🇵", + "flag-mq": "🇲🇶", + "flag-mr": "🇲🇷", + "flag-ms": "🇲🇸", + "flag-mt": "🇲🇹", + "flag-mu": "🇲🇺", + "flag-mv": "🇲🇻", + "flag-mw": "🇲🇼", + "flag-mx": "🇲🇽", + "flag-my": "🇲🇾", + "flag-mz": "🇲🇿", + "flag-na": "🇳🇦", + "flag-nc": "🇳🇨", + "flag-ne": "🇳🇪", + "flag-nf": "🇳🇫", + "flag-ng": "🇳🇬", + "flag-ni": "🇳🇮", + "flag-nl": "🇳🇱", + "flag-no": "🇳🇴", + "flag-np": "🇳🇵", + "flag-nr": "🇳🇷", + "flag-nu": "🇳🇺", + "flag-nz": "🇳🇿", + "flag-om": "🇴🇲", + "flag-pa": "🇵🇦", + "flag-pe": "🇵🇪", + "flag-pf": "🇵🇫", + "flag-pg": "🇵🇬", + "flag-ph": "🇵🇭", + "flag-pk": "🇵🇰", + "flag-pl": "🇵🇱", + "flag-pm": "🇵🇲", + "flag-pn": "🇵🇳", + "flag-pr": "🇵🇷", + "flag-ps": "🇵🇸", + "flag-pt": "🇵🇹", + "flag-pw": "🇵🇼", + "flag-py": "🇵🇾", + "flag-qa": "🇶🇦", + "flag-re": "🇷🇪", + "flag-ro": "🇷🇴", + "flag-rs": "🇷🇸", + "ru": "🇷🇺", + "flag-ru": "🇷🇺", + "flag-rw": "🇷🇼", + "flag-sa": "🇸🇦", + "flag-sb": "🇸🇧", + "flag-sc": "🇸🇨", + "flag-sd": "🇸🇩", + "flag-se": "🇸🇪", + "flag-sg": "🇸🇬", + "flag-sh": "🇸🇭", + "flag-si": "🇸🇮", + "flag-sj": "🇸🇯", + "flag-sk": "🇸🇰", + "flag-sl": "🇸🇱", + "flag-sm": "🇸🇲", + "flag-sn": "🇸🇳", + "flag-so": "🇸🇴", + "flag-sr": "🇸🇷", + "flag-ss": "🇸🇸", + "flag-st": "🇸🇹", + "flag-sv": "🇸🇻", + "flag-sx": "🇸🇽", + "flag-sy": "🇸🇾", + "flag-sz": "🇸🇿", + "flag-ta": "🇹🇦", + "flag-tc": "🇹🇨", + "flag-td": "🇹🇩", + "flag-tf": "🇹🇫", + "flag-tg": "🇹🇬", + "flag-th": "🇹🇭", + "flag-tj": "🇹🇯", + "flag-tk": "🇹🇰", + "flag-tl": "🇹🇱", + "flag-tm": "🇹🇲", + "flag-tn": "🇹🇳", + "flag-to": "🇹🇴", + "flag-tr": "🇹🇷", + "flag-tt": "🇹🇹", + "flag-tv": "🇹🇻", + "flag-tw": "🇹🇼", + "flag-tz": "🇹🇿", + "flag-ua": "🇺🇦", + "flag-ug": "🇺🇬", + "flag-um": "🇺🇲", + "flag-un": "🇺🇳", + "us": "🇺🇸", + "flag-us": "🇺🇸", + "flag-uy": "🇺🇾", + "flag-uz": "🇺🇿", + "flag-va": "🇻🇦", + "flag-vc": "🇻🇨", + "flag-ve": "🇻🇪", + "flag-vg": "🇻🇬", + "flag-vi": "🇻🇮", + "flag-vn": "🇻🇳", + "flag-vu": "🇻🇺", + "flag-wf": "🇼🇫", + "flag-ws": "🇼🇸", + "flag-xk": "🇽🇰", + "flag-ye": "🇾🇪", + "flag-yt": "🇾🇹", + "flag-za": "🇿🇦", + "flag-zm": "🇿🇲", + "flag-zw": "🇿🇼", + "flag-england": "🏴", + "flag-scotland": "🏴", + "flag-wales": "🏴", + "kotlinconf23": "K", + "kotlinconf-2023": "K", + "kotlin-intensifies-purple": "K", + "kotlin-intensifies": "K", + "compose-multiplatform": "K", + "kotlinnew": "K", + "kotlin": "K", + "kotlin_emoji": "K", + "kotlin-emoji": "K", + "kotlin-gradient": "K", + "kotlin_gradient": "K", + "kotlin-golf": "🏌️", + "mascot": "🧸", + "mascot-wink": "🧸", + "compose": "✏️", + "yes": "👌", + "ohyes": "👌", + "thank-you": "🙏", + "tnx": "🙏", + "cool-doge": "🐕", + "blob-hype": "🦠", + "suspend": "⏸️", + "parrot-upside-down": "🦜", + "twitter": "T", + "javascript": "JS", + "this-is-fine": "🙂", + "marrrcin": "🙂", + "partydagster": "🎉", + "kedroid-party": "🎉", + "planet-daggy": "🪐", + "flames-daggy": "🔥", + "next-level-daggy": "👌", + "awesome": "👌", + "dagster": "D", + "super": "👌", + "blob_ok_hand": "👌", + "blob-okay-hand": "👌", + "gradle": "G", + "maskot-wink": "🧸", + "maskot": "🧸", + "kotlin-flag": "K", + "oh-yeah": "🎉", + "thumbsup_all": "👍", + "not-kotlin": "😶", + "trollface": "🧌", + "no": "🚫", + "ohno": "🚫", + "nospam": "🚫", + "blob_shrug": "🤷", + "thread-please": "🧵", + "dagster-bot-resolve": "🤖", + "bananadance": "🍌", + "bananas": "🍌", + "laptop_parrot": "🦜", + "laptop-parrot": "🦜", + "dancing_parrot": "🦜", + "dancing-parrot": "🦜", + "bongo_blob": "🦠", + "blob": "🦠", + "blob_wave": "👋", + "pikachu_wave": "👋", + "android_wave": "👋", + "android-wave": "👋", + "dagster-bot-surfaced-to-issue": "🤖", + "tsow-slack-icon": "S", + "kedro": "K", + "pipe": "|", + "prefect": "P", + "snowflake-inc": "❄️", + "kestra": "K" +}
\ No newline at end of file diff --git a/gui/src/logic/nostrill.ts b/gui/src/logic/nostrill.ts new file mode 100644 index 0000000..bd5fc9c --- /dev/null +++ b/gui/src/logic/nostrill.ts @@ -0,0 +1,139 @@ +import type { Event } from "@/types/nostr"; +import type { Content, FC, Poast } from "@/types/trill"; +import { engagementBunt, openLock } from "./bunts"; +import type { UserType } from "@/types/nostrill"; +import type { Result } from "@/types/ui"; +import { isValidPatp } from "urbit-ob"; +export function eventsToFc(postEvents: Event[]): FC { + const fc = postEvents.reduce( + (acc: FC, event: Event) => { + const p = eventToPoast(event); + if (!p) return acc; + acc.feed[p.id] = p; + if (!acc.start || event.created_at < Number(acc.start)) acc.start = p.id; + if (!acc.end || event.created_at > Number(acc.end)) acc.end = p.id; + return acc; + }, + { feed: {}, start: null, end: null } as FC, + ); + return fc; +} +export function eventToPoast(event: Event): Poast | null { + if (event.kind !== 1) return null; + const contents: Content = [{ paragraph: [{ text: event.content }] }]; + const ts = event.created_at * 1000; + const id = `${ts}`; + const poast: Poast = { + id, + host: event.pubkey, + author: event.pubkey, + contents, + thread: id, + parent: null, + read: openLock, + write: openLock, + tags: [], + time: ts, + engagement: engagementBunt, + children: [], + }; + for (const tag of event.tags) { + const f = tag[0]; + if (!f) continue; + const ff = f.toLowerCase(); + console.log("tag", ff); + if (ff === "e") { + const [, eventId, _relayURL, marker, _pubkey, ..._] = tag; + // TODO + if (marker === "root") poast.thread = eventId; + else if (marker === "reply") poast.parent = eventId; + } + // + if (ff === "r") + contents.push({ + paragraph: [{ link: { show: tag[1]!, href: tag[1]! } }], + }); + if (ff === "p") + contents.push({ + paragraph: [{ ship: tag[1]! }], + }); + if (ff === "q") + contents.push({ + ref: { + type: "nostr", + ship: tag[1]!, + path: tag[2] || "" + `/${tag[3] || ""}`, + }, + }); + } + return poast; +} + +export function userToString(user: UserType): Result<string> { + if ("urbit" in user) { + const isValid = isValidPatp(user.urbit); + if (isValid) return { ok: user.urbit }; + else return { error: "invalid @p" }; + } else if ("nostr" in user) return { ok: user.nostr }; + else return { error: "unknown user" }; +} +export function isValidNostrPubkey(pubkey: string): boolean { + // TODO + if (pubkey.length !== 64) return false; + try { + BigInt("0x" + pubkey); + return true; + } catch (_e) { + return false; + } +} +// NOTE common tags: +// imeta +// client +// nonce +// proxy + +// export function parseEventTags(event: Event) { +// const effects: any[] = []; +// for (const tag of event.tags) { +// const f = tag[0]; +// if (!f) continue; +// const ff = f.toLowerCase(); +// switch (ff) { +// case "p": { +// const [, pubkey, relayURL, ..._] = tag; +// // people mention +// break; +// } +// case "e": { +// // marker to be "root" or "reply" +// // event mention +// break; +// } +// case "q": { +// const [, eventId, relayURL, pubkey, ..._] = tag; +// // event mention +// break; +// } +// case "t": { +// const [, hashtag, ..._] = tag; +// // event mention +// break; +// } +// case "r": { +// const [, url, ..._] = tag; +// // event mention +// break; +// } +// case "alt": { +// const [, summary, ..._] = tag; +// // event mention +// break; +// } +// default: { +// break; +// } +// } +// } +// return effects; +// } diff --git a/gui/src/logic/requests/nostrill.ts b/gui/src/logic/requests/nostrill.ts new file mode 100644 index 0000000..e35b939 --- /dev/null +++ b/gui/src/logic/requests/nostrill.ts @@ -0,0 +1,224 @@ +import type Urbit from "urbit-api"; +import type { Cursor, FC, FullNode, PID, PostID } from "@/types/trill"; +import type { Ship } from "@/types/urbit"; +import { FeedPostCount } from "../constants"; +import type { UserProfile, UserType } from "@/types/nostrill"; +import type { AsyncRes } from "@/types/ui"; + +// Subscribe +type Handler = (date: any) => void; +export default class IO { + airlock; + subs: Map<string, number> = new Map(); + constructor(airlock: Urbit) { + this.airlock = airlock; + } + private async thread(threadName: string, json: any) { + return this.airlock.thread({ + body: json, + inputMark: "json", + outputMark: "json", + threadName, + }); + } + private async poke(json: any) { + return this.airlock.poke({ app: "nostrill", mark: "json", json }); + } + private async scry(path: string) { + return this.airlock.scry({ app: "nostrill", path }); + } + private async sub(path: string, handler: Handler) { + const has = this.subs.get(path); + if (has) return; + + const err = (err: any, _id: string) => + console.log(err, "error on nostrill subscription"); + const quit = (data: any) => { + console.log(data, "nostrill subscription kicked"); + this.subs.delete(path); + }; + const res = await this.airlock.subscribe({ + app: "nostrill", + path, + event: handler, + err, + quit, + }); + this.subs.set(path, res); + console.log(res, "subscribed to nostrill agent"); + } + async unsub(sub: number) { + return await this.airlock.unsubscribe(sub); + } + // subs + async subscribeStore(handler: Handler) { + const res = await this.sub("/ui", handler); + return res; + } + // scries + + async scryFeed( + host: Ship, + start: Cursor, + end: Cursor, + desc = true, + + replies = false, + ) { + const order = desc ? 1 : 0; + const rp = replies ? 1 : 0; + + const path = `/j/feed/${host}/${start}/${end}/${FeedPostCount}/${order}/${rp}`; + return await this.scry(path); + } + async scryThread( + host: Ship, + id: PostID, + // start: Cursor, + // end: Cursor, + // desc = true, + ): AsyncRes<FullNode> { + // const order = desc ? 1 : 0; + + // const path = `/j/thread/${host}/${id}/${start}/${end}/${FeedPostCount}/${order}`; + const path = `/j/thread/${host}/${id}`; + const res = await this.scry(path); + if (!("begs" in res)) return { error: "wrong result" }; + if ("ng" in res.begs) return { error: res.begs.ng }; + if ("ok" in res.begs) { + if (!("thread" in res.begs.ok)) return { error: "wrong result" }; + else return { ok: res.begs.ok.thread }; + } else return { error: "wrong result" }; + } + // pokes + + async pokeAlive() { + return await this.poke({ alive: true }); + } + async addPost(content: string) { + const json = { add: { content } }; + return this.poke({ post: json }); + } + async addReply(content: string, host: string, id: string, thread: string) { + const json = { reply: { content, host, id, thread } }; + return this.poke({ post: json }); + } + async addQuote(content: string, pid: PID) { + const json = { quote: { content, host: pid.ship, id: pid.id } }; + return this.poke({ post: json }); + } + async addRP(pid: PID) { + const json = { quote: { host: pid.ship, id: pid.id } }; + return this.poke({ post: json }); + } + + // async addPost(post: SentPoast, gossip: boolean) { + // const json = { + // "new-post": { + // "sent-post": post, + // gossip, + // }, + // }; + // return this.poke(json); + // } + + async deletePost(id: string) { + const host = `~${this.airlock.ship}`; + const json = { + "del-post": { + ship: host, + id: id, + }, + }; + return this.poke(json); + } + + async addReact(ship: Ship, id: PostID, reaction: string) { + const json = { + "new-react": { + react: reaction, + pid: { + id: id, + ship: ship, + }, + }, + }; + + return this.poke(json); + } + + // follows + async follow(user: UserType) { + const json = { add: user }; + return this.poke({ fols: json }); + } + + async unfollow(user: UserType) { + const json = { del: user }; + return await this.poke({ fols: json }); + } + // profiles + async createProfile(profile: UserProfile) { + const json = { add: profile }; + return await this.poke({ prof: json }); + } + async deleteProfile() { + const json = { del: null }; + return await this.poke({ prof: json }); + } + async cycleKeys() { + return await this.poke({ keys: null }); + } + // relaying + async addRelay(url: string) { + const json = { add: url }; + return await this.poke({ rela: json }); + } + async deleteRelay(url: string) { + const json = { del: url }; + return await this.poke({ rela: json }); + } + async syncRelays() { + // TODO make it choosable? + const json = { sync: null }; + return await this.poke({ rela: json }); + } + async relayPost(host: string, id: string, relays: string[]) { + const json = { send: { host, id, relays } }; + return await this.poke({ rela: json }); + } + // threads + // + async peekFeed( + host: string, + ): AsyncRes<{ feed: FC; profile: UserProfile | null }> { + try { + const json = { begs: { feed: host } }; + const res: any = await this.thread("beg", json); + console.log("peeking feed", res); + if (!("begs" in res)) return { error: "wrong request" }; + if ("ng" in res.begs) return { error: res.begs.ng }; + if (!("feed" in res.begs.ok)) return { error: "wrong request" }; + else return { ok: res.begs.ok }; + } catch (e) { + return { error: `${e}` }; + } + } + async peekThread(host: string, id: string): AsyncRes<FullNode> { + try { + const json = { begs: { thread: { host, id } } }; + const res: any = await this.thread("beg", json); + console.log("peeking feed", res); + if (!("begs" in res)) return { error: "wrong request" }; + if ("ng" in res.begs) return { error: res.begs.ng }; + if (!("thread" in res.begs.ok)) return { error: "wrong request" }; + else return { ok: res.begs.ok.thread }; + } catch (e) { + return { error: `${e}` }; + } + } +} + +// notifications + +// mark as read diff --git a/gui/src/logic/trill/helpers.ts b/gui/src/logic/trill/helpers.ts new file mode 100644 index 0000000..6b5a138 --- /dev/null +++ b/gui/src/logic/trill/helpers.ts @@ -0,0 +1,10 @@ +import type { FullNode, Poast } from "@/types/trill"; + +export function toFlat(n: FullNode): Poast { + return { + ...n, + children: !n.children + ? [] + : Object.keys(n.children).map((c) => n.children[c].id), + }; +} diff --git a/gui/src/logic/utils.ts b/gui/src/logic/utils.ts new file mode 100644 index 0000000..dbd246e --- /dev/null +++ b/gui/src/logic/utils.ts @@ -0,0 +1,459 @@ +import type { + Content, + Notification, + ID, + ExternalContent, + Poast, + Reference, + Inline, + PID, + SortugRef, +} from "@/types/trill"; +import type { Ship } from "@/types/urbit"; +import anyAscii from "any-ascii"; +import type { ReactGrouping, SPID } from "@/types/ui"; +import { openLock } from "./bunts"; +import { isValidPatp, patp2dec } from "urbit-ob"; +import { REF_REGEX } from "./constants"; + +export function parseSortugLink(link: string): SortugRef { + const s = link.replace("urbit://", "").split("/"); + const [type, ship, ...pat] = s; + const path = `/${pat.join("/")}`; + return { type, ship, path }; +} +export function sortugRefTolink(r: SortugRef): string { + return `urbit://${r.type}/${r.ship}${r.path}`; +} +// TODO + +export function createReference(ship: Ship, id: ID) { + return { + reference: { + feed: { id: id, ship: ship }, + }, + }; +} + +export function addScheme(url: string) { + if (url.includes("localhost")) { + return `http://${url.replace("http://", "")}`; + } else { + return `https://${url.replace("http://", "")}`; + } +} + +export function easyCode(code: string) { + const string = code.replace(/-/g, ""); + const matches = string.match(/.{1,6}/g); + if (matches) return matches.join("-"); +} + +export function tilde(patp: Ship) { + if (patp[0] == "~") { + return patp; + } else { + return "~" + patp; + } +} + +export function color_to_hex(color: string) { + let hex = "#" + color.replace(".", "").replace("0x", "").toUpperCase(); + if (hex == "#0") { + hex = "#000000"; + } + return hex; +} + +export function date_diff(date: number | Date, type: "short" | "long") { + const now = new Date().getTime(); + const diff = now - new Date(date).getTime(); + if (type == "short") { + return to_string(diff / 1000); + } else { + return to_string_long(diff / 1000); + } +} + +function to_string(s: number) { + if (s < 60) { + return "now"; + } else if (s < 3600) { + return `${Math.ceil(s / 60)}m`; + } else if (s < 86400) { + return `${Math.ceil(s / 60 / 60)}h`; + } else if (s < 2678400) { + return `${Math.ceil(s / 60 / 60 / 24)}d`; + } else if (s < 32140800) { + return `${Math.ceil(s / 60 / 60 / 24 / 30)}mo`; + } else { + return `${Math.ceil(s / 60 / 60 / 24 / 30 / 12)}y`; + } +} + +function to_string_long(s: number) { + if (s < 60) { + return "right now"; + } else if (s < 3600) { + return `${Math.ceil(s / 60)} minutes ago`; + } else if (s < 86400) { + return `${Math.ceil(s / 60 / 60)} hours ago`; + } else if (s < 2678400) { + return `${Math.ceil(s / 60 / 60 / 24)} days ago`; + } else if (s < 32140800) { + return `${Math.ceil(s / 60 / 60 / 24 / 30)} months ago`; + } else { + return `${Math.ceil(s / 60 / 60 / 24 / 30 / 12)} years ago`; + } +} + +export function regexes() { + const IMAGE_REGEX = new RegExp(/(jpg|img|png|gif|tiff|jpeg|webp|webm|svg)$/i); + const AUDIO_REGEX = new RegExp(/(mp3|wav|ogg)$/i); + const VIDEO_REGEX = new RegExp(/(mov|mp4|ogv)$/i); + return { img: IMAGE_REGEX, aud: AUDIO_REGEX, vid: VIDEO_REGEX }; +} + +export function stringToSymbol(str: string) { + const ascii = anyAscii(str); + let result = ""; + for (let i = 0; i < ascii.length; i++) { + const n = ascii.charCodeAt(i); + if ((n >= 97 && n <= 122) || (n >= 48 && n <= 57)) { + result += ascii[i]; + } else if (n >= 65 && n <= 90) { + result += String.fromCharCode(n + 32); + } else { + result += "-"; + } + } + result = result.replace(/^[\-\d]+|\-+/g, "-"); + result = result.replace(/^\-+|\-+$/g, ""); + return result; +} +export function buildDM(author: Ship, recipient: Ship, contents: Content[]) { + const node: any = {}; + const point = patp2dec(recipient); + const index = `/${point}/${makeIndex()}`; + node[index] = { + children: null, + post: { + author: author, + contents: contents, + hash: null, + index: index, + signatures: [], + "time-sent": Date.now(), + }, + }; + return { + app: "dm-hook", + mark: "graph-update-3", + json: { + "add-nodes": { + resource: { name: "dm-inbox", ship: author }, + nodes: node, + }, + }, + }; +} + +export function makeIndex(): string { + const DA_UNIX_EPOCH = BigInt("170141184475152167957503069145530368000"); + const DA_SECOND = BigInt("18446744073709551616"); + const timeSinceEpoch = (BigInt(Date.now()) * DA_SECOND) / BigInt(1000); + return (DA_UNIX_EPOCH + timeSinceEpoch).toString(); +} +export function makeDottedIndex() { + const DA_UNIX_EPOCH = BigInt("170141184475152167957503069145530368000"); + const DA_SECOND = BigInt("18446744073709551616"); + const timeSinceEpoch = (BigInt(Date.now()) * DA_SECOND) / BigInt(1000); + const index = (DA_UNIX_EPOCH + timeSinceEpoch).toString(); + return index.replace(/\B(?=(\d{3})+(?!\d))/g, "."); +} + +export function repostData(p: Poast): PID | null { + if ( + p.contents.length === 1 && + "ref" in p.contents[0] && + p.contents[0].ref.type === "trill" + ) + return { + id: p.contents[0].ref.path.slice(1), + ship: p.contents[0].ref.ship, + }; + else return null; +} + +export function getNotificationTime(n: Notification): number { + if ("follow" in n) { + return n.follow.time; + } else if ("unfollow" in n) { + return n.unfollow.time; + } else if ("mention" in n) { + return n.mention.time; + } else if ("react" in n) { + return n.react.time; + } else if ("reply" in n) { + return n.reply.time; + } else if ("quote" in n) { + return n.quote.time; + } else if ("share" in n) { + return n.share.time; + } else { + return Date.now(); + } +} +export function abbreviateChat(s: string): string { + const plist = s.trim().split(" "); + if (isValidPatp(plist[0]) && plist.length > 1) { + return `${plist[0]} & ${plist.length - 1}+`; + } else if (s.length < 25) return s; + else return `${s.substring(0, 25)}...`; +} + +export function timestring(n: number): string { + const nn = new Date(n); + return nn.toTimeString().slice(0, 5); +} +export function wait(ms: number) { + return new Promise((resolve, _reject) => { + setTimeout(resolve, ms); + }); +} + +export function quoteToReference(d: SPID): Reference | ExternalContent { + if (d.service === "twatter") + return { + json: { + origin: "twatter", + content: JSON.stringify(d.post), + }, + }; + else + return { + ref: { + type: "trill", + ship: d.post.host, + path: `/${d.post.id}`, + }, + }; +} + +export function trillPermalink(t: Poast) { + return `urbit://trill/${t.host}/${t.id}`; +} +export function isFeedRef(c: Content): boolean { + return "ref" in c && (c as Reference).ref.type === "trill"; +} + +export function checkTilde(s: string) { + if (s[0] === "~") return s; + else return "~" + s; +} + +export function addDots(s: string, num: number): string { + const reversed = s.split("").reverse().join(""); + const reg = new RegExp(`.{${num}}`, "g"); + const withCommas = reversed.replace(reg, "$&."); + return withCommas.split("").reverse().join("").slice(1); +} +export function addDots5(s: string): string { + const reversed = s.split("").reverse().join(""); + const withCommas = reversed.replace(/.{5}/g, "$&."); + return withCommas.split("").reverse().join(""); +} +// TODO +export function getTrillText(c: Content): string { + if (!c) return ""; + const reducePara = (acc: string, item: Inline) => { + let t = ""; + if ("text" in item) t = item.text + " "; + if ("italic" in item) t = item.italic + " "; + if ("bold" in item) t = item.bold + " "; + if ("strike" in item) t = item.strike + " "; + if ("ship" in item) t = item.ship + " "; + if ("codespan" in item) t = item.codespan + " "; + if ("link" in item) t = item.link.href + " "; + if ("break" in item) t = "\n"; + return acc + t; + }; + return c.reduce((acc, item) => { + if ("paragraph" in item) { + const text = item.paragraph.reduce(reducePara, ""); + return acc + text + "\n"; + } else return acc; + }, ""); +} +export function isTwatterLink(s: string) { + const sp = s + .replace("https://", "") + .split("/") + .filter((s) => s); + return sp.length === 4 && sp[0] === "twitter.com" && sp[2] === "status"; +} +export const isSortugLink = (s: string) => !!s.match(REF_REGEX); +export function parseOutSortugLinks(s: string): [SortugRef[], string] { + const matches = s.match(REF_REGEX); + let refs = []; + let rest = s; + for (let m of matches || []) { + rest = rest.replace(m, ""); + refs.push(parseSortugLink(m)); + } + return [refs, rest]; +} + +export function isTrillLink(s: string): boolean { + if (!isSortugLink(s)) return false; + const r = parseSortugLink(s); + if (r.type !== "trill") return false; + return isValidPatp(r.ship) && !isNaN(Number(r.path.slice(1))); +} + +export function auraToHex(s: string): string { + if (s.startsWith("0x")) { + let numbers = s.replace("0x", "").replace(".", ""); + while (numbers.length < 6) { + numbers = "0" + numbers; + } + return "#" + numbers; + } else if (s.startsWith("#")) return s; + else { + // console.log(s, "weird hex"); + return "black"; + } +} + +export function buildPost( + author: Ship, + id: string, + time: number, + s: string, + content: string, +): Poast { + return { + host: author, + author: author, + thread: null, + parent: null, + contents: [{ paragraph: [{ text: s }] }], + read: openLock, + write: openLock, + tags: [], + id, + time, + children: [], + engagement: { reacts: {}, quoted: [], shared: [] }, + json: { origin: "rumors", content }, + }; +} + +// default cursors +export function makeNewestIndex() { + const DA_UNIX_EPOCH = BigInt("170141184475152167957503069145530368000"); + const DA_SECOND = BigInt("18446744073709551616"); + const timeSinceEpoch = (BigInt(Date.now()) * DA_SECOND) / BigInt(1000); + return (DA_UNIX_EPOCH + timeSinceEpoch).toString(); +} +export const startCursor = makeNewestIndex(); +export const endCursor = "0"; + +export function displayCount(c: number): string { + if (c <= 0) return ""; + if (c < 1_000) return `${c}`; + if (c >= 1_000 && c < 1_000_000) return `${Math.round(c / 1_00) / 10}K`; + if (c >= 1_000_000) return `${Math.round(c / 100_000) / 10}M`; + else return ""; +} +export function isWhiteish(hex: string): boolean { + if (hex.indexOf("#") === 0) hex = hex.slice(1); + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return r > 200 && g > 200 && b > 200; +} + +export function localISOString(date: Date) { + const offset = new Date().getTimezoneOffset(); + const localts = date.getTime() - offset * 60_000; + return new Date(localts).toISOString().slice(0, 16); +} + +export function goback() { + window.history.back(); +} + +export function groupReacts(reacts: Record<Ship, string>): ReactGrouping { + const byReact = Object.entries(reacts).reduce( + (acc: Record<string, Ship[]>, item) => { + const shipList = acc[item[1]]; + if (!shipList) acc[item[1]] = [item[0]]; + else acc[item[1]] = [...shipList, item[0]]; + return acc; + }, + {}, + ); + return Object.entries(byReact) + .reduce((acc: ReactGrouping, item) => { + const pair = { react: item[0], ships: item[1] }; + return [...acc, pair]; + }, []) + .sort((a, b) => b.ships.length - a.ships.length); +} + +export function reverseRecord( + a: Record<string, string>, +): Record<string, string> { + return Object.entries(a).reduce((acc: Record<string, string>, [k, v]) => { + acc[v] = k; + return acc; + }, {}); +} + +export function getColorHex(color: string): string { + if (color.startsWith("0x")) + return `#${padString(stripFuckingDots(color), 6)}`; + else if (color.startsWith("#") && color.length === 7) return color; + else if (color.length === 6) return `#${color}`; + else { + console.log(color, "something weird with this color"); + return "#FFFFFF"; + } +} + +export function stripFuckingDots(hex: string) { + return hex.replace("0x", "").replaceAll(".", ""); +} +export function padString(s: string, size: number) { + if (s.length >= size) return s; + else return padString(`0${s}`, size); +} +export function isDark(hexColor: string): boolean { + const r = parseInt(hexColor.substring(1, 2), 16); + const g = parseInt(hexColor.substring(3, 5), 16); + const b = parseInt(hexColor.substring(5, 7), 16); + + const sr = r / 255; + const sg = g / 255; + const sb = b / 255; + const rSrgb = + sr <= 0.03928 ? sr / 12.92 : Math.pow((sr + 0.055) / 1.055, 2.4); + const gSrgb = + sg <= 0.03928 ? sg / 12.92 : Math.pow((sg + 0.055) / 1.055, 2.4); + const bSrgb = + sb <= 0.03928 ? sb / 12.92 : Math.pow((sb + 0.055) / 1.055, 2.4); + + // Calculate luminance + const luminance = 0.2126 * rSrgb + 0.7152 * gSrgb + 0.0722 * bSrgb; + return luminance < 0.12; +} + +export function checkIfClickedOutside( + e: React.MouseEvent, + el: HTMLElement, + close: any, +) { + e.stopPropagation(); + if (el.contains(e.currentTarget)) close(); +} diff --git a/gui/src/main.tsx b/gui/src/main.tsx new file mode 100644 index 0000000..9200210 --- /dev/null +++ b/gui/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render( + // <StrictMode> + <App />, + // </StrictMode>, +); diff --git a/gui/src/pages/Feed.tsx b/gui/src/pages/Feed.tsx new file mode 100644 index 0000000..66acc66 --- /dev/null +++ b/gui/src/pages/Feed.tsx @@ -0,0 +1,182 @@ +// import spinner from "@/assets/icons/spinner.svg"; +import "@/styles/trill.css"; +import "@/styles/feed.css"; +import UserLoader from "./User"; +import PostList from "@/components/feed/PostList"; +import useLocalState from "@/state/state"; +import { useParams } from "wouter"; +import spinner from "@/assets/triangles.svg"; +import { useState } from "react"; +import Composer from "@/components/composer/Composer"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import { eventsToFc } from "@/logic/nostrill"; +import { ErrorPage } from "@/Router"; + +type FeedType = "global" | "following" | "nostr"; +function Loader() { + // const { api } = useLocalState(); + const params = useParams(); + console.log({ params }); + // const [loc, navigate] = useLocation(); + // console.log({ loc }); + // const our = api!.airlock.ship; + if (params.taip === "global") return <FeedPage t={"global"} />; + if (params.taip === "nostr") return <FeedPage t={"nostr"} />; + // else if (param === FeedType.Rumors) return <Rumors />; + // else if (param === FeedType.Home) return <UserFeed p={our} />; + else if (params.taip) return <UserLoader userString={params.taip!} />; + else return <ErrorPage msg="No such page" />; +} +function FeedPage({ t }: { t: FeedType }) { + const [active, setActive] = useState<FeedType>(t); + return ( + <main> + <div id="top-tabs"> + <div + className={active === "global" ? "active" : ""} + onClick={() => setActive("global")} + > + Global + </div> + <div + className={active === "following" ? "active" : ""} + onClick={() => setActive("following")} + > + Following + </div> + <div + className={active === "nostr" ? "active" : ""} + onClick={() => setActive("nostr")} + > + Nostr + </div> + </div> + <div id="feed-proper"> + <Composer /> + {active === "global" ? ( + <Global /> + ) : active === "following" ? ( + <Global /> + ) : active === "nostr" ? ( + <Nostr /> + ) : null} + </div> + </main> + ); +} +// {active === "global" ? ( +// <Global /> +// ) : active === "following" ? ( +// <Global /> +// ) : ( +// <Global /> +// )} + +function Global() { + // const { api } = useLocalState(); + // const { isPending, data, refetch } = useQuery({ + // queryKey: ["globalFeed"], + // queryFn: () => { + // return api!.scryFeed(null, null); + // }, + // }); + // console.log(data, "scry feed data"); + // if (isPending) return <img className="x-center" src={spinner} />; + // else if ("bucun" in data) return <p>Error</p>; + // else return <Inner data={data} refetch={refetch} />; + return <p>Error</p>; +} +function Nostr() { + const { nostrFeed, api } = useLocalState((s) => ({ + nostrFeed: s.nostrFeed, + api: s.api, + })); + const [isSyncing, setIsSyncing] = useState(false); + const feed = eventsToFc(nostrFeed); + console.log({ feed }); + const refetch = () => feed; + + const handleResync = async () => { + if (!api) return; + + setIsSyncing(true); + try { + await api.syncRelays(); + toast.success("Nostr feed sync initiated"); + } catch (error) { + toast.error("Failed to sync Nostr feed"); + console.error("Sync error:", error); + } finally { + setIsSyncing(false); + } + }; + + // Show empty state with resync option when no feed data + if (!feed || !feed.feed || Object.keys(feed.feed).length === 0) { + return ( + <div className="nostr-empty-state"> + <div className="empty-content"> + <Icon name="nostr" size={48} color="textMuted" /> + <h3>No Nostr Posts</h3> + <p> + Your Nostr feed appears to be empty. Try syncing with your relays to + fetch the latest posts. + </p> + <button + onClick={handleResync} + disabled={isSyncing} + className="resync-btn" + > + {isSyncing ? ( + <> + <img src={spinner} alt="Loading" className="btn-spinner" /> + Syncing... + </> + ) : ( + <> + <Icon name="settings" size={16} /> + Sync Relays + </> + )} + </button> + </div> + </div> + ); + } + + // Show feed with resync button in header + return ( + <div className="nostr-feed"> + <div className="nostr-header"> + <div className="feed-info"> + <h4>Nostr Feed</h4> + <span className="post-count"> + {Object.keys(feed.feed).length} posts + </span> + </div> + <button + onClick={handleResync} + disabled={isSyncing} + className="resync-btn-small" + title="Sync with Nostr relays" + > + {isSyncing ? ( + <img src={spinner} alt="Loading" className="btn-spinner-small" /> + ) : ( + <Icon name="settings" size={16} /> + )} + </button> + </div> + <PostList data={feed} refetch={refetch} /> + </div> + ); +} + +export default Loader; +// TODO +type MixFeed = any; + +function Inner({ data, refetch }: { data: MixFeed; refetch: Function }) { + return <PostList data={data.mix.fc} refetch={refetch} />; +} diff --git a/gui/src/pages/Settings.tsx b/gui/src/pages/Settings.tsx new file mode 100644 index 0000000..abf0022 --- /dev/null +++ b/gui/src/pages/Settings.tsx @@ -0,0 +1,255 @@ +import useLocalState from "@/state/state"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; +import Icon from "@/components/Icon"; +import "@/styles/Settings.css"; +import WebSocketWidget from "@/components/WsWidget"; + +function Settings() { + const { key, relays, api, addNotification } = useLocalState((s) => ({ + key: s.pubkey, + relays: s.relays, + api: s.api, + addNotification: s.addNotification, + })); + const [newRelay, setNewRelay] = useState(""); + const [isAddingRelay, setIsAddingRelay] = useState(false); + const [isCyclingKey, setIsCyclingKey] = useState(false); + + async function removeRelay(url: string) { + try { + await api?.deleteRelay(url); + toast.success("Relay removed"); + } catch (error) { + toast.error("Failed to remove relay"); + console.error("Remove relay error:", error); + } + } + + async function addNewRelay() { + if (!newRelay.trim()) { + toast.error("Please enter a relay URL"); + return; + } + + setIsAddingRelay(true); + try { + const valid = ["wss:", "ws:"]; + const url = new URL(newRelay); + if (!valid.includes(url.protocol)) { + toast.error("Invalid Relay URL - must use wss:// or ws://"); + return; + } + + await api?.addRelay(newRelay); + toast.success("Relay added"); + setNewRelay(""); + } catch (error) { + toast.error("Invalid relay URL or failed to add relay"); + console.error("Add relay error:", error); + } finally { + setIsAddingRelay(false); + } + } + + async function cycleKey() { + setIsCyclingKey(true); + try { + await api?.cycleKeys(); + toast.success("Key cycled successfully"); + } catch (error) { + toast.error("Failed to cycle key"); + console.error("Cycle key error:", error); + } finally { + setIsCyclingKey(false); + } + } + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + addNewRelay(); + } + }; + + return ( + <div className="settings-page"> + <div className="settings-header"> + <h1>Settings</h1> + <p>Manage your Nostrill configuration and preferences</p> + </div> + + <div className="settings-content"> + <WebSocketWidget url="ws://localhost:8090/nostrill-ui" /> + {/* Notifications Test Section - Remove in production */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="bell" size={20} /> + <h2>Test Notifications</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Test Notification System</label> + <p>Generate test notifications to see how they work</p> + </div> + <div className="setting-control"> + <button + className="test-notification-btn" + onClick={() => { + const types = [ + "follow", + "reply", + "react", + "mention", + "access_request", + ]; + const randomType = types[ + Math.floor(Math.random() * types.length) + ] as any; + addNotification({ + type: randomType, + from: "~sampel-palnet", + message: "This is a test notification", + reaction: randomType === "react" ? "👍" : undefined, + }); + toast.success("Test notification sent!"); + }} + > + <Icon name="bell" size={16} /> + Send Test Notification + </button> + </div> + </div> + </div> + </div> + + {/* Appearance Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="settings" size={20} /> + <h2>Appearance</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Theme</label> + <p>Choose your preferred color theme</p> + </div> + <div className="setting-control"> + <ThemeSwitcher /> + </div> + </div> + </div> + </div> + + {/* Identity Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="key" size={20} /> + <h2>Identity</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Nostr Public Key</label> + <p>Your unique identifier on the Nostr network</p> + </div> + <div className="setting-control"> + <div className="key-display"> + <code className="pubkey">{key || "No key generated"}</code> + <button + onClick={cycleKey} + disabled={isCyclingKey} + className="cycle-btn" + title="Generate new key pair" + > + {isCyclingKey ? ( + <Icon name="settings" size={16} /> + ) : ( + <Icon name="settings" size={16} /> + )} + {isCyclingKey ? "Cycling..." : "Cycle Key"} + </button> + </div> + </div> + </div> + </div> + </div> + + {/* Nostr Relays Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="nostr" size={20} /> + <h2>Nostr Relays</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Connected Relays</label> + <p>Manage your Nostr relay connections</p> + </div> + <div className="setting-control"> + <div className="relay-list"> + {Object.keys(relays).length === 0 ? ( + <div className="no-relays"> + <Icon name="nostr" size={24} color="textMuted" /> + <p>No relays configured</p> + </div> + ) : ( + Object.keys(relays).map((url) => ( + <div key={url} className="relay-item"> + <div className="relay-info"> + <span className="relay-url">{url}</span> + <span className="relay-status">Connected</span> + </div> + <button + onClick={() => removeRelay(url)} + className="remove-relay-btn" + title="Remove relay" + > + × + </button> + </div> + )) + )} + + <div className="add-relay-form"> + <div className="relay-input-group"> + <input + type="text" + value={newRelay} + onChange={(e) => setNewRelay(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="wss://relay.example.com" + className="relay-input" + /> + <button + onClick={addNewRelay} + disabled={isAddingRelay || !newRelay.trim()} + className="add-relay-btn" + > + {isAddingRelay ? ( + <> + <Icon name="settings" size={16} /> + Adding... + </> + ) : ( + <> + <Icon name="settings" size={16} /> + Add Relay + </> + )} + </button> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + </div> + ); +} +export default Settings; diff --git a/gui/src/pages/Thread.tsx b/gui/src/pages/Thread.tsx new file mode 100644 index 0000000..8296f07 --- /dev/null +++ b/gui/src/pages/Thread.tsx @@ -0,0 +1,127 @@ +import { useParams } from "wouter"; +import { useQuery } from "@tanstack/react-query"; +import useLocalState from "@/state/state"; +import PostList from "@/components/feed/PostList"; +import Composer from "@/components/composer/Composer"; +import Icon from "@/components/Icon"; +import spinner from "@/assets/triangles.svg"; +import { ErrorPage } from "@/Router"; +import "@/styles/trill.css"; +import "@/styles/feed.css"; +import Post from "@/components/post/Post"; +import { toFlat } from "@/logic/trill/helpers"; + +export default function Thread() { + const params = useParams<{ host: string; id: string }>(); + const { host, id } = params; + const { api } = useLocalState((s) => ({ api: s.api })); + + async function fetchThread() { + return await api!.scryThread(host, id); + } + const { isPending, data, error, refetch } = useQuery({ + queryKey: ["thread", params.host, params.id], + queryFn: fetchThread, + enabled: !!api && !!params.host && !!params.id, + }); + + console.log({ data }); + if (!params.host || !params.id) { + return <ErrorPage msg="Invalid thread URL" />; + } + + if (isPending) { + return ( + <main> + <div className="thread-header"> + <h2>Loading Thread...</h2> + </div> + <div className="loading-container"> + <img className="x-center" src={spinner} alt="Loading" /> + </div> + </main> + ); + } + + if (error) { + return ( + <main> + <div className="thread-header"> + <h2>Error Loading Thread</h2> + </div> + <ErrorPage msg={error.message || "Failed to load thread"} /> + </main> + ); + } + + if (!data || "error" in data) { + return ( + <main> + <div className="thread-header"> + <h2>Thread Not Found</h2> + </div> + <ErrorPage + msg={data?.error || "This thread doesn't exist or isn't accessible"} + /> + </main> + ); + } + + return ( + <main> + <div className="thread-header"> + <div className="thread-nav"> + <button + className="back-btn" + onClick={() => window.history.back()} + title="Go back" + > + <Icon name="reply" size={16} /> + <span>Back to Feed</span> + </button> + </div> + <h2>Thread</h2> + <div className="thread-info"> + <span className="thread-host">~{params.host}</span> + <span className="thread-separator">•</span> + <span className="thread-id">#{params.id}</span> + </div> + </div> + + <div id="feed-proper"> + <Composer /> + <div className="thread-content"> + <Post poast={toFlat(data.ok)} /> + </div> + </div> + </main> + ); +} +// function OwnData(props: Props) { +// const { api } = useLocalState((s) => ({ +// api: s.api, +// })); +// const { host, id } = props; +// async function fetchThread() { +// return await api!.scryThread(host, id); +// } +// const { isLoading, isError, data, refetch } = useQuery({ +// queryKey: ["trill-thread", host, id], +// queryFn: fetchThread, +// }); +// return isLoading ? ( +// <div className={props.className}> +// <div className="x-center not-found"> +// <p className="x-center">Scrying Post, please wait...</p> +// <img src={spinner} className="x-center s-100" alt="" /> +// </div> +// </div> +// ) : null; +// } +// function SomeoneElses(props: Props) { +// // const { api, following } = useLocalState((s) => ({ +// // api: s.api, +// // following: s.following, +// // })); +// return <div>ho</div>; +// } diff --git a/gui/src/pages/User.tsx b/gui/src/pages/User.tsx new file mode 100644 index 0000000..b73cd96 --- /dev/null +++ b/gui/src/pages/User.tsx @@ -0,0 +1,212 @@ +// import spinner from "@/assets/icons/spinner.svg"; +import Composer from "@/components/composer/Composer"; +import PostList from "@/components/feed/PostList"; +import Profile from "@/components/profile/Profile"; +import useLocalState, { useStore } from "@/state/state"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import { useEffect, useState } from "react"; +import type { FC } from "@/types/trill"; +import type { UserType } from "@/types/nostrill"; +import { isValidPatp } from "urbit-ob"; +import { isValidNostrPubkey } from "@/logic/nostrill"; +import { ErrorPage } from "@/Router"; + +function UserLoader({ userString }: { userString: string }) { + const { api, pubkey } = useLocalState((s) => ({ + api: s.api, + pubkey: s.pubkey, + })); + // auto updating on SSE doesn't work if we do shallow + + const user = isValidPatp(userString) + ? { urbit: userString } + : isValidNostrPubkey(userString) + ? { nostr: userString } + : { error: "" }; + + const isOwnProfile = + "urbit" in user + ? user.urbit === api?.airlock.our + : "nostr" in user + ? pubkey === user.nostr + : false; + if ("error" in user) return <ErrorPage msg={"Invalid user"} />; + else + return <UserFeed user={user} userString={userString} isMe={isOwnProfile} />; +} + +function UserFeed({ + user, + userString, + isMe, +}: { + user: UserType; + userString: string; + isMe: boolean; +}) { + const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({ + api: s.api, + addProfile: s.addProfile, + addNotification: s.addNotification, + lastFact: s.lastFact, + })); + // auto updating on SSE doesn't work if we do shallow + const { following } = useStore(); + const feed = following.get(userString); + const hasFeed = !feed ? false : Object.entries(feed).length > 0; + const refetch = () => feed; + const isFollowing = following.has(userString); + + const [isFollowLoading, setIsFollowLoading] = useState(false); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [fc, setFC] = useState<FC>(); + + useEffect(() => { + console.log("fact", lastFact); + console.log(isFollowLoading); + if (!isFollowLoading) return; + const follow = lastFact?.fols; + if (!follow) return; + if ("new" in follow) { + if (userString !== follow.new.user) return; + toast.success(`Now following ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "follow", + from: userString, + message: `You are now following ${userString}`, + }); + } else if ("quit" in follow) { + toast.success(`Unfollowed ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "unfollow", + from: userString, + message: `You unfollowed ${userString}`, + }); + } + }, [lastFact, userString, isFollowLoading]); + + const handleFollow = async () => { + if (!api) return; + + setIsFollowLoading(true); + try { + if (isFollowing) { + await api.unfollow(user); + } else { + await api.follow(user); + toast.success(`Follow request sent to ${userString}`); + } + } catch (error) { + toast.error( + `Failed to ${isFollowing ? "unfollow" : "follow"} ${userString}`, + ); + setIsFollowLoading(false); + console.error("Follow error:", error); + } + }; + + const handleRequestAccess = async () => { + if (!api) return; + if (!("urbit" in user)) return; + + setIsAccessLoading(true); + try { + const res = await api.peekFeed(user.urbit); + toast.success(`Access request sent to ${user.urbit}`); + addNotification({ + type: "access_request", + from: userString, + message: `Access request sent to ${userString}`, + }); + if ("error" in res) toast.error(res.error); + else { + console.log("peeked", res.ok.feed); + setFC(res.ok.feed); + if (res.ok.profile) addProfile(userString, res.ok.profile); + } + } catch (error) { + toast.error(`Failed to request access from ${user.urbit}`); + console.error("Access request error:", error); + } finally { + setIsAccessLoading(false); + } + }; + console.log({ user, userString, feed, fc }); + + return ( + <div id="user-page"> + <Profile user={user} userString={userString} isMe={isMe} /> + + {!isMe && ( + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${isFollowing ? "" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {isFollowing ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={isFollowing ? "bell" : "pals"} size={16} /> + {isFollowing ? "Unfollow" : "Follow"} + </> + )} + </button> + + <button + onClick={handleRequestAccess} + disabled={isAccessLoading} + className="action-btn access" + > + {isAccessLoading ? ( + <> + <Icon name="settings" size={16} /> + Requesting... + </> + ) : ( + <> + <Icon name="key" size={16} /> + Request Access + </> + )} + </button> + </div> + )} + + {feed && hasFeed ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={feed} refetch={refetch} /> + </div> + ) : fc ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={fc} refetch={refetch} /> + </div> + ) : null} + + {!isMe && !feed && !fc && ( + <div id="other-user-feed"> + <div className="empty-feed-message"> + <Icon name="messages" size={48} color="textMuted" /> + <h3>No Posts Available</h3> + <p> + This user's posts are not publicly visible. + {!isFollowing && " Try following them"} or request temporary + access to see their content. + </p> + </div> + </div> + )} + </div> + ); +} + +export default UserLoader; diff --git a/gui/src/state/state.ts b/gui/src/state/state.ts new file mode 100644 index 0000000..f329145 --- /dev/null +++ b/gui/src/state/state.ts @@ -0,0 +1,150 @@ +import type { JSX } from "react"; +import { start } from "@/logic/api"; +import IO from "@/logic/requests/nostrill"; +import type { ComposerData } from "@/types/ui"; +import { create } from "zustand"; +import type { UserProfile } from "@/types/nostrill"; +import type { Event } from "@/types/nostr"; +import type { FC, Poast } from "@/types/trill"; +import type { Notification } from "@/types/notifications"; +import { useShallow } from "zustand/shallow"; +// TODO handle airlock connection issues +// the SSE pipeline has a "status-update" event FWIW +// type AirlockState = "connecting" | "connected" | "failed"; +export type LocalState = { + isNew: boolean; + api: IO | null; + init: () => Promise<void>; + UISettings: Record<string, any>; + modal: JSX.Element | null; + setModal: (modal: JSX.Element | null) => void; + composerData: ComposerData | null; + setComposerData: (c: ComposerData | null) => void; + pubkey: string; + nostrFeed: Event[]; + relays: Record<string, Event[]>; + profiles: Map<string, UserProfile>; // pubkey key + addProfile: (key: string, u: UserProfile) => void; + following: Map<string, FC>; + followers: string[]; + // Notifications + notifications: Notification[]; + unreadNotifications: number; + addNotification: ( + notification: Omit<Notification, "id" | "timestamp" | "read">, + ) => void; + markNotificationRead: (id: string) => void; + markAllNotificationsRead: () => void; + clearNotifications: () => void; + lastFact: any; +}; + +const creator = create<LocalState>(); +export const useStore = creator((set, get) => ({ + isNew: false, + api: null, + init: async () => { + const airlock = await start(); + const api = new IO(airlock); + console.log({ api }); + await api.subscribeStore((data) => { + console.log("store sub", data); + if ("state" in data) { + const { feed, nostr, following, relays, profiles, pubkey } = data.state; + const flwing = new Map(Object.entries(following as Record<string, FC>)); + flwing.set(api!.airlock.our!, feed); + set({ + relays, + nostrFeed: nostr, + profiles: new Map(Object.entries(profiles)), + following: flwing, + pubkey, + }); + } else if ("fact" in data) { + set({ lastFact: data.fact }); + if ("fols" in data.fact) { + const { following, profiles } = get(); + if ("new" in data.fact.fols) { + const { user, feed, profile } = data.fact.fols.new; + following.set(user, feed); + if (profile) profiles.set(user, profile); + set({ following, profiles }); + } + if ("quit" in data.fact.fols) { + following.delete(data.fact.fols.quit); + set({ following }); + } + } + if ("post" in data.fact) { + if ("add" in data.fact.post) { + const post: Poast = data.fact.post.add.post; + const following = get().following; + const curr = following.get(post.author); + const fc = curr ? curr : { feed: {}, start: null, end: null }; + fc.feed[post.id] = post; + following.set(post.author, fc); + + set({ following }); + } + } + } + }); + set({ api }); + }, + pubkey: "", + profiles: new Map(), + addProfile: (key, profile) => { + const profiles = get().profiles; + profiles.set(key, profile); + set({ profiles }); + }, + lastFact: null, + relays: {}, + nostrFeed: [], + following: new Map(), + followers: [], + UISettings: {}, + modal: null, + setModal: (modal) => set({ modal }), + // composer data + composerData: null, + setComposerData: (composerData) => set({ composerData }), + // Notifications + notifications: [], + unreadNotifications: 0, + addNotification: (notification) => { + const newNotification: Notification = { + ...notification, + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date(), + read: false, + }; + set((state) => ({ + notifications: [newNotification, ...state.notifications], + unreadNotifications: state.unreadNotifications + 1, + })); + }, + markNotificationRead: (id) => { + set((state) => ({ + notifications: state.notifications.map((n) => + n.id === id ? { ...n, read: true } : n, + ), + unreadNotifications: Math.max(0, state.unreadNotifications - 1), + })); + }, + markAllNotificationsRead: () => { + set((state) => ({ + notifications: state.notifications.map((n) => ({ ...n, read: true })), + unreadNotifications: 0, + })); + }, + clearNotifications: () => { + set({ notifications: [], unreadNotifications: 0 }); + }, +})); + +const useShallowStore = <T extends (state: LocalState) => any>( + selector: T, +): ReturnType<T> => useStore(useShallow(selector)); + +export default useShallowStore; diff --git a/gui/src/styles/NotificationCenter.css b/gui/src/styles/NotificationCenter.css new file mode 100644 index 0000000..6991118 --- /dev/null +++ b/gui/src/styles/NotificationCenter.css @@ -0,0 +1,263 @@ +/* Notification Badge in Sidebar */ +.notification-item { + position: relative; +} + +.notification-icon-wrapper { + position: relative; + display: inline-block; +} + +.notification-badge { + position: absolute; + top: -4px; + right: -8px; + background: var(--color-error); + color: white; + border-radius: 10px; + padding: 2px 6px; + font-size: 10px; + font-weight: bold; + min-width: 18px; + text-align: center; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.05); + } + 100% { + transform: scale(1); + } +} + +/* Notification Center Modal */ +.notification-center { + max-width: 500px; + width: 100%; + max-height: 600px; + display: flex; + flex-direction: column; +} + +.notification-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--color-border); +} + +.notification-header h2 { + margin: 0; + color: var(--color-text); + font-size: 24px; +} + +.notification-actions { + display: flex; + gap: 8px; +} + +.mark-all-read-btn, +.clear-all-btn { + padding: 6px 12px; + font-size: 12px; + border: 1px solid var(--color-border); + background: transparent; + color: var(--color-text-secondary); + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; +} + +.mark-all-read-btn:hover, +.clear-all-btn:hover { + background: var(--color-surface); + color: var(--color-text); + border-color: var(--color-primary); +} + +/* Notification Filters */ +.notification-filters { + display: flex; + gap: 8px; + padding: 12px 20px; + background: var(--color-surface); + border-bottom: 1px solid var(--color-border); +} + +.filter-btn { + padding: 8px 16px; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 20px; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.filter-btn:hover { + background: var(--color-background); + color: var(--color-text); +} + +.filter-btn.active { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +/* Notification List */ +.notification-list { + flex: 1; + overflow-y: auto; + background: var(--color-background); +} + +.no-notifications { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + text-align: center; +} + +.no-notifications p { + margin: 16px 0 0 0; + color: var(--color-text-muted); + font-size: 16px; +} + +/* Notification Item */ +.notification-item { + display: flex; + align-items: flex-start; + padding: 16px 20px; + border-bottom: 1px solid var(--color-border-light); + cursor: pointer; + transition: background 0.2s; + position: relative; +} + +.notification-item:hover { + background: var(--color-surface); +} + +.notification-item.unread { + background: var(--color-surface); +} + +.notification-icon { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-background); + border-radius: 50%; + margin-right: 12px; +} + +.notification-content { + flex: 1; + min-width: 0; +} + +.notification-user { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.notification-text { + flex: 1; + min-width: 0; +} + +.notification-text p { + margin: 0; + color: var(--color-text); + font-size: 14px; + line-height: 1.4; +} + +.notification-item.unread .notification-text p { + font-weight: 500; +} + +.notification-time { + display: block; + margin-top: 4px; + color: var(--color-text-muted); + font-size: 12px; +} + +.unread-indicator { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + background: var(--color-primary); + border-radius: 50%; + animation: pulse 2s infinite; +} + +/* Scrollbar Styling */ +.notification-list::-webkit-scrollbar { + width: 8px; +} + +.notification-list::-webkit-scrollbar-track { + background: var(--color-background); +} + +.notification-list::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} + +.notification-list::-webkit-scrollbar-thumb:hover { + background: var(--color-text-muted); +} + +/* Mobile Responsive */ +@media (max-width: 600px) { + .notification-center { + max-width: 100%; + max-height: 90vh; + } + + .notification-header { + padding: 16px; + } + + .notification-header h2 { + font-size: 20px; + } + + .notification-actions { + flex-direction: column; + gap: 4px; + } + + .mark-all-read-btn, + .clear-all-btn { + padding: 4px 8px; + font-size: 11px; + } + + .notification-item { + padding: 12px 16px; + } +}
\ No newline at end of file diff --git a/gui/src/styles/Profile.css b/gui/src/styles/Profile.css new file mode 100644 index 0000000..624cb12 --- /dev/null +++ b/gui/src/styles/Profile.css @@ -0,0 +1,325 @@ +.profile { + align-items: center; + padding: 20px; + background: var(--color-surface); + border-radius: 8px; + margin-bottom: 20px; +} + +.profile-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.profile-header h2 { + margin: 0; + color: var(--color-text); +} + +.edit-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.2s; +} + +.edit-btn:hover { + opacity: 0.9; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 500; + color: var(--color-text); +} + +.form-group input, +.form-group textarea { + padding: 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background); + color: var(--color-text); + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.picture-preview { + width: 100px; + height: 100px; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--color-border); + margin-top: 10px; +} + +.picture-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.custom-fields { + display: flex; + flex-direction: column; + gap: 10px; +} + +.custom-field-row { + display: flex; + gap: 10px; + align-items: center; +} + +.field-key-input, +.field-value-input { + flex: 1; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background); + color: var(--color-text); +} + +.remove-field-btn { + padding: 4px 8px; + background: var(--color-error); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; + font-size: 16px; + font-weight: bold; + min-width: 28px; + height: 28px; +} + +.remove-field-btn:hover { + opacity: 0.8; +} + +.add-field-btn { + padding: 10px; + background: transparent; + color: var(--color-primary); + border: 1px dashed var(--color-primary); + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.add-field-btn:hover { + background: var(--color-surface); +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.save-btn, +.cancel-btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: opacity 0.2s; +} + +.save-btn { + background: var(--color-primary); + color: white; +} + +.cancel-btn { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.save-btn:disabled, +.cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.save-btn:hover:not(:disabled), +.cancel-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.profile-view, +.view-mode { + display: flex; + gap: 20px; +} + +.profile-picture { + width: 120px; + height: 120px; + border-radius: 50%; + overflow: hidden; + border: 3px solid var(--color-border); + flex-shrink: 0; +} + +.profile-picture img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-info { + flex: 1; +} + +.profile-info h3 { + margin: 0 0 10px 0; + color: var(--color-text); +} + +.profile-about { + color: var(--color-text-secondary); + line-height: 1.5; + margin-bottom: 20px; +} + +.profile-custom-fields { + margin-top: 20px; +} + +.profile-custom-fields h4 { + margin: 0 0 10px 0; + color: var(--color-text); +} + +.custom-field-view { + display: flex; + gap: 10px; + margin-bottom: 8px; +} + +.field-key { + font-weight: 500; + color: var(--color-text); +} + +.field-value { + color: var(--color-text-secondary); +} + +/* User Actions */ +.user-actions { + display: flex; + gap: 12px; + margin-bottom: 20px; + padding: 16px; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border: 1px solid; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + background: transparent; +} + +.action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-btn.follow { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.action-btn.follow:hover:not(:disabled) { + background: var(--color-primary); + color: white; +} + +.action-btn.following { + border-color: var(--color-success); + color: var(--color-success); + background: var(--color-success); + color: white; +} + +.action-btn.following:hover:not(:disabled) { + background: var(--color-error); + border-color: var(--color-error); +} + +.action-btn.access { + border-color: var(--color-secondary); + color: var(--color-secondary); +} + +.action-btn.access:hover:not(:disabled) { + background: var(--color-secondary); + color: white; +} + +/* Empty feed message */ +.empty-feed-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 60px 20px; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.empty-feed-message h3 { + margin: 20px 0 10px 0; + color: var(--color-text); + font-size: 20px; +} + +.empty-feed-message p { + color: var(--color-text-secondary); + line-height: 1.5; + max-width: 400px; +}
\ No newline at end of file diff --git a/gui/src/styles/Settings.css b/gui/src/styles/Settings.css new file mode 100644 index 0000000..bb1f46e --- /dev/null +++ b/gui/src/styles/Settings.css @@ -0,0 +1,339 @@ +.settings-page { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.settings-header { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--color-border); +} + +.settings-header h1 { + margin: 0 0 8px 0; + color: var(--color-text); + font-size: 32px; + font-weight: 600; +} + +.settings-header p { + margin: 0; + color: var(--color-text-secondary); + font-size: 16px; +} + +.settings-content { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Settings Sections */ +.settings-section { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px; + background: var(--color-background); + border-bottom: 1px solid var(--color-border); +} + +.section-header h2 { + margin: 0; + color: var(--color-text); + font-size: 20px; + font-weight: 600; +} + +.section-content { + padding: 0; +} + +/* Setting Items */ +.setting-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 24px; + gap: 20px; +} + +.setting-item:not(:last-child) { + border-bottom: 1px solid var(--color-border-light); +} + +.setting-info { + flex: 1; + min-width: 0; +} + +.setting-info label { + display: block; + margin-bottom: 4px; + color: var(--color-text); + font-size: 16px; + font-weight: 500; +} + +.setting-info p { + margin: 0; + color: var(--color-text-secondary); + font-size: 14px; + line-height: 1.4; +} + +.setting-control { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; +} + +/* Key Display */ +.key-display { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: 400px; +} + +.pubkey { + flex: 1; + padding: 10px 12px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + word-break: break-all; + line-height: 1.3; + min-width: 0; +} + +.cycle-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: opacity 0.2s; + white-space: nowrap; +} + +.cycle-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.cycle-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Relay Management */ +.relay-list { + width: 100%; + max-width: 500px; +} + +.no-relays { + display: flex; + flex-direction: column; + align-items: center; + padding: 30px 20px; + text-align: center; + color: var(--color-text-muted); +} + +.no-relays p { + margin: 12px 0 0 0; + color: var(--color-text-muted); +} + +.relay-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + margin-bottom: 8px; + transition: border-color 0.2s; +} + +.relay-item:hover { + border-color: var(--color-primary); +} + +.relay-info { + flex: 1; + min-width: 0; +} + +.relay-url { + display: block; + color: var(--color-text); + font-size: 14px; + font-weight: 500; + word-break: break-all; + margin-bottom: 2px; +} + +.relay-status { + display: inline-block; + color: var(--color-success); + font-size: 12px; + padding: 2px 6px; + background: var(--color-surface); + border-radius: 3px; + border: 1px solid var(--color-success); +} + +.remove-relay-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--color-error); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.remove-relay-btn:hover { + opacity: 0.8; +} + +/* Add Relay Form */ +.add-relay-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--color-border-light); +} + +.relay-input-group { + display: flex; + gap: 8px; + width: 100%; +} + +.relay-input { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + color: var(--color-text); + font-size: 14px; +} + +.relay-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.relay-input::placeholder { + color: var(--color-text-muted); +} + +.add-relay-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: opacity 0.2s; + white-space: nowrap; +} + +.add-relay-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.add-relay-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .settings-page { + padding: 16px; + } + + .setting-item { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .setting-control { + width: 100%; + justify-content: stretch; + } + + .key-display { + max-width: none; + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .pubkey { + text-align: center; + } + + .relay-input-group { + flex-direction: column; + } + + .section-header { + padding: 16px 20px; + } + + .setting-item { + padding: 20px; + } +} + +@media (max-width: 480px) { + .settings-header h1 { + font-size: 28px; + } + + .section-header h2 { + font-size: 18px; + } + + .settings-page { + padding: 12px; + } +}
\ No newline at end of file diff --git a/gui/src/styles/ThemeProvider.tsx b/gui/src/styles/ThemeProvider.tsx new file mode 100644 index 0000000..08d2e64 --- /dev/null +++ b/gui/src/styles/ThemeProvider.tsx @@ -0,0 +1,446 @@ +import React, { + createContext, + useContext, + useEffect, + useState, + type ReactNode, +} from "react"; + +export type ThemeName = + | "light" + | "dark" + | "sepia" + | "noir" + | "ocean" + | "forest" + | "gruvbox"; + +export interface ThemeColors { + primary: string; + primaryHover: string; + secondary: string; + accent: string; + accentHover: string; + background: string; + surface: string; + surfaceHover: string; + text: string; + textSecondary: string; + textMuted: string; + border: string; + borderLight: string; + success: string; + warning: string; + error: string; + info: string; + link: string; + linkHover: string; + shadow: string; + overlay: string; +} + +export interface ThemeTypography { + fontSizeXs: string; + fontSizeSm: string; + fontSizeMd: string; + fontSizeLg: string; + fontSizeXl: string; + fontWeightNormal: string; + fontWeightMedium: string; + fontWeightSemibold: string; + fontWeightBold: string; +} + +export interface ThemeSpacing { + spacingXs: string; + spacingSm: string; + spacingMd: string; + spacingLg: string; + spacingXl: string; +} + +export interface ThemeRadius { + radiusSm: string; + radiusMd: string; + radiusLg: string; + radiusFull: string; +} + +export interface ThemeTransitions { + transitionFast: string; + transitionNormal: string; + transitionSlow: string; +} + +export interface Theme { + name: ThemeName; + colors: ThemeColors; + typography: ThemeTypography; + spacing: ThemeSpacing; + radius: ThemeRadius; + transitions: ThemeTransitions; +} + +// Common theme properties +const commonTypography: ThemeTypography = { + fontSizeXs: "0.75rem", + fontSizeSm: "0.875rem", + fontSizeMd: "1rem", + fontSizeLg: "1.125rem", + fontSizeXl: "1.25rem", + fontWeightNormal: "400", + fontWeightMedium: "500", + fontWeightSemibold: "600", + fontWeightBold: "700", +}; + +const commonSpacing: ThemeSpacing = { + spacingXs: "0.25rem", + spacingSm: "0.5rem", + spacingMd: "1rem", + spacingLg: "1.5rem", + spacingXl: "2rem", +}; + +const commonRadius: ThemeRadius = { + radiusSm: "0.25rem", + radiusMd: "0.5rem", + radiusLg: "0.75rem", + radiusFull: "9999px", +}; + +const commonTransitions: ThemeTransitions = { + transitionFast: "150ms ease", + transitionNormal: "250ms ease", + transitionSlow: "350ms ease", +}; + +const themes: Record<ThemeName, Theme> = { + light: { + name: "light", + colors: { + primary: "#543fd7", + primaryHover: "#4532b8", + secondary: "#f39c12", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#ffffff", + surface: "#f8f9fa", + surfaceHover: "#e9ecef", + text: "#212529", + textSecondary: "#495057", + textMuted: "#6c757d", + border: "#dee2e6", + borderLight: "#e9ecef", + success: "#28a745", + warning: "#ffc107", + error: "#dc3545", + info: "#17a2b8", + link: "#543fd7", + linkHover: "#4532b8", + shadow: "rgba(0, 0, 0, 0.1)", + overlay: "rgba(0, 0, 0, 0.5)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, + dark: { + name: "dark", + colors: { + primary: "#7c6ef7", + primaryHover: "#9085f9", + secondary: "#f39c12", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#0d1117", + surface: "#161b22", + surfaceHover: "#21262d", + text: "#c9d1d9", + textSecondary: "#8b949e", + textMuted: "#6e7681", + border: "#30363d", + borderLight: "#21262d", + success: "#3fb950", + warning: "#d29922", + error: "#f85149", + info: "#58a6ff", + link: "#58a6ff", + linkHover: "#79b8ff", + shadow: "rgba(0, 0, 0, 0.3)", + overlay: "rgba(0, 0, 0, 0.7)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, + sepia: { + name: "sepia", + colors: { + primary: "#8b4513", + primaryHover: "#6b3410", + secondary: "#d2691e", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#f4e8d0", + surface: "#ede0c8", + surfaceHover: "#e6d9c0", + text: "#3e2723", + textSecondary: "#5d4037", + textMuted: "#6d4c41", + border: "#d7ccc8", + borderLight: "#e0d5d0", + success: "#689f38", + warning: "#ff9800", + error: "#d32f2f", + info: "#0288d1", + link: "#8b4513", + linkHover: "#6b3410", + shadow: "rgba(62, 39, 35, 0.1)", + overlay: "rgba(62, 39, 35, 0.5)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, + noir: { + name: "noir", + colors: { + primary: "#ffffff", + primaryHover: "#e0e0e0", + secondary: "#808080", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#000000", + surface: "#0a0a0a", + surfaceHover: "#1a1a1a", + text: "#ffffff", + textSecondary: "#b0b0b0", + textMuted: "#808080", + border: "#333333", + borderLight: "#1a1a1a", + success: "#4caf50", + warning: "#ff9800", + error: "#f44336", + info: "#2196f3", + link: "#b0b0b0", + linkHover: "#ffffff", + shadow: "rgba(255, 255, 255, 0.1)", + overlay: "rgba(0, 0, 0, 0.9)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, + ocean: { + name: "ocean", + colors: { + primary: "#006994", + primaryHover: "#005577", + secondary: "#00acc1", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#e1f5fe", + surface: "#b3e5fc", + surfaceHover: "#81d4fa", + text: "#01579b", + textSecondary: "#0277bd", + textMuted: "#4fc3f7", + border: "#81d4fa", + borderLight: "#b3e5fc", + success: "#00c853", + warning: "#ffab00", + error: "#d50000", + info: "#00b0ff", + link: "#0277bd", + linkHover: "#01579b", + shadow: "rgba(1, 87, 155, 0.1)", + overlay: "rgba(1, 87, 155, 0.5)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, + forest: { + name: "forest", + colors: { + primary: "#2e7d32", + primaryHover: "#1b5e20", + secondary: "#689f38", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#f1f8e9", + surface: "#dcedc8", + surfaceHover: "#c5e1a5", + text: "#1b5e20", + textSecondary: "#33691e", + textMuted: "#558b2f", + border: "#aed581", + borderLight: "#c5e1a5", + success: "#4caf50", + warning: "#ff9800", + error: "#f44336", + info: "#03a9f4", + link: "#388e3c", + linkHover: "#2e7d32", + shadow: "rgba(27, 94, 32, 0.1)", + overlay: "rgba(27, 94, 32, 0.5)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, + gruvbox: { + name: "gruvbox", + colors: { + primary: "#fe8019", + primaryHover: "#d65d0e", + secondary: "#fabd2f", + accent: "#2a9d8f", + accentHover: "#238b7f", + background: "#282828", + surface: "#3c3836", + surfaceHover: "#504945", + text: "#ebdbb2", + textSecondary: "#d5c4a1", + textMuted: "#bdae93", + border: "#665c54", + borderLight: "#504945", + success: "#b8bb26", + warning: "#fabd2f", + error: "#fb4934", + info: "#83a598", + link: "#8ec07c", + linkHover: "#b8bb26", + shadow: "rgba(0, 0, 0, 0.3)", + overlay: "rgba(40, 40, 40, 0.8)", + }, + typography: commonTypography, + spacing: commonSpacing, + radius: commonRadius, + transitions: commonTransitions, + }, +}; + +interface ThemeContextType { + theme: Theme; + themeName: ThemeName; + setTheme: (name: ThemeName) => void; + availableThemes: ThemeName[]; +} + +const ThemeContext = createContext<ThemeContextType | undefined>(undefined); + +interface ThemeProviderProps { + children: ReactNode; + defaultTheme?: ThemeName; +} + +export const ThemeProvider: React.FC<ThemeProviderProps> = ({ + children, + defaultTheme = "light", +}) => { + const [themeName, setThemeName] = useState<ThemeName>(() => { + const savedTheme = localStorage.getItem("theme") as ThemeName; + if (savedTheme && themes[savedTheme]) { + return savedTheme; + } + + if ( + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + return "dark"; + } + + return defaultTheme; + }); + + const theme = themes[themeName]; + + useEffect(() => { + const root = document.documentElement; + + root.setAttribute("data-theme", themeName); + + // Set color variables + Object.entries(theme.colors).forEach(([key, value]) => { + const cssVarName = `--color-${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`; + root.style.setProperty(cssVarName, value); + }); + + // Set typography variables + Object.entries(theme.typography).forEach(([key, value]) => { + const cssVarName = `--${key.replace(/([A-Z])/g, "-$1").toLowerCase().replace("font-", "font-").replace("size", "").replace("weight", "")}`; + root.style.setProperty(cssVarName, value); + }); + + // Set spacing variables + Object.entries(theme.spacing).forEach(([key, value]) => { + const cssVarName = `--${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`; + root.style.setProperty(cssVarName, value); + }); + + // Set radius variables + Object.entries(theme.radius).forEach(([key, value]) => { + const cssVarName = `--${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`; + root.style.setProperty(cssVarName, value); + }); + + // Set transition variables + Object.entries(theme.transitions).forEach(([key, value]) => { + const cssVarName = `--${key.replace(/([A-Z])/g, "-$1").toLowerCase()}`; + root.style.setProperty(cssVarName, value); + }); + + // Legacy variables for backward compatibility + root.style.setProperty('--text-color', theme.colors.text); + root.style.setProperty('--background-color', theme.colors.background); + + localStorage.setItem("theme", themeName); + }, [themeName, theme]); + + useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = (e: MediaQueryListEvent) => { + const savedTheme = localStorage.getItem("theme"); + if (!savedTheme) { + setThemeName(e.matches ? "dark" : "light"); + } + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, []); + + const setTheme = (name: ThemeName) => { + if (themes[name]) { + setThemeName(name); + } + }; + + const value: ThemeContextType = { + theme, + themeName, + setTheme, + availableThemes: Object.keys(themes) as ThemeName[], + }; + + return ( + <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> + ); +}; + +export const useTheme = (): ThemeContextType => { + const context = useContext(ThemeContext); + if (context === undefined) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +}; diff --git a/gui/src/styles/ThemeSwitcher.css b/gui/src/styles/ThemeSwitcher.css new file mode 100644 index 0000000..6b48545 --- /dev/null +++ b/gui/src/styles/ThemeSwitcher.css @@ -0,0 +1,252 @@ +/* Theme Switcher Styles */ + +/* Compact variant */ +.theme-switcher-compact { + display: inline-flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-full); + cursor: pointer; + transition: all var(--transition-fast); + font-size: var(--font-md); + color: var(--color-text); +} + +.theme-switcher-compact:hover { + background-color: var(--color-surface-hover); + border-color: var(--color-primary); + transform: scale(1.05); +} + +.theme-switcher-compact:active { + transform: scale(0.98); +} + +.theme-switcher-compact .theme-icon { + font-size: 1.2em; + display: flex; + align-items: center; +} + +.theme-switcher-compact .theme-label { + font-weight: var(--font-medium); +} + +/* Buttons variant */ +.theme-switcher-buttons { + display: flex; + align-items: center; + gap: var(--spacing-md); +} + +.theme-switcher-buttons .theme-label { + color: var(--color-text-secondary); + font-weight: var(--font-medium); +} + +.theme-buttons-group { + display: flex; + gap: var(--spacing-xs); + background-color: var(--color-surface); + padding: var(--spacing-xs); + border-radius: var(--radius-lg); + border: 1px solid var(--color-border); +} + +.theme-button { + display: flex; + align-items: center; + gap: var(--spacing-xs); + padding: var(--spacing-xs) var(--spacing-sm); + background-color: transparent; + border: 1px solid transparent; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + color: var(--color-text-secondary); + font-size: var(--font-sm); +} + +.theme-button:hover { + background-color: var(--color-surface-hover); + color: var(--color-text); +} + +.theme-button.active { + background-color: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +.theme-button .theme-icon { + font-size: 1.1em; +} + +.theme-button .theme-name { + display: none; +} + +@media (min-width: 768px) { + .theme-button .theme-name { + display: inline; + } +} + +/* Dropdown variant */ +.theme-switcher-dropdown { + position: relative; + display: inline-block; +} + +.theme-dropdown-toggle { + display: flex; + align-items: center; + gap: var(--spacing-sm); + padding: var(--spacing-sm) var(--spacing-md); + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + color: var(--color-text); + font-size: var(--font-md); +} + +.theme-dropdown-toggle:hover { + background-color: var(--color-surface-hover); + border-color: var(--color-primary); +} + +.theme-dropdown-toggle .theme-icon { + font-size: 1.2em; +} + +.theme-dropdown-toggle .theme-label { + font-weight: var(--font-medium); +} + +.theme-dropdown-toggle .dropdown-arrow { + font-size: 0.7em; + margin-left: var(--spacing-xs); + transition: transform var(--transition-fast); + color: var(--color-text-muted); +} + +.theme-dropdown-toggle[aria-expanded="true"] .dropdown-arrow { + transform: rotate(180deg); +} + +.theme-dropdown-backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-dropdown); + background-color: transparent; +} + +.theme-dropdown-menu { + position: absolute; + top: calc(100% + var(--spacing-xs)); + right: 0; + left: 0; + min-width: 180px; + background-color: var(--color-background); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: 0 4px 12px var(--color-shadow); + z-index: calc(var(--z-dropdown) + 1); + padding: var(--spacing-xs); + animation: dropdownSlide 0.2s ease-out; +} + +@keyframes dropdownSlide { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.theme-dropdown-item { + display: flex; + align-items: center; + gap: var(--spacing-sm); + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background-color: transparent; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + color: var(--color-text); + font-size: var(--font-md); + text-align: left; +} + +.theme-dropdown-item:hover { + background-color: var(--color-surface); +} + +.theme-dropdown-item.active { + background-color: var(--color-surface); + color: var(--color-primary); + font-weight: var(--font-medium); +} + +.theme-dropdown-item .theme-icon { + font-size: 1.2em; + width: 1.5em; + text-align: center; +} + +.theme-dropdown-item .theme-name { + flex: 1; +} + +.theme-dropdown-item .checkmark { + color: var(--color-success); + font-weight: var(--font-bold); +} + +/* Accessibility */ +.theme-switcher-compact:focus, +.theme-button:focus, +.theme-dropdown-toggle:focus, +.theme-dropdown-item:focus { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Dark theme adjustments */ +[data-theme="dark"] .theme-dropdown-menu { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + + .theme-switcher-compact, + .theme-button, + .theme-dropdown-toggle, + .theme-dropdown-item, + .dropdown-arrow { + transition: none; + } + + .theme-dropdown-menu { + animation: none; + } + + .theme-switcher-compact:hover { + transform: none; + } +}
\ No newline at end of file diff --git a/gui/src/styles/ThemeSwitcher.tsx b/gui/src/styles/ThemeSwitcher.tsx new file mode 100644 index 0000000..425bed9 --- /dev/null +++ b/gui/src/styles/ThemeSwitcher.tsx @@ -0,0 +1,131 @@ +import React, { useState } from "react"; +import { useTheme, type ThemeName } from "../styles/ThemeProvider"; +import "./ThemeSwitcher.css"; + +interface ThemeSwitcherProps { + variant?: "dropdown" | "buttons" | "compact"; + showLabel?: boolean; +} + +const themeIcons: Record<ThemeName, string> = { + light: "☀️", + dark: "🌙", + sepia: "📜", + noir: "⚫", + ocean: "🌊", + forest: "🌲", + gruvbox: "🍂", +}; + +const themeLabels: Record<ThemeName, string> = { + light: "Light", + dark: "Dark", + sepia: "Sepia", + noir: "Noir", + ocean: "Ocean", + forest: "Forest", + gruvbox: "Gruvbox", +}; + +export const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ + variant = "dropdown", + showLabel = true, +}) => { + const { themeName, setTheme, availableThemes } = useTheme(); + const [isOpen, setIsOpen] = useState(false); + + const handleThemeChange = (theme: ThemeName) => { + setTheme(theme); + setIsOpen(false); + }; + + const cycleTheme = () => { + const currentIndex = availableThemes.indexOf(themeName); + const nextIndex = (currentIndex + 1) % availableThemes.length; + setTheme(availableThemes[nextIndex]); + }; + + if (variant === "compact") { + return ( + <button + className="theme-switcher-compact" + onClick={cycleTheme} + title={`Current theme: ${themeLabels[themeName]}. Click to switch.`} + aria-label="Switch theme" + > + <span className="theme-icon">{themeIcons[themeName]}</span> + {showLabel && ( + <span className="theme-label">{themeLabels[themeName]}</span> + )} + </button> + ); + } + + if (variant === "buttons") { + return ( + <div className="theme-switcher-buttons"> + {showLabel && <span className="theme-label">Theme:</span>} + <div className="theme-buttons-group"> + {availableThemes.map((theme) => ( + <button + key={theme} + className={`theme-button ${themeName === theme ? "active" : ""}`} + onClick={() => handleThemeChange(theme)} + title={themeLabels[theme]} + aria-label={`Switch to ${themeLabels[theme]} theme`} + aria-pressed={themeName === theme} + > + <span className="theme-icon">{themeIcons[theme]}</span> + {showLabel && ( + <span className="theme-name">{themeLabels[theme]}</span> + )} + </button> + ))} + </div> + </div> + ); + } + + // Default dropdown variant + return ( + <div className="theme-switcher-dropdown"> + <button + className="theme-dropdown-toggle" + onClick={() => setIsOpen(!isOpen)} + aria-haspopup="true" + aria-expanded={isOpen} + > + <span className="theme-icon">{themeIcons[themeName]}</span> + {showLabel && ( + <span className="theme-label">{themeLabels[themeName]}</span> + )} + <span className="dropdown-arrow">▼</span> + </button> + + {isOpen && ( + <> + <div + className="theme-dropdown-backdrop" + onClick={() => setIsOpen(false)} + aria-hidden="true" + /> + <div className="theme-dropdown-menu" role="menu"> + {availableThemes.map((theme) => ( + <button + key={theme} + className={`theme-dropdown-item ${themeName === theme ? "active" : ""}`} + onClick={() => handleThemeChange(theme)} + role="menuitem" + aria-selected={themeName === theme} + > + <span className="theme-icon">{themeIcons[theme]}</span> + <span className="theme-name">{themeLabels[theme]}</span> + {themeName === theme && <span className="checkmark">✓</span>} + </button> + ))} + </div> + </> + )} + </div> + ); +}; diff --git a/gui/src/styles/feed.css b/gui/src/styles/feed.css new file mode 100644 index 0000000..02d64db --- /dev/null +++ b/gui/src/styles/feed.css @@ -0,0 +1,134 @@ +.avatar { + border: 1px solid var(--color-text); +} + +/* Nostr Feed Styles */ +.nostr-empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + padding: 40px 20px; +} + +.empty-content { + text-align: center; + max-width: 400px; +} + +.empty-content h3 { + margin: 20px 0 10px 0; + color: var(--color-text); + font-size: 24px; +} + +.empty-content p { + color: var(--color-text-secondary); + line-height: 1.5; + margin-bottom: 30px; +} + +.resync-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: 500; + transition: opacity 0.2s ease; +} + +.resync-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.resync-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.nostr-feed { + width: 100%; +} + +.nostr-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--color-surface); + border-radius: 8px; + margin-bottom: 16px; + border: 1px solid var(--color-border); +} + +.feed-info { + display: flex; + align-items: center; + gap: 12px; +} + +.feed-info h4 { + margin: 0; + color: var(--color-text); + font-size: 18px; +} + +.post-count { + color: var(--color-text-secondary); + font-size: 14px; + background: var(--color-background); + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--color-border); +} + +.resync-btn-small { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--color-text); +} + +.resync-btn-small:hover:not(:disabled) { + background: var(--color-surface-hover); + border-color: var(--color-primary); +} + +.resync-btn-small:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-spinner, +.btn-spinner-small { + width: 16px; + height: 16px; + animation: spin 1s linear infinite; +} + +.btn-spinner-small { + width: 14px; + height: 14px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +}
\ No newline at end of file diff --git a/gui/src/styles/styles.css b/gui/src/styles/styles.css new file mode 100644 index 0000000..c105656 --- /dev/null +++ b/gui/src/styles/styles.css @@ -0,0 +1,704 @@ +@import "tailwindcss"; + +/* assets */ +/* fonts */ +@font-face { + font-family: "Inter"; + src: url(/fonts/Inter/Inter-VariableFont_opsz,wght.ttf); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Inter"; + src: url(/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "Source Code Pro"; + src: url(/fonts/Source_Code_Pro/SourceCodePro-VariableFont_wght.ttf); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Source Code Pro"; + src: url(/fonts/Source_Code_Pro/SourceCodePro-Italic-VariableFont_wght.ttf); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} + +/* tailwindy */ + +.global-center { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + +.centered { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + + +.grow { + flex-grow: 1; +} + +button { + cursor: pointer; +} + +code { + font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", + monospace; +} + +t .red { + background-color: rgb(200, 0, 0, 0.9); +} + +.tc, +.ct { + text-align: center; +} + +.cb { + margin: auto; +} + +.xc { + position: absolute; + left: 50%; + transform: translateX(-50%); +} + +.hidden { + display: none; +} + +.x-center { + margin: auto; + text-align: center; + display: block; +} + +.flex { + display: flex; +} + +.f1 { + display: flex; + justify-content: space-between; + align-items: center; +} + +.flex-align { + display: flex; + gap: 1rem; + align-items: center; +} + +.noscroll { + overflow: hidden; +} + +.scroll-y { + overflow-y: scroll; +} + +.cp { + cursor: pointer; +} + +.m0 { + margin: 0; +} + +.mb { + margin: 0 0 1rem 0; +} + +.mt { + margin-top: 1rem; +} + +.mr { + margin-right: 0.5rem; +} + +.s-50 { + width: 50px; +} + +.s-100 { + width: 100px; +} + +.border { + border: 1px solid var(--text-color); +} + +/* styles */ + +/* common */ +html { + box-sizing: border-box; + color: var(--text-color); + background-color: var(--background-color); +} + +html, +body, +#root, +#mobile-ui { + height: 100%; + width: 100vw; + overflow: hidden; + /* no scrolling!!!*/ +} + +*, +*:before, +*:after { + box-sizing: inherit; +} + +body { + margin: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--color-background); + color: var(--color-text); + line-height: 1.6; + transition: background-color var(--transition-normal), color var(--transition-normal); +} + +/* Typography */ +h1, +h2, +h3, +h4, +h5, +h6 { + margin-bottom: var(--spacing-md); + font-weight: var(--font-semibold); + line-height: 1.2; + color: var(--color-text); +} + +#root { + margin: 1rem 2rem; + height: 100%; + overflow-y: auto; + font-family: "Inter"; + + + display: flex; + + & #left-menu { + margin-right: 1rem; + + #logo { + display: flex; + gap: 0.3rem; + + & img { + width: 48px; + height: 48px; + } + } + + & .opt { + cursor: pointer; + display: flex; + gap: 1rem; + margin: 1rem 0; + + + & img { + width: 24px; + height: 24px; + } + } + + .opt.tbd { + opacity: 0.4; + } + } + + & main { + width: 726px; + margin: auto; + height: 100vh; + + & #top-tabs { + display: flex; + gap: 2rem; + justify-content: center; + + & div { + cursor: pointer; + } + + & .active { + font-weight: 700; + border-bottom: 3px solid var(--color-text); + } + } + + & #feed-proper { + margin-top: 1rem; + border: 1px solid grey; + border-radius: 0.75rem; + + & #composer { + padding: 16px; + display: flex; + gap: 0.75rem; + transition: all 0.3s ease; + border-bottom: 1px solid rgba(128, 128, 128, 0.2); + + &.expanded { + padding: 20px 16px; + background: linear-gradient(to bottom, rgba(128, 128, 128, 0.05), transparent); + } + + &.has-context { + min-height: 120px; + } + + & .sigil { + width: 48px; + height: 48px; + flex-shrink: 0; + + & img { + width: inherit; + border-radius: 50%; + } + } + + & .composer-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 12px; + } + + & .composer-context { + background: rgba(128, 128, 128, 0.08); + border-radius: 12px; + padding: 12px; + position: relative; + animation: slideDown 0.3s ease; + + & .composer-snippet { + max-height: 200px; + overflow-y: auto; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + + &>div { + padding: 8px; + } + } + + & #reply { + background: transparent; + padding: 0; + } + } + + & .reply-context { + margin-bottom: 12px; + border-left: 3px solid var(--color-accent, #2a9d8f); + background: rgba(42, 157, 143, 0.08); + } + + & .quote-context { + margin-top: 12px; + border-left: 3px solid var(--color-secondary, #e76f51); + background: rgba(231, 111, 81, 0.08); + } + + & .quote-header { + margin-bottom: 12px; + padding: 8px 12px; + background: rgba(231, 111, 81, 0.08); + border-radius: 8px; + border-left: 3px solid var(--color-secondary, #e76f51); + } + + & .context-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + & .context-type { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.85rem; + color: var(--color-text-muted, #888); + font-weight: 500; + } + + & .clear-context { + background: none; + border: none; + color: var(--color-text-muted, #888); + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + padding: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s; + + &:hover { + background: rgba(128, 128, 128, 0.2); + color: var(--color-text); + } + } + } + + & .quote-header .context-header { + margin-bottom: 0; + } + + & .composer-input-row { + display: flex; + gap: 12px; + align-items: center; + } + + & input { + background-color: transparent; + color: var(--color-text); + flex-grow: 1; + border: none; + outline: none; + font-size: 1rem; + padding: 8px 0; + border-bottom: 2px solid transparent; + transition: border-color 0.2s; + + &:focus { + border-bottom-color: var(--color-accent, #2a9d8f); + } + + &::placeholder { + color: var(--color-text-muted, #888); + } + } + + & .post-btn { + padding: 8px 20px; + background: var(--color-accent, #2a9d8f); + color: white; + border: none; + border-radius: 20px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; + + &:hover:not(:disabled) { + background: var(--color-accent-hover, #238b7f); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(42, 157, 143, 0.3); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + } + } + + @keyframes slideDown { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + } + + /* Thread page styling */ + & .thread-header { + margin-bottom: 1rem; + padding: 1rem 0; + border-bottom: 1px solid rgba(128, 128, 128, 0.2); + + & h2 { + margin: 0.5rem 0; + font-size: 1.5rem; + color: var(--color-text); + } + + & .thread-nav { + margin-bottom: 0.5rem; + + & .back-btn { + background: rgba(128, 128, 128, 0.1); + border: 1px solid rgba(128, 128, 128, 0.3); + border-radius: 8px; + padding: 8px 12px; + color: var(--color-text); + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; + transition: all 0.2s; + + &:hover { + background: rgba(128, 128, 128, 0.2); + transform: translateX(-2px); + } + + & span { + font-weight: 500; + } + } + } + + & .thread-info { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.9rem; + color: var(--color-text-muted, #888); + + & .thread-host { + font-family: "Source Code Pro", monospace; + background: rgba(128, 128, 128, 0.1); + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + } + + & .thread-separator { + opacity: 0.5; + } + + & .thread-id { + font-family: "Source Code Pro", monospace; + background: rgba(42, 157, 143, 0.1); + color: var(--color-accent, #2a9d8f); + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + } + } + } + + & .thread-content { + /* Use same styling as feed content */ + } + + & .loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + + & img { + width: 40px; + height: 40px; + } + } + } + + & .trill-post, + & .twatter-post { + border-top: 1px solid grey; + + & .left { + margin-right: 10px; + width: unset; + + & .sigil { + width: 48px; + height: 48px; + } + } + + & header { + align-items: center; + justify-content: left; + + & .author { + flex: unset; + gap: 0; + + & .name { + display: flex; + align-items: center; + + & .p { + font-family: "Source Code Pro"; + } + } + } + + & .date { + color: grey; + } + + } + + & footer { + justify-content: left; + margin: unset; + + & .icon { + margin: 0; + align-items: center; + gap: 0.2rem; + width: 64px; + + & img { + height: 18px; + } + + & .react-img { + height: 24px; + } + + & .react-icon { + font-size: 20px; + } + + & span { + margin-right: unset; + text-align: left; + font-size: 14px; + line-height: 1rem; + color: grey; + width: unset; + } + } + + & .menu-icon { + margin-left: auto; + } + } + } + + + & .user-contact { + & .contact-cover { + margin-bottom: -40px; + + & img { + width: 100%; + height: 100%; + object-fit: cover; + } + } + + & .contact-name { + display: flex; + align-items: center; + gap: 0.5rem; + } + + & .contact-username { + margin-top: 1rem; + font-family: "Source Code Pro"; + font-weight: 400; + } + + & button { + width: unset; + margin: unset; + height: unset; + } + } +} + +& button { + font-size: 0.9rem; + font-weight: 700; + line-height: 1rem; + border: none; + border-radius: 2rem; + padding: 0.5rem 2rem; +} + +& .sigil, +& .sigil svg { + border-radius: 0.5rem; +} + +#big-button { + position: absolute; + right: 2rem; + bottom: 2rem; + font-size: 1.5rem; + font-weight: bold; + cursor: pointer; + text-align: center; + line-height: 3rem; + width: 3rem; + height: 3rem; + border-radius: 50%; + z-index: 100; +} + +/* modal */ +#modal-background { + height: 100vh; + width: 100vw; + background-color: rgb(0, 0, 0, 0.9); + position: fixed; + top: 0; + left: 0; + z-index: 100; +} + +#modal { + position: fixed; + top: 50%; + left: 50%; + width: 80%; + z-index: 101; + transform: translate(-50%, -50%); + background-color: var(--background-color); + padding: 1rem; + max-height: 80%; +} + +.modal-buttons { + display: flex; + justify-content: space-around; +} + +::-webkit-scrollbar { + display: none; +}
\ No newline at end of file diff --git a/gui/src/styles/trill.css b/gui/src/styles/trill.css new file mode 100644 index 0000000..0a21ed5 --- /dev/null +++ b/gui/src/styles/trill.css @@ -0,0 +1,623 @@ +#not-found { + margin: auto; + padding: 2rem; + text-align: center; +} + +#not-found button { + height: 1.5rem; + margin: auto; +} + +#timeline { + overflow-y: auto; + overflow-x: hidden; +} + +.timeline-post { + width: 100%; + border-top: 1px solid var(--text-color); +} + +.trill-post { + display: flex; + padding: 0.5rem; + /* min-height: 150px; */ +} + +.trill-post .author { + flex: 1 0 auto; +} + +.trill-reply-thread { + border-top: 1px solid var(--text-color); +} + +.trill-post:first-child { + border-top: none; +} + +.trill-post:last-child { + border-bottom: 1px solid var(--text-color); +} + +.trill-post .left { + width: 8%; + margin-right: 1rem; +} + +.trill-post .sigil { + height: 42px; + width: 42px; +} + +.trill-post .right { + width: 90%; +} + +.trill-post header { + display: flex; + justify-content: space-between; +} + +.trill-post header p { + margin: 0 0.3rem; +} + +.trill-post .nick { + font-weight: 700; +} + +.trill-post header .p { + font-family: "Courier New", Courier, monospace; + font-weight: 100; + font-size: 1rem; +} + +.trill-post .p-only { + margin: 0.7rem 0.3rem; + font-weight: 700; +} + +.trill-post .p { + /* margin-top: -5px; */ +} + +.trill-post a { + text-decoration: 0; + color: var(--text-color); +} + +.trill-post blockquote { + border-left: 2px solid grey; + margin-left: 0; + padding-left: 0.5rem; + opacity: 70%; +} + +.trill-post .body { + margin: 1rem; + margin-left: 0; +} + +.trill-post-body p span { + /* margin: 0 3px; */ +} + +.trill-post pre { + font-family: "Courier New", Courier, monospace; + background-color: rgb(200, 200, 200, 0.5); + padding: 0.2rem; + max-width: 90%; + border: 1px solid var(--text-color); + overflow: scroll; +} + +.trill-post .quote-in-post .body { + margin: 0; +} + +.quote-in-post svg { + margin-right: 0.5rem; +} + +.trill-post .body-text { + /* font-family: Arial, Helvetica, sans-serif; */ + margin: 0.3rem 0 1rem 0; + word-break: break-word; +} + +.trill-post .trill-post-paragraph { + margin-block-start: 1em; + margin-block-end: 1em; +} + +.trill-post .body-text a { + text-decoration: underline; +} + +.trill-post .token { + margin: 0 0.5rem; +} + +.trill-post .date { + float: right; +} + +.trill-post .nav { + display: flex; +} + +.trill-post .chevron { + width: 1.5rem; + height: 1.5rem; +} + +.body-media { + width: 100%; + max-height: 520px; + text-align: center; + /* images being inline */ +} + +.body-media img { + margin: 1px 3px; +} + +.body-img-1-of-1 { + max-width: 100%; + max-height: inherit; + margin: auto !important; +} + +.body-img-1-of-2 { + max-width: 48.5%; + max-height: inherit; +} + +.body-img-1-of-3 { + max-width: 48.5%; +} + +.body-img-1-of-4 { + max-width: 48.5%; +} + +.body-img-1-of-5 { + max-width: 31%; +} + +.body-img-1-of-6 { + max-width: 31%; +} + +.body-img-1-of-7 { + max-width: 31%; +} + +.body-img-1-of-8 { + max-width: 31%; +} + +.body-img-1-of-9 { + max-width: 31%; +} + +/* quotes */ + +.quote-in-post { + margin-top: 1rem; + padding: 0.5rem; + border: 1px solid grey; + border-radius: 0.5rem; + cursor: pointer; +} + +.quote-in-post header { + display: flex; +} + +.mention { + font-family: "Courier New", Courier, monospace; + font-weight: 700; +} + +.mention:hover { + cursor: pointer; + text-decoration: underline; +} + +.bad-quote { + border: 1px solid var(--text-color); + padding: 7px; + border-radius: 0.5rem; +} + +/* post-cards */ +.trill-post-card { + position: relative; + border-radius: 0.3rem; + /* margin: 1rem 0 0 -8%; */ + margin: 0.5rem 0; +} + +.trill-post-card-logo { + position: absolute; + width: 25px; + height: 25px; + top: -17px; + left: -17px; +} + +#post-menu { + position: absolute; + top: 0; + right: 50px; + z-index: 99; +} + +.deleted-post { + text-align: center; + border: 1px solid var(--text-color); + padding: 0.4rem; +} + +#post-menu p { + background-color: var(--background-color); + margin: 0; + padding: 0.5rem; + cursor: pointer; + border: 1px solid var(--text-color); + height: 40px; +} + +#post-menu p:hover { + /* background-color: var(--highlighted-grey); */ +} + +/* threads */ +.trill-reply-thread { + margin-top: 1rem; +} + +#replies>.trill-post:first-child { + border-top: 1px solid black; +} + +/* footer */ + +.footer-wrapper { + position: relative; + /* transform: rotate(0deg); */ + /* the dummy transform enforces position fixed inheritance */ +} + +.post-footer footer { + display: flex; + margin-left: -20px; + height: 24px; + justify-content: space-between; +} + +footer .icon { + cursor: pointer; + margin: 0 0.2rem; + display: flex; + /* min-width: 64px; */ +} + +footer .icon .icon-wrapper { + cursor: pointer; + display: inline-block; + transition: transform 0.1s ease, opacity 0.1s ease; +} + +footer .icon .icon-wrapper:hover { + transform: scale(1.1); + opacity: 0.8; +} + +footer #menu-icon { + width: 32px !important; + /* margin-left: 20px; */ +} + +.post-footer footer .icon img { + display: block; + width: 24px; + height: 24px; +} + +footer .icon span { + display: block; + width: 30px; + text-align: right; + padding-top: 0.2rem; + margin-right: 0.4rem; +} + +footer .icon span:hover { + text-decoration: underline; +} + +.react-icon { + font-size: 26px; + margin: -10px 0 0 0 !important; + padding: 0; + padding-top: 0 !important; +} + +#react-list { + display: flex; + flex-wrap: wrap; +} + +#react-list img { + margin: 3px; + width: 50px; + height: 50px; + cursor: pointer; + border: 1px solid transparent; +} + +#react-list span { + width: 50px; + height: 50px; + font-size: 38px; + margin: 3px; + cursor: pointer; + border: 1px solid transparent; +} + +#react-list span:hover, +#react-list img:hover { + border: 1px solid var(--text-color); +} + +#menu-background { + position: fixed; + top: 0; + left: 0; + opacity: 0; + height: 100vh; + width: 100vw; +} + +/* contact */ + +.contact-cover { + height: 150px; + max-width: 100vw; + margin-bottom: -50px; +} + +#contact-proper { + padding: 1rem; +} + +#contact-proper .row { + display: flex; +} + +.contact-avatar { + width: 6rem; + height: 6rem; +} + +.contact-name { + margin-top: 1rem; + margin-bottom: 0.5rem; + margin-left: 0.3rem; + font-weight: 700; + font-size: 1.1rem; +} + +.contact-username { + margin-top: -10px; +} + +#contact-proper .buttons { + margin-top: 2rem; + margin-left: auto; +} + +#contact-proper .buttons button { + width: 5rem; + margin-bottom: 5px; + height: 1.5rem; +} + +#contact-proper .p { + font-family: "Courier New", Courier, monospace; +} + +#contact-proper .p-only { + margin-top: 1rem; +} + +.bio-row { + display: flex; + align-items: center; +} + +.stats-row { + display: flex; + justify-content: center; +} + +.stats-icon { + margin: 0 2px; +} + +.stats-row p { + text-align: center; + font-size: 1.3rem; + margin: -5px 0 0 0; +} + +.stats-row img { + width: 32px; +} + +.locked-notice, +.suspended-notice { + text-align: center; +} + +.cover-placeholder { + height: 150px; + background-color: rgb(125, 125, 125, 0.5); +} + +#stats-modal .trill-post { + border-bottom: 1px solid var(--text-color) !important; +} + +#stats-modal { + height: 80vh; +} + +#stats-modal #engagement { + min-height: 40%; + max-height: 40%; + overflow-y: scroll; +} + +#stats-modal .trill-post { + max-height: 50%; + overflow-y: scroll; +} + +.btw { + display: flex; + justify-content: space-between; + align-items: center; +} + +#stats-modal .react-stat img { + width: 32px; + height: 32px; +} + +#stats-modal .react-stat react-icon { + width: 32px; + height: 32px; +} + +#stats-modal #engagement .nickname { + font-size: 1rem; +} + +#stats-modal #engagement .p { + font-size: 0.9rem; +} + +#stats-modal .tab h4 { + font-weight: 100; +} + +#stats-modal .tab.active-tab h4 { + font-weight: 700; +} + +/* .not-found { + border: 1px solid var(--text-color); + border-radius: 1rem; + padding: 0.5rem; +} */ + +/* refs */ +.reference {} + +/* polls */ +.trill-poll { + /* border: 1px solid var(--text-color); */ + border-radius: 1rem; + padding: 0.5rem; + position: relative; + background: linear-gradient(90deg, + rgba(255, 255, 168, 0.4) 0%, + /* Lighter yellow */ + rgba(255, 233, 150, 0.5) 52%, + /* Mid-tone gold */ + rgba(255, 209, 0, 0.4) 100% + /* Deeper gold */ + ); +} + +.trill-poll .poll-option { + height: 2rem; + align-items: center; + text-align: center; + border: 1px solid var(--text-color); + border-radius: 0.7rem; + margin: 1rem; + position: relative; + outline: 3px solid transparent; +} + +.trill-poll .my-vote:hover { + /* cursor:not-allowed */ +} + +.trill-poll .poll-option:hover { + opacity: 50%; + outline-color: var(--text-color); +} + +.trill-poll .poll-option p { + padding: 0 0.5rem; + margin: 0; + line-height: 2rem; +} + +.trill-poll .poll-option-stats { + height: 2rem; + position: relative; +} + +.trill-poll .poll-option-bar { + height: 100%; + position: absolute; + background-color: rgb(100, 100, 100, 0.3); + border-radius: 0.7rem; +} + +.trill-poll .my-vote { + border: 3px solid var(--text-color); + border-right: 4px solid var(--text-color); +} + +.trill-poll .bottom-row { + opacity: 60%; +} + +.youtube-thumbnail { + width: 70%; + margin: 0.7rem auto; +} + +.cursor-button { + width: 100%; + padding: 1rem; + border-top: 1px solid var(--text-color); +} + +.cursor-button button { + display: block; + margin: auto; + padding: 0.5rem; +} + +.rumor-quote img { + width: 50px; + margin-right: 1rem; +} + +#trill-thread { + flex-grow: 1; + height: 100%; + overflow-y: auto; + + +}
\ No newline at end of file diff --git a/gui/src/types/nostr.ts b/gui/src/types/nostr.ts new file mode 100644 index 0000000..90610d1 --- /dev/null +++ b/gui/src/types/nostr.ts @@ -0,0 +1,12 @@ +export type Event = { + id: string; // hex, no 0x, 32bytes + pubkey: string; // "" + sig: string; // "", 64 bytes + created_at: number; + kind: number; + tags: Tag[]; + content: string; +}; + +export type NostrEvent = Event; +export type Tag = string[]; diff --git a/gui/src/types/nostrill.ts b/gui/src/types/nostrill.ts new file mode 100644 index 0000000..5ce033c --- /dev/null +++ b/gui/src/types/nostrill.ts @@ -0,0 +1,24 @@ +import type { NostrEvent } from "./nostr"; +import type { Poast } from "./trill"; + +export type UserType = { urbit: string } | { nostr: string }; +export type UserProfile = { + name: string; + picture: string; // URL + about: string; + other: Record<string, string>; +}; + +export type PostWrapper = + | { nostr: NostrPost } + | { urbit: { post: Poast; nostr?: NostrMetadata } }; +export type NostrPost = { + relay: string; + event: NostrEvent; + post: Poast; +}; +export type NostrMetadata = { + pubkey?: string; + eventId: string; + relay?: string; +}; diff --git a/gui/src/types/notifications.ts b/gui/src/types/notifications.ts new file mode 100644 index 0000000..760702a --- /dev/null +++ b/gui/src/types/notifications.ts @@ -0,0 +1,28 @@ +import type { Ship } from "./urbit"; + +export type NotificationType = + | "follow" + | "unfollow" + | "mention" + | "reply" + | "repost" + | "react" + | "access_request" + | "access_granted"; + +export interface Notification { + id: string; + type: NotificationType; + from: Ship | string; // Ship for Urbit users, string for Nostr pubkeys + timestamp: Date; + read: boolean; + // Optional context data + postId?: string; + message?: string; + reaction?: string; +} + +export interface NotificationState { + notifications: Notification[]; + unreadCount: number; +}
\ No newline at end of file diff --git a/gui/src/types/trill.ts b/gui/src/types/trill.ts new file mode 100644 index 0000000..984b1f3 --- /dev/null +++ b/gui/src/types/trill.ts @@ -0,0 +1,420 @@ +import type { Ship } from "./urbit"; + +export type SortugRef = { + type: string; // could call it app... anyway + ship: Ship; + path: string; // `/${string}` +}; + +export type PostID = string; // +export type ID = string; // +export interface PID { + ship: Ship; + id: ID; +} + +export type TrillNode = Poast | FullNode; +export type FullFeed = Record<ID, FullNode>; +export type FlatFeed = Record<ID, Poast>; + +export interface Engagement { + reacts: ReactMap; + quoted: Array<{ pid: PID }>; + shared: Array<{ pid: PID }>; +} +export type ReactMap = Record<Ship, string>; +export interface SentPoast { + host: Ship; + author: Ship; + thread: ID | null; + parent: ID | null; + contents: string; + read: Lock; + write: Lock; + tags: string[]; +} +export type Poast = { + host: Ship; + author: Ship; + thread: ID | null; + parent: ID | null; + read: Lock; + write: Lock; + tags: string[]; + contents: Content; + id: string; + time: number; // not in the backend + children: ID[]; + engagement: Engagement; + tlonRumor?: boolean; + json?: { origin: ExternalApp; content: string }; // for rumor quoting +}; +export type FullNode = Omit<Poast, "children"> & { + children: FullFeed; + prov?: boolean; +}; +export type Content = Block[]; +export type Block = + | Paragraph + | Blockquote + | Heading + | ListBlock + | Codeblock + | Eval + | Media + | Reference + | ExternalContent; + +export type Paragraph = { paragraph: Inline[] }; +export type Blockquote = { blockquote: Inline[] }; +export type Heading = { heading: { text: string; num: number } }; +export type Codeblock = { codeblock: { code: string; lang: string } }; +export type Eval = { hoon: string }; +export type ListBlock = { list: { ordered: boolean; text: Inline[] } }; +export type Media = { media: PostImages | PostVideo | PostAudio }; +export type PostImages = { images: string[] }; +export type PostVideo = { video: string }; +export type PostAudio = { audio: string }; +export type Reference = { ref: { type: string; ship: Ship; path: string } }; + +export type Inline = + | TextInline + | Italic + | Bold + | Strike + | Underline + | Superscript + | Subscript + | Mention + | Codespan + | LinkInline + | Break; +export type TextInline = { text: string }; +export type Italic = { italic: string }; +export type Bold = { bold: string }; +export type Strike = { strike: string }; +export type Underline = { underline: string }; +export type Superscript = { sup: string }; +export type Subscript = { sub: string }; +export type Mention = { ship: Ship }; +// TODO! export type Da = {date: number} +export type Codespan = { codespan: string }; +export type LinkInline = { link: { href: string; show: string } }; +export type Break = { break: null }; + +export type ExternalContent = { + json: { + origin: ExternalApp; + content: string; + }; +}; +export type ExternalApp = "twatter" | "insta" | "anon" | "rumors" | "nostr"; +export interface TwatterReference { + json: { + origin: "twatter"; + content: string; + }; +} +// interface CodeContent { +// code: { +// expression: string; +// output: string[][]; +// }; +// } +// Notifications +export interface Notifications { + engagement: EngagementNotification[]; + unread: Record<Ship, PID[]>; +} +export type Notification = + | EngagementNotification + | FollowNotification + | UnfollowNotification; +export type EngagementNotification = + | ReactNotification + | ReplyNotification + | QuoteNotification + | RepostNotification + | MentionNotification; +export type NotificationData = { ship: Ship; time: number }; +export interface FollowNotification { + follow: NotificationData; +} +export interface UnfollowNotification { + unfollow: NotificationData; +} +export interface ReactNotification { + react: { + pid: PID; + react: string; + } & NotificationData; +} +export interface ReplyNotification { + reply: { + ab: PID; + ad: PID; + } & NotificationData; +} +export interface QuoteNotification { + quote: { + ab: PID; + ad: PID; + } & NotificationData; +} +export interface RepostNotification { + share: { + ab: PID; + ad: PID; + } & NotificationData; +} +export interface MentionNotification { + mention: { + pid: PID; + } & NotificationData; +} +export interface UnreadDisplay { + [s: Ship]: string[]; +} + +// data fetching +export type MixFeedScry = MixFeed | { bucun: string }; + +export type Cursor = string | null; +export type FC = { + feed: FlatFeed; + start: Cursor; + end: Cursor; +}; +export type MixFeed = { + mix: { + name: string; + fc: FC; + }; +}; +export type PoastScry = { post: Poast } | Bucun | NotFollowScry; +export type Bucun = { bucun: PID }; +// TODO bucun no-node come on +export type UserFeedScry = UserScry | NotFollowScry; +export type NotFollowScry = { bugen: Ship }; +export interface UserScry { + feed: { + ship: Ship; + fc: FC; + }; +} + +export type FullNodeScry = + | { fpost: FullNode } + | { "no-node": { ship: Ship; id: ID } }; + +// Facts +export type PostFact = { + post: ThreadFact | GossipFact; +}; +export type ThreadFact = { thread: FullNode }; +export type GossipFact = { gossip: { post: FullNode; feeds: string[] } }; + +export type PullFact = PeekFact | BegFact; +export type PeekFact = { peek: any }; +export type BegFact = { beg: any }; +export type HarkFact = any; +export type ListsFact = any; + +export type TrillProfile = {}; + +export type TrillPostPermisssion = + | "everyone" + | "planets" + | "followers" + | "pals" + | "tag"; + +// Lists + +export type List = { + name: string; + symbol: string; // @tas + public: boolean; + desc: string; + members: ListEntry[]; + icon: string; + cover: string; +}; +export type ListEntry = { + service: "trill" | "twatter" | "twitter"; + username: string; +}; + +export type Lock = { + rank: { caveats: Rank[]; locked: boolean; public: boolean }; + luk: { caveats: Ship[]; locked: boolean; public: boolean }; + ship: { caveats: Ship[]; locked: boolean; public: boolean }; + tags: { caveats: string[]; locked: boolean; public: boolean }; + custom: { fn: null; public: boolean }; +}; +export type Rank = "czar" | "king" | "duke" | "earl" | "pawn"; +// Fetch return types +export type PushState = { + followers: Ship[]; + gate: { + lock: Lock; + begs: Ship[]; + postBegs: PID[]; + mute: Lock; + backlog: number; + }; +}; + +export type PullState = { + begs: Ship[]; + postBegs: PID[]; + following: Ship[]; +}; +export type TrillSearchResponse = { + search: { + query: string; + fc: FC; + }; +}; +export type ListsResponse = { + lists: List[]; +}; +export type MetaPeek = { + posts: number; + inc: Ship[]; + out: Ship[]; + lock: Lock; + ship: Ship; +}; +export type NodePeek = {}; +export type FeedPeek = { + ship: Ship; + feed: FlatFeed; +}; +export interface FollowAttempt { + ship: Ship; + timestamp: number; +} +export interface Key { + ship: Ship; + name: string; +} +// pals stuff +// TODO +// export interface SocialData { +// groups: any | null; +// clubs: any | null; +// lists: List[]; +// pals: Pals | null; +// contacts: Contacts; +// } + +export type Poll = { + host: Ship; + id: string; // atom id + expiry: number; + text: string; + options: string[]; + votes: PollVotes; + // TODO locks +}; +export type PollVotes = HiddenVotes | OpenExcVotes | OpenIncVotes; +export type HiddenVotes = { + type: "hid"; + exc: boolean; + votes: Record<number, number>; +}; +export type OpenExcVotes = { + type: "exc"; + votes: Record<Ship, VoteComment>; +}; +export type OpenIncVotes = { + type: "inc"; + votes: Record<number, Ship[]>; +}; +export type VoteComment = { option: number; comment: string }; + +export type PollPoke = + | CreatePoll + | CancelPoll + | ChangeExpiry + | VotePoke + | CancelVote + | PeekPoll; +export type PeekPoll = { peek: PID }; +export type SentPoll = { + text: string; + expiry: number; + options: string[]; + exc: boolean; + hidden: boolean; + private: boolean; + id: string; +}; +export type CreatePoll = { + propose: SentPoll; +}; +export type CancelPoll = { + cancel: ID; +}; +export type ChangeExpiry = { + "change-expiry": { + pid: PID; + expiry: number; + }; +}; +export type VotePoke = { + vote: { + pid: PID; + option: number; + comment: string; + }; +}; +export type CancelVote = { + "cancel-vote": { pid: PID; option: number }; +}; +export type PollScry = OnePoll | DonePolls | CurrentPolls | BadPoll; +export type OnePoll = { poll: Poll }; +export type DonePolls = { done: Poll[] }; +export type CurrentPolls = { cur: Poll[] }; +export type BadPoll = { ng: null }; +export type TombPoll = { tomb: null }; + +export type PollUpdate = NewPollU | DedPollU | OldPollU | PollPeekRes; +export type DedPollU = { "ded-poll": PID }; +export type OldPollU = { pid: PID } & ( + | NewVoteU + | PollExpiryChanged + | VoteCanceled + | PollPeekRes +); +export type PollPeekRes = { + "peek-res": PollPeekOK | PollPeekNG | PollPeekNF; +}; +export type PollPeekOK = { + "peek-ok": Poll; +}; +export type PollPeekNG = { "peek-ng": string }; +export type PollPeekNF = { "no-poll": null }; + +export type NewPollU = { + "new-poll": Poll; +}; +export type NewVoteU = { + type: "new-vote"; + update: { + option: number; + ship: Ship; + comment: string; + }; +}; +export type PollExpiryChanged = { + type: "expiry-changed"; + update: { + expiry: number; + }; +}; +export type VoteCanceled = { + type: "vote-canceled"; + update: { option: number; ship: Ship }; +}; diff --git a/gui/src/types/twatter.ts b/gui/src/types/twatter.ts new file mode 100644 index 0000000..9814cbf --- /dev/null +++ b/gui/src/types/twatter.ts @@ -0,0 +1,336 @@ +import type { Ship } from "./urbit"; +import type { Content as TrillContent } from "@/types/trill"; + +export interface APITweet { + core: APITweetCore; + legacy: APITweetLegacy; + rest_id?: string; // number + __typename?: string; + card?: any; + quoted_status_result?: { result: APIQuoteTweet }; +} +export interface APIQuoteTweet extends APITweet { + quotedRefResult: { result: { rest_id: string; __typename: string } }; +} +export interface APITwitterPoll { + binding_values: any[]; + card_platform: any; + name: string; + url: string; + user_refs_results: any[]; +} +export interface UserEntities { + description: { + urls: any[]; + }; + url: { + urls: URLEntity[]; + }; +} +export interface TweetEntities { + user_mentions: UserMentionEntity[]; + urls: URLEntity[]; + hashtags: HashtagEntity[]; + symbols: any[]; + media?: MediaEntity[]; +} +export interface UserMentionEntity { + id_str: string; // "144930676" + indices: [number, number]; + name: string; // "Naninizhoni" + screen_name: string; // "naninizhoni" +} +export interface HashtagEntity { + indices: [number, number]; + text: string; +} +export interface URLEntity { + url: string; + display_url: string; + expanded_url: string; + indices: [number, number]; +} +export interface MediaEntity { + display_url: string; // "pic.twitter.com/0qkz8kpFPQ" + expanded_url: string; // "https://twitter.com/ThaiNewsReports/status/1476368702924898304/photo/1" + media_url_https: string; // "https://pbs.twimg.com/media/FH0dgqeXEAEHVgI.jpg" + url: string; // "https://t.co/0qkz8kpFPQ" + features: { + large: { faces: any[] }; + medium: { faces: any[] }; + orig: { faces: any[] }; + small: { faces: any[] }; + }; + id_str: string; + indices: [number, number]; + original_info: { + height: number; + width: number; + focus_rects?: { x: number; y: number; w: number; h: number }[]; + }; + sizes: { + large: MediaSize; + medium: MediaSize; + small: MediaSize; + thumb: MediaSize; + }; + type: "photo"; //"photo" | ?? +} +export interface ExtendedEntity { + media: ExtendedMediaEntity[] | VideoEntity[]; +} +export interface ExtendedMediaEntity { + display_url: string; // "pic.twitter.com/0qkz8kpFPQ" + expanded_url: string; // "https://twitter.com/ThaiNewsReports/status/1476368702924898304/photo/1" + media_url_https: string; // "https://pbs.twimg.com/media/FH0dgqeXEAEHVgI.jpg" + url: string; // "https://t.co/0qkz8kpFPQ" + features: { + large: { faces: any[] }; + medium: { faces: any[] }; + orig: { faces: any[] }; + small: { faces: any[] }; + }; + id_str: string; + media_key: string; // "3_1476368699842039809" + indices: [number, number]; + original_info: { + height: number; + width: number; + focus_rects?: { x: number; y: number; w: number; h: number }[]; + }; + sizes: { + large: MediaSize; + medium: MediaSize; + small: MediaSize; + thumb: MediaSize; + }; + type: "photo" | "video"; // ?? + ext_media_availability: { status: string }; // "Available" + ext_media_color: { + palette: { + percentage: number; + rgb: { red: number; blue: number; green: number }; + }[]; + }; +} +export interface VideoEntity extends ExtendedMediaEntity { + original_info: { height: number; width: number }; + additional_media_info: { monetizable: boolean }; + mediaStats: { viewCount: number }; + video_info: { + aspect_ratio: [number, number]; + duration_millis: number; + variants: VideoVariant[]; + }; +} +export interface VideoVariant { + bitrate?: number; + content_type: string; // "video/mp4" "application/x-mpegURL" + url: string; // "https://video.twimg.com/ext_tw_video/1476257027378888711/pu/vid/640x360/KwFE_5vWD7hAVtu4.mp4?tag=12" +} +export interface MediaSize { + h: number; + w: number; + resize: "crop" | "fit"; +} +export interface APITweetLegacy { + conversation_id_str: string; // thread id + created_at: string; // "Wed Dec 15 14:02:32 +0000 2021" + display_text_range: [number, number]; // [0, 96] + entities: TweetEntities; + favorite_count: number; + favorited: boolean; + full_text: string; // + id_str: string; // "1471118482095943680" + is_quote_status: boolean; + lang: string; // "en" + possibly_sensitive: boolean; + possibly_sensitive_editable: boolean; + quote_count: number; + reply_count: number; + retweet_count: number; + retweeted: boolean; + source: string; // "<a href=\"https://mobile.twitter.com\" rel=\"nofollow\">Twitter Web App</a>" + user_id_str: string; // "368897808" + retweeted_status_result?: { result: APITweet }; + quoted_status_id_str?: string; + quoted_status_permalink?: { + display: string; //"twitter.com/lijukic/status…" + expanded: string; //"https://twitter.com/lijukic/status/1476284640826736640" + url: string; //"https://t.co/1yLiM97600" + }; + in_reply_to_screen_name?: string; + in_reply_to_status_id_str?: string; + in_reply_to_user_id_str?: string; + self_thread?: { id_str: string }; + extended_entities?: ExtendedEntity; +} +export interface Tweet { + index: string; // number + parent: string | null; // number + thread: string; // number + time: number; + author: TweetAuthor; + contents: TwatterToken[]; + text: string; + media: TweetMedia[]; + poll: TwatterPoll | null; + rt_by: TweetAuthor | null; + rt_time: number | null; + language: string; + quoting: Tweet | null; + replies: number; + rts: number; + likes: number; + quotes: number; +} +export type TweetMedia = TweetPic | TweetVideo; +export interface TweetPic { + url: string; //url + thumbnail?: string; //url +} +export interface TweetVideo { + url: string; //url + thumbnail: string; //url +} + +export interface TwatterPoll { + card_url: string; + api: string; + last_updated_datetime_utc: Date; + end_datetime_utc: Date; + counts_are_final: boolean; + choice1_label: string; + choice1_count: string; + choice2_label: string; + choice2_count: string; + choice3_label?: string; + choice3_count?: string; + choice4_label?: string; + choice4_count?: string; +} + +export interface TweetAuthor { + suspended?: boolean; + username: string; + name: string; + id: string; // number + created: number; // date + bio: string; + avatar: string; + avatar_big: string; + cover_img: string; + following: number; + followers: number; + location: string; + url: string; + bluecheck: boolean; + locked: boolean; + withheld_in_countries: string[]; + post_count: number; + media_count: number; + listed_count: number; + patp: Ship | null; +} +export type EntityType = + | "user_mentions" + | "hashtags" + | "urls" + | "media" + | "symbol"; +export type tokenizerData = [string, taggedContent[]]; +export type taggedContent = [string, TwatterToken]; +export type TwatterToken = TwatterContent | EmojiContent | HashtagContent; +export type TwatterContent = + | { text: string } + | { mention: string } + | { url: string } + | { hashtag: string }; +export interface EmojiContent { + emoji: string; +} +export interface HashtagContent { + hashtag: string; +} +export interface TwatterThread { + thread: TweetsWithCursor; + replyThreads: TweetsWithCursor[]; + cursor: string; +} +export interface TweetsWithCursor { + tweets: Tweet[]; + cursor: string; + cursorBottom?: string; + type?: string; +} +export interface APITweetCore { + user: APIUserProfile; +} +export interface APIUserProfile { + affiliates_highlighted_label?: any; + id?: string; // base64 + rest_id: string; // number + legacy: { + created_at: string; // "Tue Sep 06 12:23:27 +0000 2011" + default_profile: boolean; + default_profile_image: boolean; + description: string; + entities: UserEntities; + fast_followers_count: number; + favourites_count: number; + followers_count: number; + friends_count: number; + has_custom_timelines: boolean; + is_translator: boolean; + listed_count: number; + location: string; + media_count: number; + name: string; + normal_followers_count: number; + pinned_tweet_ids_str: string[]; // ['1471118482095943680'] + profile_banner_extensions: any; //{mediaColor: {…}} + profile_banner_url: string; // "https://pbs.twimg.com/profile_banners/368897808/1398230281" + profile_image_extensions: any; // {mediaColor: {…}} + profile_image_url_https: string; //"https://pbs.twimg.com/profile_images/1193225494994571264/So4axAeC_normal.jpg" + profile_interstitial_type: string; // "" + protected: boolean; + screen_name: string; + statuses_count: number; + translator_type: string; // "none" + url?: string; // "https://t.co/uaINnItg4d" + verified: boolean; + withheld_in_countries: string[]; + }; +} + +// return types of our Urbit fetcher +export type NoCokiRes = { "no-coki": null }; +export type BadRequestRes = { fail: string }; +export type TwatterSearchRes = TwatterSearchResOK | NoCokiRes | BadRequestRes; +export type TwatterUserRes = TwatterUserResOK | NoCokiRes | BadRequestRes; +export type TwatterThreadRes = TwatterThreadResOK | NoCokiRes | BadRequestRes; +export type TwatterUserResOK = { + user: { + profile: string; + feed: string; + }; +}; +export type TwatterThreadResOK = TwatterLoggedThreadRes | TwatterLurkThreadRes; +export type TwatterLurkThreadRes = { + "thread-lurk": string; +}; +export type TwatterLoggedThreadRes = { + thread: string; +}; +export type TwatterSearchResOK = { + search: { + query: string; + data: string; + } +} +export type TwatterNotification = { + type: string; + user: string; + post?: string; + text: string; +}
\ No newline at end of file diff --git a/gui/src/types/ui.ts b/gui/src/types/ui.ts new file mode 100644 index 0000000..4596236 --- /dev/null +++ b/gui/src/types/ui.ts @@ -0,0 +1,53 @@ +import type { NostrMetadata } from "./nostrill"; +import type { Poast } from "./trill"; +import type { Tweet } from "./twatter"; +import type { Ship } from "./urbit"; +export type Result<T> = { ok: T } | { error: string }; +export type AsyncRes<T> = Promise<Result<T>>; + +export type Timestamp = number; +export type UrbitTime = string; + +export interface ComposerData { + type: "quote" | "reply"; + post: SPID; +} +export type SPID = TrillPID | NostrPID | TwatterPID | RumorsPID; + +export interface TrillPID { + trill: Poast; +} +export interface NostrPID { + nostr: NostrMetadata; +} +export interface TwatterPID { + twatter: Tweet; +} +export interface RumorsPID { + rumors: Poast; +} +export interface Guanxi { + trill: Relationship; + pals: Relationship; +} +export type Relationship = "mutual" | "incoming" | "outgoing" | "none"; + +// should make a sortug type codebase + +export type BucketCreds = { + opts: { + bucket: string; + origin: string; // this is the endpoint + region: string; + }; + creds: { + credentials: { + accessKey: string; + secretKey: string; + }; + }; +}; + +export type DateStruct = { year: number; month: number; day: number }; +export type ChatQuoteParams = { p: Ship; nest: string; id: string }; +export type ReactGrouping = Array<{ react: string; ships: Ship[] }>; diff --git a/gui/src/types/urbit.ts b/gui/src/types/urbit.ts new file mode 100644 index 0000000..af9ee06 --- /dev/null +++ b/gui/src/types/urbit.ts @@ -0,0 +1,8 @@ +export type Ship = string; +export interface S3Bucket { + accessKeyId: string; + endpoint: string; + secretAccessKey: string; + bucket: string; + region: string; +}
\ No newline at end of file diff --git a/gui/src/vite-env.d.ts b/gui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/gui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// <reference types="vite/client" /> diff --git a/gui/tsconfig.app.json b/gui/tsconfig.app.json new file mode 100644 index 0000000..873ffa5 --- /dev/null +++ b/gui/tsconfig.app.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["./src/*"] }, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/gui/tsconfig.json b/gui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/gui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/gui/tsconfig.node.json b/gui/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/gui/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/gui/vite.config.ts b/gui/vite.config.ts new file mode 100644 index 0000000..682d66f --- /dev/null +++ b/gui/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { resolve } from "node:path"; +import tailwindcss from "@tailwindcss/vite"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": resolve(__dirname, "./src"), + }, + }, +}); |
