├── .firebaserc ├── .github └── workflows │ ├── deploy-firebase.yaml │ └── lint-code.yaml ├── .gitignore ├── .npmrc ├── .vscode ├── extensions.json └── settings.json ├── CITATION.cff ├── LICENSE ├── README.md ├── build-meta.bash ├── build-meta.ps1 ├── docs ├── HowTos.md ├── bootstrap-for-test.md ├── file-mapping.md ├── install-issues.md ├── project-structure.md └── sync-agent.md ├── eslint.config.mjs ├── firebase.json ├── index.html ├── package.json ├── patches └── file-system-access@1.0.4.patch ├── pnpm-lock.yaml ├── public ├── build-meta.json ├── font │ ├── KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2 │ ├── KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2 │ ├── KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2 │ ├── KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2 │ ├── KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2 │ ├── KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2 │ ├── L0x5DF4xlVMF-BfR8bXMIjhFq3-cXbKDO1w.woff2 │ ├── L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2 │ ├── roboto-mono-v23.css │ └── roboto-v30.css ├── icons │ ├── icon-192x192.png │ ├── icon-384x384.png │ └── icon-512x512.png ├── images │ ├── connected.png │ └── registered.png ├── ndn.svg ├── ndn_app.png ├── oidc-redirected.html ├── robots.txt └── swiftlatex │ ├── swiftlatexpdftex.js │ └── swiftlatexpdftex.wasm ├── src ├── App.tsx ├── Context.tsx ├── adaptors │ ├── milkdown-plugin-synced-store │ │ └── collab-service.ts │ ├── peerjs-transport.ts │ └── solid-synced-store.ts ├── backend │ ├── connection │ │ └── mod.ts │ ├── file-mapper │ │ ├── diff.ts │ │ └── index.ts │ ├── main.ts │ └── models │ │ ├── chats.ts │ │ ├── connections.ts │ │ ├── index.ts │ │ ├── profiles.ts │ │ ├── project.ts │ │ └── typed-models.ts ├── build-meta.ts ├── components │ ├── chat │ │ ├── add-channel-dialog.tsx │ │ ├── chat-state-store.tsx │ │ ├── chat.tsx │ │ ├── styles.module.scss │ │ └── toggle-visibility-dialog.tsx │ ├── common.scss │ ├── config │ │ ├── config-page.tsx │ │ ├── file-history │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── rebuild-cache │ │ │ └── index.tsx │ │ ├── update-inspect │ │ │ └── index.tsx │ │ ├── version-table │ │ │ └── index.tsx │ │ ├── workspace-state │ │ │ └── index.tsx │ │ └── yjs-state-vector │ │ │ └── index.tsx │ ├── connect │ │ ├── conn-button.tsx │ │ ├── conn-state.tsx │ │ ├── connect.tsx │ │ ├── index.ts │ │ ├── ndn-testbed-oidc.tsx │ │ ├── ndn-testbed.tsx │ │ ├── nfd-websocket.tsx │ │ ├── peer-js.tsx │ │ └── stored-conns.tsx │ ├── oauth-test │ │ └── index.tsx │ ├── root-wrapper.tsx │ ├── share-latex │ │ ├── app-tools.tsx │ │ ├── file-list.tsx │ │ ├── index.ts │ │ ├── latex-doc │ │ │ ├── index.tsx │ │ │ └── theme.ts │ │ ├── markdown-doc │ │ │ ├── index.tsx │ │ │ └── style.scss │ │ ├── new-item-modal.tsx │ │ ├── path-bread.tsx │ │ ├── rename-item.tsx │ │ ├── rich-doc │ │ │ ├── cmd-icon.tsx │ │ │ ├── color-list.tsx │ │ │ ├── icons.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.scss │ │ ├── share-latex │ │ │ ├── component.tsx │ │ │ ├── index.tsx │ │ │ └── styles.module.scss │ │ ├── simple-pdf │ │ │ └── pdf-viewer.tsx │ │ └── types.ts │ └── workspace │ │ ├── app-namespace.tsx │ │ ├── boot-safebag.tsx │ │ ├── convert-testbed.tsx │ │ ├── hackathon.tsx.bak │ │ ├── index.ts │ │ ├── own-certificate.tsx │ │ ├── profiles.tsx │ │ ├── qr-gen.tsx │ │ ├── qr-read.tsx │ │ └── workspace.tsx ├── constants.ts ├── index.tsx ├── material-web.d.ts ├── testbed.ts ├── utils │ ├── index.ts │ ├── opfs-ponyfill.ts │ └── solid-assist.ts ├── vendor │ └── swiftlatex │ │ └── PdfTeXEngine.ts ├── vite-env.d.ts └── workers │ └── sw.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "ndn-workspace" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/workflows/deploy-firebase.yaml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Deploy to Firebase Hosting on merge 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | # Pattern matched against refs/tags 8 | tags: 9 | - 'v*' # Push events to every tag not containing / 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # Sets the GITHUB_TOKEN permissions to allow deployment to GitHub Pages 15 | permissions: 16 | contents: read 17 | pages: write 18 | id-token: write 19 | packages: read 20 | 21 | # Allow one concurrent deployment 22 | concurrency: 23 | group: "pages" 24 | cancel-in-progress: true 25 | 26 | jobs: 27 | # Single deploy job since we're just deploying 28 | deploy: 29 | name: Deploy to Firebase 30 | runs-on: ubuntu-latest 31 | if: github.repository == 'UCLA-IRL/ndn-workspace-solid' 32 | environment: 33 | name: firebase 34 | url: https://ndn-workspace.web.app/ 35 | steps: 36 | - name: Checkout 37 | uses: actions/checkout@v4 38 | - name: Set up pnpm 39 | uses: pnpm/action-setup@v4 40 | with: 41 | version: 10 42 | run_install: | 43 | - args: [--no-frozen-lockfile] 44 | - name: Build 45 | run: pnpm build 46 | - uses: FirebaseExtended/action-hosting-deploy@v0 47 | with: 48 | repoToken: "${{ secrets.GITHUB_TOKEN }}" 49 | firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_NDN_WORKSPACE }}" 50 | channelId: live 51 | projectId: ndn-workspace 52 | -------------------------------------------------------------------------------- /.github/workflows/lint-code.yaml: -------------------------------------------------------------------------------- 1 | # Simple workflow for deploying static content to GitHub Pages 2 | name: Lint and Build 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | pull_request_target: 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Down scope as necessary via https://docs.github.com/en/actions/security-guides/automatic-token-authentication#modifying-the-permissions-for-the-github_token 13 | permissions: 14 | checks: write 15 | contents: read 16 | packages: read 17 | 18 | jobs: 19 | run-linters: 20 | name: Run linters 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | - name: Set up pnpm 26 | uses: pnpm/action-setup@v4 27 | with: 28 | version: 10 29 | run_install: | 30 | - args: [--no-frozen-lockfile] 31 | - name: Run linters 32 | uses: zjkmxy/lint-action@v2.3.3 33 | with: 34 | # Enable your linters here 35 | eslint: true 36 | eslint_args: "--max-warnings 10" 37 | - name: Build 38 | run: pnpm build 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | dev-dist 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # Firebase 28 | .firebase/ 29 | 30 | # TEMP 31 | temp/ 32 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @jsr:registry=https://npm.jsr.io 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "githubPullRequests.remotes": [ 3 | "origin", 4 | "ndn" 5 | ], 6 | "editor.formatOnSave": false, 7 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 8 | "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, 9 | "eslint.format.enable": true, 10 | "eslint.lintTask.enable": true, 11 | } -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | message: "If you use this software, please cite it as below." 3 | authors: 4 | - family-names: "Yu" 5 | given-names: "Tianyuan" 6 | orcid: "https://orcid.org/0000-0002-6722-1938" 7 | - family-names: "Ma" 8 | given-names: "Xinyu" 9 | orcid: "https://orcid.org/0000-0002-7575-1058" 10 | - family-names: "Patil" 11 | given-names: "Varun" 12 | orcid: "https://orcid.org/0000-0002-5404-0070" 13 | - family-names: "Kocaoğullar" 14 | given-names: "Yekta" 15 | orcid: "https://orcid.org/0000-0002-6929-0734" 16 | - family-names: "Zhang" 17 | given-names: "Lixia" 18 | orcid: "https://orcid.org/0000-0003-0701-757X" 19 | title: "NDN Workspace" 20 | version: 1.3.7 21 | doi: 10.1109/MetaCom62920.2024.00027 22 | date-released: 2023-11-15 23 | url: "https://github.com/UCLA-IRL/ndn-workspace-solid" 24 | preferred-citation: 25 | type: conference-paper 26 | authors: 27 | - family-names: "Yu" 28 | given-names: "Tianyuan" 29 | orcid: "https://orcid.org/0000-0002-6722-1938" 30 | - family-names: "Ma" 31 | given-names: "Xinyu" 32 | orcid: "https://orcid.org/0000-0002-7575-1058" 33 | - family-names: "Patil" 34 | given-names: "Varun" 35 | orcid: "https://orcid.org/0000-0002-5404-0070" 36 | - family-names: "Kocaoğullar" 37 | given-names: "Yekta" 38 | orcid: "https://orcid.org/0000-0002-6929-0734" 39 | - family-names: "Zhang" 40 | given-names: "Lixia" 41 | orcid: "https://orcid.org/0000-0003-0701-757X" 42 | doi: "10.1109/MetaCom62920.2024.00027" 43 | conference: 44 | name: "2024 IEEE International Conference on Metaverse Computing, Networking, and Applications (MetaCom)" 45 | month: 8 46 | start: 89 # First page number 47 | end: 96 # Last page number 48 | title: "Exploring the Design of Collaborative Applications via the Lens of NDN Workspace" 49 | year: 2024 50 | publisher: "IEEE" 51 | url: "https://doi.org/10.1109/MetaCom62920.2024.00027" 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2023-2023, Xinyu Ma 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NDN Workspace 2 | 3 | A local-first shared workspace demo application using NDNts and Yjs. 4 | 5 | Published at https://ndn-workspace.web.app/ 6 | 7 | ## Usage 8 | 9 | Please refer to `docs/HowTos.md` for instructions for local development. 10 | -------------------------------------------------------------------------------- /build-meta.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | revision="$(git describe --tags --abbrev=0) $(git rev-parse --short HEAD)" 3 | timestamp=$(date +%s) 4 | echo '{"revision":"'${revision}'","timestamp":'${timestamp}'}' > ./public/build-meta.json 5 | echo -e 'export const REVISION = '"'"${revision}"'" \ 6 | '\n// eslint-disable-next-line @typescript-eslint/no-loss-of-precision, prettier/prettier' \ 7 | '\nexport const TIMESTAMP = '${timestamp} > ./src/build-meta.ts 8 | if [ "$(uname -s)" = "Darwin" ]; then 9 | sed -E -i '' -r 's/([^ \t]+)[ \t]+$/\1/' ./src/build-meta.ts 10 | else 11 | sed -i -r 's/([^ \t]+)[ \t]+$/\1/' ./src/build-meta.ts 12 | fi 13 | -------------------------------------------------------------------------------- /build-meta.ps1: -------------------------------------------------------------------------------- 1 | $revision = "$(git describe --tags --abbrev=0) $(git rev-parse --short HEAD)" 2 | $timestamp = Get-Date -UFormat %s 3 | "{`"revision`":`"$revision`",`"timestamp`":$timestamp}" | Out-File -FilePath ./public/build-meta.json 4 | @" 5 | export const REVISION = '$revision' 6 | // eslint-disable-next-line @typescript-eslint/no-loss-of-precision, prettier/prettier 7 | export const TIMESTAMP = $timestamp 8 | "@ | Out-File -FilePath ./src/build-meta.ts 9 | -------------------------------------------------------------------------------- /docs/HowTos.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | You need [pnpm](https://pnpm.io/installation) to build this project. 4 | 5 | - Install dependencies: `pnpm install` 6 | - Development test with hot module reload: `pnpm dev` and then open `http://localhost:5173/` 7 | - Start a PeerJS server on localhost (not necessary for test): `pnpm run peer-server` 8 | - Preview test: `pnpm build`, `pnpm preview` and then `http://localhost:4173/` 9 | 10 | Please see `bootstrap-for-tes.md` for certificates used for local test. 11 | Please see `install-issues.md` for some solutions to posssible installation issues. 12 | 13 | # Conflict of namespace 14 | 15 | - Make sure exactly one node clicks the `CREATE` button. 16 | - Make sure a safe bag is only used once. 17 | If you reuse the safebag, even after the original tab is closed, the new node will be out of sync. 18 | - Testbed nodes require specific configuration. 19 | 20 | # Choose of browser 21 | 22 | Chrome and Edge are preferred over Firefox. I'm using Edge 118, but Chrome 118+ should also work. 23 | The application can run on Firefox 118+, but the Solid dev tools and the PWA are not supported by Firefox. 24 | Also, it is not guaranteed the File System API works the same as Chromium based browsers. 25 | Safari 17+ is theoretically supported but not tested. 26 | 27 | # Set up debug tools 28 | 29 | You can use [solid-devtools](https://github.com/thetarnav/solid-devtools) to examine signals. 30 | Install the Chrome extension, and uncomment the following code: 31 | 32 | https://github.com/zjkmxy/ndn-workspace-solid/blob/dbb3c470b1fdc62c2a52a3cc78889a64686ebdd8/vite.config.ts#L4 33 | https://github.com/zjkmxy/ndn-workspace-solid/blob/dbb3c470b1fdc62c2a52a3cc78889a64686ebdd8/vite.config.ts#L11-L13 34 | https://github.com/zjkmxy/ndn-workspace-solid/blob/dbb3c470b1fdc62c2a52a3cc78889a64686ebdd8/src/index.tsx#L4 35 | 36 | Then, you can see a `Solid` panel in the F12 development tools, where components and signals can be examined. 37 | 38 | Note: enabling devtools will add to loading latency by about 1 second. 39 | 40 | # Check OPFS 41 | 42 | [Origin Private File System](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system) 43 | is a persistent virtual file system simulated by the browser. 44 | Install the [OPFS Explorer](https://chrome.google.com/webstore/detail/opfs-explorer/acndjpgkpaclldomagafnognkcgjignd) 45 | extension and then you can see a panel called `OPFS Explorer` in the F12 development tools. 46 | You can download the OPFS files there and delete them one by one. 47 | To clear all files at once, you may try to open the `Application` panel and click the `Click site data`. 48 | However, files are not guaranteed to be removed. 49 | 50 | # Test SW 51 | 52 | [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) are the backend of PWAs. 53 | However, due to performance and cache invalidation reason, they are on for development test. 54 | Most of the time it just works automagically. 55 | If you want to explicitly test it, run a preview build by `pnpm build` followed by `pnpm preview` and then open 56 | the `4173` port. 57 | 58 | To enable this, set the following to be `true`: 59 | 60 | https://github.com/zjkmxy/ndn-workspace-solid/blob/dbb3c470b1fdc62c2a52a3cc78889a64686ebdd8/vite.config.ts#L19 61 | 62 | In the current configuration, SW may offer out-of-dated pages sometimes. 63 | This is because this application is a Single Page App (SPA), 64 | where almost all requests are satisfied only by JS (in-browser router) and the server cannot answer those requests. 65 | 66 | SA: https://tomwilderspin.medium.com/updating-progressive-web-applications-with-service-workers-ffca192ec16b 67 | 68 | # Solid is not React 69 | 70 | Solid JS is a vary new frontend framework so that there are only very limited learning resources. 71 | In the case you cannot find the tutorials or documents you are looking for, you may use the corresponding React versions. 72 | Most of the time they can be easily translated into Solid. 73 | Just be aware of the following differences: 74 | 75 | - Solid uses signal `state()` instead of (dirty) value `state`. 76 | - Solid only support function components. 77 | - Solid components are rendered (as a whole) only once, 78 | so you cannot put anything reacttive in the top level of the component function. 79 | The compiler will give you a warning if you try to do so. 80 | Most of the time you can simply create a new signal for the thing you want to compute. 81 | `createEffect` is also good. 82 | - Since Solid gets rid of frequent rerendering, `createMemo` is not as useful as React. 83 | You can simply translate everything (`useMemo`/`useCallback`/...) into `createEffect`. 84 | - You are supposed to use `` and ``/`` for loops and branches in the JSX part. 85 | The compiler will warn you if you keep a React-style `array.forEach` in that part. 86 | -------------------------------------------------------------------------------- /docs/bootstrap-for-test.md: -------------------------------------------------------------------------------- 1 | # Bootstrapping for Testing 2 | 3 | This document describes the `ndnsec` commands to bootstrap a workspace for test using without NDN Cert. 4 | If you do not want to generate your own certificates for test, skip steps (1)-(3). 5 | You can directly use the result provided in the document to execute step (4). 6 | 7 | ## (1) Setup trust anchor 8 | 9 | ```text 10 | $ ndnsec key-gen /ndn-workspace/test 11 | 12 | Bv0BPgc0CA1uZG4td29ya3NwYWNlCAR0ZXN0CANLRVkICBRdAtiCAmT9CARzZWxm 13 | NggAAAGTI30x/BQJGAECGQQANu6AFVswWTATBgcqhkjOPQIBBggqhkjOPQMBBwNC 14 | AATaieiDsFxdLSi/WSDI6/ip0jpZJ0xwMAPo/WotQCETa2oBle2coLwsheuT7NP+ 15 | Oc9dwIJ8F4e7eBEV3Rz55XGGFlUbAQMcJgckCA1uZG4td29ya3NwYWNlCAR0ZXN0 16 | CANLRVkICBRdAtiCAmT9/QD9Jv0A/g8yMDI0MTExM1QwMzA3MDf9AP8PMjA0NDEx 17 | MDhUMDMwNzA3F0cwRQIhAOMnC+I3Mgo0kPgEv2LjDG0PEoiJbGlfuUAKhxQox/qO 18 | AiA+9Fyba7u6k2lwsDBEhCJSs5a+rVUqhO9uD65Veh5LwA== 19 | 20 | $ ndnsec list -vv 21 | pib-sqlite3: 22 | └─* /ndn-workspace/test 23 | └─* /ndn-workspace/test/KEY/%14%5D%02%D8%82%02d%FD 24 | └─* /ndn-workspace/test/KEY/%14%5D%02%D8%82%02d%FD/self/v=1731467227644 25 | 26 | $ ndnsec cert-dump /ndn-workspace/test/KEY/%14%5D%02%D8%82%02d%FD/self/v=1731467227644 27 | 28 | ``` 29 | 30 | ## (2) Create certificate for node 1 31 | 32 | Note that each node can only be used in one tab for this implementation. 33 | 34 | ```text 35 | $ ndnsec key-gen /ndn-workspace/test/node-1 36 | 37 | 38 | $ ndnsec sign-req /ndn-workspace/test/node-1 | ndnsec cert-gen -s /ndn-workspace/test -i root 39 | 40 | 41 | 42 | $ ndnsec cert-install - 43 | 44 | 45 | | ndnsec cert-install - > 46 | OK: certificate with name [/ndn-workspace/test/node-1/KEY/%9D%DA%9BA%DA%BC5%C9/root/v=1731467428502] has been successfully installed 47 | 48 | $ ndnsec export -c /ndn-workspace/test/node-1/KEY/%9D%DA%9BA%DA%BC5%C9/root/v=1731467428502 49 | Passphrase for the private key: 123456 50 | Confirm: 123456 51 | gP0CSgb9AVUHPAgNbmRuLXdvcmtzcGFjZQgEdGVzdAgGbm9kZS0xCANLRVkICJ3a 52 | m0HavDXJCARyb290NggAAAGTI4BClhQJGAECGQQANu6AFVswWTATBgcqhkjOPQIB 53 | BggqhkjOPQMBBwNCAASJDFOuIbq1SJrpMzKK+rSO/jGplsaMt/s5TSd8qEBGnUkC 54 | +Wbh/Dw63QGMCrXTJZMFIdFjzH7If5R3grwbaHdXFmUbAQMcNgc0CA1uZG4td29y 55 | a3NwYWNlCAR0ZXN0CANLRVkICBRdAtiCAmT9CARzZWxmNggAAAGTI30x/P0A/Sb9 56 | AP4PMjAyNDExMTNUMDMxMDI5/QD/DzIwMjUxMTEzVDAzMTAyOBdGMEQCIHzVpQKy 57 | 6uYsxxkjTsJv2NVhAItSiPovWgBNttaG8LeiAiAbaTgd1SOSpKEnKcGaLoSkuvRv 58 | +dbTN/RALdaNTL905YHvMIHsMFcGCSqGSIb3DQEFDTBKMCkGCSqGSIb3DQEFDDAc 59 | BAjblNRsPNnzBAICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEECsIO5ke 60 | EJNouYU54Z3Z5EQEgZAEpBKYsic9p+it9v53MPG/ivoNltv+DjGbQC+ilmaTd8ZH 61 | K73XSk4FNxNCyTzbDOhEBKsEn8CFxc+r2gQvwsLX4wwfTnbARhInq9aGqvUoA82S 62 | c/LV6+sNTVADsGKPY1bIV3w3wRC2Rd3TyT04azalkLkhGhNoDyb1mi3ySP9fUlYI 63 | s6euH1O+UczI98EVsDY= 64 | ``` 65 | 66 | ## (3) Create certificate for node 2 67 | 68 | ```text 69 | $ ndnsec key-gen /ndn-workspace/test/node-2 70 | 71 | 72 | $ ndnsec sign-req /ndn-workspace/test/node-2 | ndnsec cert-gen -s /ndn-workspace/test -i root 73 | 74 | 75 | 76 | $ ndnsec cert-install - 77 | 78 | 79 | | ndnsec cert-install - > 80 | OK: certificate with name [/ndn-workspace/test/node-2/KEY/%9C%01%EC%0E%0A%DD%9C%B2/root/v=1731468527960] has been successfully installed 81 | 82 | $ ndnsec export -c /ndn-workspace/test/node-2/KEY/%9C%01%EC%0E%0A%DD%9C%B2/root/v=1731468527960 83 | Passphrase for the private key: 123456 84 | Confirm: 123456 85 | gP0CTAb9AVcHPAgNbmRuLXdvcmtzcGFjZQgEdGVzdAgGbm9kZS0yCANLRVkICJwB 86 | 7A4K3ZyyCARyb290NggAAAGTI5EJWBQJGAECGQQANu6AFVswWTATBgcqhkjOPQIB 87 | BggqhkjOPQMBBwNCAAQYw9dUbB3GtlzstrFcbEPrJ9NoFz1ijCQPBkUWJpjQ7gql 88 | kCK8TjYZRjMcA7iLN+xJW7zeevgHW9Sq84TV1GnsFmUbAQMcNgc0CA1uZG4td29y 89 | a3NwYWNlCAR0ZXN0CANLRVkICBRdAtiCAmT9CARzZWxmNggAAAGTI30x/P0A/Sb9 90 | AP4PMjAyNDExMTNUMDMyODQ4/QD/DzIwMjUxMTEzVDAzMjg0NxdIMEYCIQDjA3XS 91 | lVqIlOewCmHCXcKQ8lKBhqGsAroAn/cMIfKXSwIhAN5RuiW/jogJkQiwwl4P6uNr 92 | 6k1mUnSldNitkRpIvtKGge8wgewwVwYJKoZIhvcNAQUNMEowKQYJKoZIhvcNAQUM 93 | MBwECKf8glOZszNtAgIIADAMBggqhkiG9w0CCQUAMB0GCWCGSAFlAwQBKgQQDxXO 94 | bEV03vo9LJ/jn5KJygSBkEpnZPC80nVw1jTlsMFXxMl3j5CN5E4xM27JOwrQEqJB 95 | k/In7EAxH8WetK67kcybrqAlxZOOb5HFfYhiMmF3iCjrF8vguJ4HGe1+JFmYnSeV 96 | 8f8eyRiiaOOVY+cLWixaVITSjbb2rZKNnrRacT1jjsGtCubEb4fOSJa7aUO6qpQJ 97 | hf+IJPkG3kTLW9FLv2kISA== 98 | ``` 99 | 100 | ## (4) Bootstrap in the webpage 101 | 102 | - Initialize the workspace 103 | - Open one page and enter the `Workspace` tab 104 | - Paste the trust anchor 105 | - Paste the SafeBag of one node 106 | - Click `CREATE` 107 | - Setup Connection if not yet 108 | - Join the workspace 109 | - Open one page and enter the `Workspace` tab 110 | - Paste the trust anchor 111 | - Paste the SafeBag of the other node 112 | - Click `JOIN` 113 | - Setup Connection if not yet 114 | 115 | NOTE: 116 | 117 | - Make sure exactly one node clicks the `CREATE` button 118 | - Make sure safebags are not reused 119 | - The order of bootstrapping and connection setup does not matter. 120 | However, if you bootstrap first and forget to setup the connection, 121 | other pages (LaTeX and A-Frame) will be running in offline mode without telling you so. 122 | -------------------------------------------------------------------------------- /docs/file-mapping.md: -------------------------------------------------------------------------------- 1 | # File Mapping 2 | 3 | File mapping allows the user to map the workspace to a folder on the disk, 4 | and then compile / edit using a text editor. 5 | 6 | ## Step 7 | 8 | - Make sure you are using Chrome or Edge 119+. Firefox and Safari do not support required API. 9 | - On the LaTeX page, click `Map to a folder` in the menu 10 | - Select a folder. Empty folder is recommended as local changes will be discarded. 11 | - Click `yes` to allow the app to write to the selected folder. 12 | - Now the browser will start syncing the workspace and the folder. 13 | - Remote updates will be write to the disk folder upon receiption. 14 | - Local updates will be processed and propagated in about 1.5s **after you save the file**. 15 | - Note that real time collaboration is **NOT** supported in the text editor. 16 | Some content will be lost when there is a conflict. (VSCode will warn you at that time) 17 | If you cannot make sure that only you are editing one file, please go back to the browser app. 18 | - Only files existing in the workspace will be synced. 19 | If you need to create a new file, please use the browser app. 20 | - To stop syncing, navigate to any page other than `LaTeX`. 21 | 22 | ## Mechanism 23 | 24 | - Keep the handle of the directory the user selected. 25 | - Scan that folder every 1.5s. 26 | - When there is an update to a text file, use JS `diff` to capture the deltas and apply to the YDoc. 27 | - When there is an update to a blob file, wrap the local version into a segmented object and publish. 28 | - On the other hand, if the YDoc version is newer, write is back to the disk. 29 | - This function uses a mutex to avoid race conditions. 30 | - Listen to all YDoc updates. Call the callback above when there is an update. 31 | -------------------------------------------------------------------------------- /docs/install-issues.md: -------------------------------------------------------------------------------- 1 | # Errors during installation 2 | ## pnpm: not found: node 3 | - For this error, Node was not properly installed 4 | - Install Newest Node version 5 | ``` 6 | # Install nvm 7 | $ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash 8 | # Install node 9 | $ nvm install 20 10 | # Check Node version, should be > 18.12 11 | $ node -v 12 | ``` 13 | 14 | ## ERR_SOCKET_TIMEOUT 15 | - Full example error: 16 | 17 | ``` 18 | WARN  GET https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz error (ERR_SOCKET_TIMEOUT). Will retry in 10 seconds. 2 retries left. 19 | ``` 20 | 21 | - One solution is to disable IPv6 22 | ``` 23 | # 1. Open and Edit the sysctl configuration file: 24 | $ sudo nano /etc/sysctl.conf 25 | 26 | # 2. Add the following lines to the end of the file: 27 | net.ipv6.conf.all.disable_ipv6 = 1 28 | net.ipv6.conf.default.disable_ipv6 = 1 29 | net.ipv6.conf.lo.disable_ipv6 = 1 30 | # 3. Press Ctrl-x to save the file 31 | 32 | # 4. Apply the changes: 33 | $ sudo sysctl -p 34 | 35 | # 5. Try pnpm install again 36 | $ pnpm install 37 | ``` 38 | 39 | ## Unauthorized - 401 40 | - Example error message: 41 | ``` 42 | ERR_PNPM_FETCH_401  GET https://npm.pkg.github.com/download/@ucla-irl/ndnts-aux/3.0.3/0a1a5f599ca4e133e338b1a2e9c6d776360a48bf: Unauthorized - 401 43 | ``` 44 | 45 | - To solve this problem: 46 | 1. Follow this guide to create a personal access token, remember to save your password, you will use it later https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens 47 | 2. Run this command ```pnpm login --scope=@ucla-irl --auth-type=legacy --registry=https://npm.pkg.github.com``` (source: https://github.com/UCLA-IRL/ndnts-aux) 48 | - Enter your github username and the personal access token password you took note above. 49 | 3. Try pnpm install again 50 | 51 | ## Package name mismatch found 52 | - Full error: 53 | ``` 54 | WARN  Package name mismatch found while reading {"tarball":"https://ndnts-nightly.ndn.today/autoconfig.tgz"} from the store. 55 | ERR_PNPM_UNEXPECTED_PKG_CONTENT_IN_STORE  The lockfile is broken! Resolution step will be performed to fix it. 56 | WARN  Package name mismatch found while reading {"tarball":"https://ndnts-nightly.ndn.today/fw.tgz"} from the store. 57 | ERR_PNPM_UNEXPECTED_PKG_CONTENT_IN_STORE  The lockfile is broken! A full installation will be performed in an attempt to fix it. 58 | ERR_PNPM_UNEXPECTED_PKG_CONTENT_IN_STORE  Package name mismatch found while reading {"tarball":"https://ndnts-nightly.ndn.today/endpoint.tgz"} from the store. 59 | ``` 60 | - To solve this, create or open the file ~/.npmrc 61 | ``` 62 | $ sudo nano ~/.npmrc 63 | ``` 64 | - Copy and paste this line to the end of file: 65 | ```strict-store-pkg-content-check=false``` 66 | 67 | - Try pnpm install again 68 | 69 | ## Clean PNPM cache for nightly built NDNts 70 | 71 | - Use `pnpm store path` to figure out the cache path. 72 | - Delete all files starting with `@ndnts-nightly*` under that path. 73 | - Update and force install: `pnpm update`, `pnpm install --force`. 74 | - If there is still issue, remove `pnpm-lock.yaml` and `node_modules` folder and then repeat last step. 75 | -------------------------------------------------------------------------------- /docs/project-structure.md: -------------------------------------------------------------------------------- 1 | Will fill in later. 2 | -------------------------------------------------------------------------------- /docs/sync-agent.md: -------------------------------------------------------------------------------- 1 | # SyncAgent 2 | 3 | This document describes the design decision of SyncAgent, a data access layer built upon SVS 4 | 5 | ## Design Goal 6 | 7 | - Upward multiplexing by application-defined topics 8 | - Different application components can subscribe and publish messages by topics 9 | - Note: different from partial sync, this is not related to the network, but software modularization. 10 | - Downward multiplexing by methods of sync and storage 11 | - E.g., different sync prefixes, temporary and persistent storage. 12 | - Guaranteed delivery if requested 13 | - This specifically means obtaining an acknowledge from the caller component. 14 | - Reassembly and data verification 15 | 16 | Example: 17 | 18 | ```text 19 | +-----+ +--------+ +----------+ 20 | | Doc | | Folder | | Calendar | 21 | +-----+ +--------+ +----------+ 22 | \ | / (channels & topics) 23 | +---------------------------------------+ 24 | | Sync Agent | 25 | +---------------------------------------+ 26 | / | \ | 27 | +----------+ | +----------+ | 28 | | AtLeast | | | Latest | | 29 | | Delivery | | | Delivery | | 30 | +----------+ | +----------+ | 31 | / \ | / | \ | 32 | +-----+ +----------+ +-----+ +----------+ 33 | | | | Persist | | | | Temp | 34 | | SVS | | Storage | | SVS | | Storage | 35 | +-----+ +----------+ +-----+ +----------+ 36 | ``` 37 | 38 | ## Delivery 39 | 40 | A _Sync delivery_ is a SVS instance with an independent namespace. 41 | There are two types of delivery: At-Least-Once and Latest-Only. 42 | (The names come from message queues but may have different meanings) 43 | In SyncAgent, deliveries are underlying SVS pipes used by an agent. 44 | 45 | ### At Least Once 46 | 47 | At-Least-Once Delivery guarantees every message is delivered and acknowledged at least once. 48 | 49 | To justify the usage, suppose the following use case: 50 | 51 | - (1) The delivery receives an update message to a document 52 | - (2) The application parses the message and updates the document 53 | - (3) The application stores the updated document into the persistent storage 54 | 55 | Now, if the sync delivery stores its state (more specifically, SVS state vector) before (3) finishes, 56 | and the application crushes, there will be an inconsistency: 57 | when the application restarts, the loaded document does not contain the update, while the sync delivery 58 | will treat the message as delivered. 59 | 60 | To prevent this from happening, the workflow must be modified to the following 61 | 62 | - (1) The delivery receives an update message to a document 63 | - (2) The application parses the message and updates the document 64 | - (3) The application stores the updated document into the persistent storage 65 | - (4) The application gives the delivery an acknowledge 66 | - (5) The delivery stores the sync state into the persistent storage 67 | 68 | In SyncAgent, the application receives messages via async callbacks, 69 | and the acknowledge is represented by the resolution of the promise. 70 | The application is required to register the callback before SVS sync starts to operate. 71 | If there is any problem with the message, including validation failure and promise rejection of the callback, 72 | At-Least-Once Delivery will reset by storing the _previous_ SVS state into the storage and disconnecting. 73 | 74 | Note: At-Least-Once Delivery does not guarantee the order. 75 | The current implementation will only do in-order delivery when there is no restart nor reset. 76 | 77 | ### Latest Only 78 | 79 | Latest-Only Delivery only fetches and delivers the latest message only with respect to the SVS sequence number of each node. 80 | It is designed for real-time status update and close to at most once delivery in message brokers. 81 | 82 | Latest-Only Delivery only uses the persistent storage to store the state vector. 83 | Fetched data are only stored in a temporary storage. 84 | 85 | Too frequent messages will lead to missing. 86 | For example, if a node always sends `{time: Date.now()}` and `{position: Document.position}` at the same time, 87 | then the `time` message will always get lost. 88 | The application may want to combine them into one message before sending out. 89 | 90 | ## Storage 91 | 92 | SyncAgent depends on key-value storages to store both NDN packets and non-packet data, such as SVS state vectors. 93 | There are two kinds of storages: temporary storages and persistent storages. 94 | 95 | - A **temporary storage** stores data that is only used in a single execution of the application. 96 | All data are lost after it shuts down. 97 | - A **persistent storage** stores data carries data over to future executions, until data are explicitly deleted. 98 | 99 | ## Channel 100 | 101 | A **channel** is a set of API to the upper layer, representing a specific way to fetch and store data. 102 | Current implementation provides 3 channels for the share latex project 103 | 104 | - `update`: Reliable delivery designed for delta updates 105 | 106 | - Embedded in sync packets without segmentation support 107 | - Use AtLeastOnce delivery 108 | - Stored in persistent storage 109 | 110 | - `blob`: Reliable delivery designed for blob files uploaded by users 111 | 112 | - Segmented in a seperate namespace 113 | - Only object name is embedded in the sync packet 114 | - Use AtLeastOnce delivery for blob name 115 | - Stored in persistent storage 116 | 117 | - `status`: Unreliable delivery for frequent and small online status update such as Yjs awareness 118 | - Embedded in sync packets without segmentation support 119 | - Use LatestOnly delivery 120 | - Stored in temporary storage 121 | 122 | ## Namespace 123 | 124 | WIP 125 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js"; 2 | import tseslint from "typescript-eslint"; 3 | import solid from "eslint-plugin-solid/configs/recommended"; 4 | import * as tsParser from "@typescript-eslint/parser"; 5 | import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; 6 | 7 | export default [ 8 | eslint.configs.recommended, 9 | ...tseslint.configs.recommended, 10 | eslintPluginPrettierRecommended, 11 | { 12 | ignores: [ 13 | "dist/**", 14 | "public/**", 15 | "src/build-meta.ts", 16 | "dev-dist/**", 17 | "node_modules/**", 18 | "vite.config.ts", 19 | ], 20 | }, 21 | { 22 | files: ["**/*.{ts,tsx}"], 23 | ...solid, 24 | languageOptions: { 25 | parser: tsParser, 26 | parserOptions: { 27 | project: "tsconfig.json", 28 | }, 29 | }, 30 | rules: { 31 | // The following two are for debug use. Should fix before release. 32 | "@typescript-eslint/no-unused-vars": "warn", 33 | "prefer-const": "warn", 34 | // NDNts style class & namespace combination requires turning off the following 35 | "@typescript-eslint/no-namespace": "off", 36 | // Some cannot be fixed due to dependency issue 37 | "@typescript-eslint/no-explicit-any": "warn", 38 | "@typescript-eslint/ban-ts-comment": "warn", 39 | "prettier/prettier": [ 40 | "error", 41 | { 42 | endOfLine: "auto", 43 | singleQuote: true, 44 | useTabs: false, 45 | tabWidth: 2, 46 | printWidth: 120, 47 | semi: false, 48 | }, 49 | ], 50 | }, 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "dist", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/index.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | NDN Workspace 12 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ndn-workspace", 3 | "private": true, 4 | "version": "1.3.6", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "./build-meta.bash && tsc && vite build", 9 | "build-win": "pwsh ./build-meta.ps1 && tsc && vite build", 10 | "lint": "eslint . --max-warnings 0", 11 | "preview": "vite preview", 12 | "peer-server": "peerjs --port 8000 --key peerjs --path /aincraft --allow_discovery true", 13 | "upload": "firebase deploy --only hosting", 14 | "format": "eslint . --fix --max-warnings 0 ." 15 | }, 16 | "dependencies": { 17 | "@codemirror/language": "^6.10.8", 18 | "@codemirror/legacy-modes": "^6.4.3", 19 | "@codemirror/state": "^6.5.2", 20 | "@codemirror/view": "^6.36.3", 21 | "@material/web": "^2.2.0", 22 | "@milkdown/core": "^7.6.3", 23 | "@milkdown/ctx": "^7.6.3", 24 | "@milkdown/exception": "^7.6.3", 25 | "@milkdown/plugin-clipboard": "^7.6.3", 26 | "@milkdown/plugin-collab": "^7.6.3", 27 | "@milkdown/plugin-cursor": "^7.6.3", 28 | "@milkdown/plugin-history": "^7.6.3", 29 | "@milkdown/plugin-indent": "^7.6.3", 30 | "@milkdown/preset-commonmark": "^7.6.3", 31 | "@milkdown/prose": "^7.6.3", 32 | "@milkdown/theme-nord": "^7.6.3", 33 | "@milkdown/transformer": "^7.6.3", 34 | "@ndn/autoconfig": "^0.0.20250122", 35 | "@ndn/endpoint": "^0.0.20250122", 36 | "@ndn/fw": "^0.0.20250122", 37 | "@ndn/keychain": "^0.0.20250122", 38 | "@ndn/l3face": "^0.0.20250122", 39 | "@ndn/naming-convention2": "^0.0.20250122", 40 | "@ndn/ndncert": "^0.0.20250122", 41 | "@ndn/ndnsec": "^0.0.20250122", 42 | "@ndn/nfdmgmt": "^0.0.20250122", 43 | "@ndn/node-transport": "^0.0.20250122", 44 | "@ndn/packet": "^0.0.20250122", 45 | "@ndn/rdr": "^0.0.20250122", 46 | "@ndn/segmented-object": "^0.0.20250122", 47 | "@ndn/svs": "^0.0.20250122", 48 | "@ndn/sync-api": "^0.0.20250122", 49 | "@ndn/tlv": "^0.0.20250122", 50 | "@ndn/util": "^0.0.20250122", 51 | "@ndn/web-bluetooth-transport": "^0.0.20250122", 52 | "@ndn/ws-transport": "^0.0.20250122", 53 | "@reactivedata/reactive": "^0.2.2", 54 | "@solid-primitives/script-loader": "^2.3.0", 55 | "@solidjs/router": "^0.15.3", 56 | "@suid/icons-material": "^0.8.1", 57 | "@suid/material": "^0.18.0", 58 | "@syncedstore/core": "^0.6.0", 59 | "@tailwindcss/postcss": "^4.0.9", 60 | "@tailwindcss/typography": "^0.5.16", 61 | "@tailwindcss/vite": "^4.0.9", 62 | "@tiptap/core": "^2.11.5", 63 | "@tiptap/extension-collaboration": "^2.11.5", 64 | "@tiptap/extension-collaboration-cursor": "^2.11.5", 65 | "@tiptap/extension-color": "^2.11.5", 66 | "@tiptap/extension-highlight": "^2.11.5", 67 | "@tiptap/extension-link": "^2.11.5", 68 | "@tiptap/extension-text-style": "^2.11.5", 69 | "@tiptap/pm": "^2.11.5", 70 | "@tiptap/starter-kit": "^2.11.5", 71 | "@ucla-irl/ndnts-aux": "npm:@jsr/ucla-irl__ndnts-aux@^4.1.0", 72 | "autoprefixer": "^10.4.20", 73 | "codemirror": "^6.0.1", 74 | "diff": "^7.0.0", 75 | "event-iterator": "^2.0.0", 76 | "eventemitter3": "^5.0.1", 77 | "file-system-access": "^1.0.4", 78 | "jose": "^5.10.0", 79 | "jszip": "^3.10.1", 80 | "peerjs": "^1.5.4", 81 | "postcss": "^8.5.3", 82 | "qr-scanner": "^1.4.2", 83 | "qrcode": "^1.5.4", 84 | "remark-gfm": "^4.0.1", 85 | "solid-codemirror": "^2.3.1", 86 | "solid-js": "^1.9.5", 87 | "solid-markdown": "^2.0.14", 88 | "solid-tiptap": "^0.7.0", 89 | "solid-toast": "^0.5.0", 90 | "tailwindcss": "^4.0.9", 91 | "uuid": "^11.1.0", 92 | "y-codemirror.next": "^0.3.5", 93 | "y-prosemirror": "^1.2.16", 94 | "y-protocols": "^1.0.6", 95 | "yjs": "^13.6.23" 96 | }, 97 | "devDependencies": { 98 | "@eslint/js": "^9.21.0", 99 | "@suid/vite-plugin": "^0.3.1", 100 | "@types/diff": "^7.0.1", 101 | "@types/qrcode": "^1.5.5", 102 | "@types/uuid": "^10.0.0", 103 | "@types/web-bluetooth": "^0.0.20", 104 | "@types/wicg-file-system-access": "^2023.10.5", 105 | "@typescript-eslint/parser": "^8.25.0", 106 | "eslint": "9.20.1", 107 | "eslint-config-prettier": "^10.0.2", 108 | "eslint-plugin-prettier": "^5.2.3", 109 | "eslint-plugin-solid": "^0.14.5", 110 | "peer": "^1.0.2", 111 | "prettier": "^3.5.2", 112 | "sass": "^1.85.1", 113 | "solid-devtools": "^0.33.0", 114 | "typescript": "^5.7.3", 115 | "typescript-eslint": "^8.25.0", 116 | "typescript-event-target": "^1.1.1", 117 | "vite": "^6.2.0", 118 | "vite-plugin-pwa": "^0.21.1", 119 | "vite-plugin-solid": "^2.11.4", 120 | "wait-your-turn": "^1.0.1", 121 | "workbox-cacheable-response": "^7.3.0", 122 | "workbox-core": "^7.3.0", 123 | "workbox-expiration": "^7.3.0", 124 | "workbox-navigation-preload": "^7.3.0", 125 | "workbox-precaching": "^7.3.0", 126 | "workbox-routing": "^7.3.0", 127 | "workbox-strategies": "^7.3.0" 128 | }, 129 | "pnpm": { 130 | "patchedDependencies": { 131 | "file-system-access@1.0.4": "patches/file-system-access@1.0.4.patch" 132 | }, 133 | "onlyBuiltDependencies": [ 134 | "@parcel/watcher", 135 | "bufferutil", 136 | "esbuild" 137 | ] 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /patches/file-system-access@1.0.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/lib/FileSystemFileHandle.js b/lib/FileSystemFileHandle.js 2 | index fc8de6559d0bbbcd815d3406fd07c8821836d5b8..f27095915602f72f20bf725a03fa793ba55d5723 100644 3 | --- a/lib/FileSystemFileHandle.js 4 | +++ b/lib/FileSystemFileHandle.js 5 | @@ -1,4 +1,5 @@ 6 | import { FileSystemHandle } from './FileSystemHandle.js'; 7 | +import { FileSystemWritableFileStream } from './FileSystemWritableFileStream.js'; 8 | const kAdapter = Symbol('adapter'); 9 | export class FileSystemFileHandle extends FileSystemHandle { 10 | constructor(adapter) { 11 | @@ -7,7 +8,7 @@ export class FileSystemFileHandle extends FileSystemHandle { 12 | this[kAdapter] = adapter; 13 | } 14 | async createWritable(options = {}) { 15 | - const { FileSystemWritableFileStream } = await import('./FileSystemWritableFileStream.js'); 16 | + // const { FileSystemWritableFileStream } = await import('./FileSystemWritableFileStream.js'); 17 | return new FileSystemWritableFileStream(await this[kAdapter].createWritable(options)); 18 | } 19 | async getFile() { 20 | -------------------------------------------------------------------------------- /public/build-meta.json: -------------------------------------------------------------------------------- 1 | {"revision":"v1.3.8 f4b57ab","timestamp":1740601888} 2 | -------------------------------------------------------------------------------- /public/font/KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2 -------------------------------------------------------------------------------- /public/font/KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /public/font/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2 -------------------------------------------------------------------------------- /public/font/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /public/font/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2 -------------------------------------------------------------------------------- /public/font/KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /public/font/L0x5DF4xlVMF-BfR8bXMIjhFq3-cXbKDO1w.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/L0x5DF4xlVMF-BfR8bXMIjhFq3-cXbKDO1w.woff2 -------------------------------------------------------------------------------- /public/font/L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/font/L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2 -------------------------------------------------------------------------------- /public/font/roboto-mono-v23.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Roboto Mono'; 4 | font-style: normal; 5 | font-weight: 300; 6 | font-display: swap; 7 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhGq3-cXbKDO1w.woff2) format('woff2'); 8 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 9 | } 10 | /* cyrillic */ 11 | @font-face { 12 | font-family: 'Roboto Mono'; 13 | font-style: normal; 14 | font-weight: 300; 15 | font-display: swap; 16 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhPq3-cXbKDO1w.woff2) format('woff2'); 17 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 18 | } 19 | /* greek */ 20 | @font-face { 21 | font-family: 'Roboto Mono'; 22 | font-style: normal; 23 | font-weight: 300; 24 | font-display: swap; 25 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhIq3-cXbKDO1w.woff2) format('woff2'); 26 | unicode-range: U+0370-03FF; 27 | } 28 | /* vietnamese */ 29 | @font-face { 30 | font-family: 'Roboto Mono'; 31 | font-style: normal; 32 | font-weight: 300; 33 | font-display: swap; 34 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhEq3-cXbKDO1w.woff2) format('woff2'); 35 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; 36 | } 37 | /* latin-ext */ 38 | @font-face { 39 | font-family: 'Roboto Mono'; 40 | font-style: normal; 41 | font-weight: 300; 42 | font-display: swap; 43 | src: url(./L0x5DF4xlVMF-BfR8bXMIjhFq3-cXbKDO1w.woff2) format('woff2'); 44 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 45 | } 46 | /* latin */ 47 | @font-face { 48 | font-family: 'Roboto Mono'; 49 | font-style: normal; 50 | font-weight: 300; 51 | font-display: swap; 52 | src: url(./L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2) format('woff2'); 53 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 54 | } 55 | /* cyrillic-ext */ 56 | @font-face { 57 | font-family: 'Roboto Mono'; 58 | font-style: normal; 59 | font-weight: 400; 60 | font-display: swap; 61 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhGq3-cXbKDO1w.woff2) format('woff2'); 62 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 63 | } 64 | /* cyrillic */ 65 | @font-face { 66 | font-family: 'Roboto Mono'; 67 | font-style: normal; 68 | font-weight: 400; 69 | font-display: swap; 70 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhPq3-cXbKDO1w.woff2) format('woff2'); 71 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 72 | } 73 | /* greek */ 74 | @font-face { 75 | font-family: 'Roboto Mono'; 76 | font-style: normal; 77 | font-weight: 400; 78 | font-display: swap; 79 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhIq3-cXbKDO1w.woff2) format('woff2'); 80 | unicode-range: U+0370-03FF; 81 | } 82 | /* vietnamese */ 83 | @font-face { 84 | font-family: 'Roboto Mono'; 85 | font-style: normal; 86 | font-weight: 400; 87 | font-display: swap; 88 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhEq3-cXbKDO1w.woff2) format('woff2'); 89 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; 90 | } 91 | /* latin-ext */ 92 | @font-face { 93 | font-family: 'Roboto Mono'; 94 | font-style: normal; 95 | font-weight: 400; 96 | font-display: swap; 97 | src: url(./L0x5DF4xlVMF-BfR8bXMIjhFq3-cXbKDO1w.woff2) format('woff2'); 98 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 99 | } 100 | /* latin */ 101 | @font-face { 102 | font-family: 'Roboto Mono'; 103 | font-style: normal; 104 | font-weight: 400; 105 | font-display: swap; 106 | src: url(./L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2) format('woff2'); 107 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 108 | } 109 | /* cyrillic-ext */ 110 | @font-face { 111 | font-family: 'Roboto Mono'; 112 | font-style: normal; 113 | font-weight: 500; 114 | font-display: swap; 115 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhGq3-cXbKDO1w.woff2) format('woff2'); 116 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 117 | } 118 | /* cyrillic */ 119 | @font-face { 120 | font-family: 'Roboto Mono'; 121 | font-style: normal; 122 | font-weight: 500; 123 | font-display: swap; 124 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhPq3-cXbKDO1w.woff2) format('woff2'); 125 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 126 | } 127 | /* greek */ 128 | @font-face { 129 | font-family: 'Roboto Mono'; 130 | font-style: normal; 131 | font-weight: 500; 132 | font-display: swap; 133 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhIq3-cXbKDO1w.woff2) format('woff2'); 134 | unicode-range: U+0370-03FF; 135 | } 136 | /* vietnamese */ 137 | @font-face { 138 | font-family: 'Roboto Mono'; 139 | font-style: normal; 140 | font-weight: 500; 141 | font-display: swap; 142 | src: url(https://fonts.gstatic.com/s/robotomono/v23/L0x5DF4xlVMF-BfR8bXMIjhEq3-cXbKDO1w.woff2) format('woff2'); 143 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; 144 | } 145 | /* latin-ext */ 146 | @font-face { 147 | font-family: 'Roboto Mono'; 148 | font-style: normal; 149 | font-weight: 500; 150 | font-display: swap; 151 | src: url(./L0x5DF4xlVMF-BfR8bXMIjhFq3-cXbKDO1w.woff2) format('woff2'); 152 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 153 | } 154 | /* latin */ 155 | @font-face { 156 | font-family: 'Roboto Mono'; 157 | font-style: normal; 158 | font-weight: 500; 159 | font-display: swap; 160 | src: url(./L0x5DF4xlVMF-BfR8bXMIjhLq3-cXbKD.woff2) format('woff2'); 161 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 162 | } -------------------------------------------------------------------------------- /public/font/roboto-v30.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Roboto'; 4 | font-style: normal; 5 | font-weight: 300; 6 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 8 | } 9 | /* cyrillic */ 10 | @font-face { 11 | font-family: 'Roboto'; 12 | font-style: normal; 13 | font-weight: 300; 14 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2) format('woff2'); 15 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 16 | } 17 | /* greek-ext */ 18 | @font-face { 19 | font-family: 'Roboto'; 20 | font-style: normal; 21 | font-weight: 300; 22 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2) format('woff2'); 23 | unicode-range: U+1F00-1FFF; 24 | } 25 | /* greek */ 26 | @font-face { 27 | font-family: 'Roboto'; 28 | font-style: normal; 29 | font-weight: 300; 30 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2) format('woff2'); 31 | unicode-range: U+0370-03FF; 32 | } 33 | /* vietnamese */ 34 | @font-face { 35 | font-family: 'Roboto'; 36 | font-style: normal; 37 | font-weight: 300; 38 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2) format('woff2'); 39 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; 40 | } 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Roboto'; 44 | font-style: normal; 45 | font-weight: 300; 46 | src: url(./KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2) format('woff2'); 47 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Roboto'; 52 | font-style: normal; 53 | font-weight: 300; 54 | src: url(./KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 56 | } 57 | /* cyrillic-ext */ 58 | @font-face { 59 | font-family: 'Roboto'; 60 | font-style: normal; 61 | font-weight: 400; 62 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format('woff2'); 63 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 64 | } 65 | /* cyrillic */ 66 | @font-face { 67 | font-family: 'Roboto'; 68 | font-style: normal; 69 | font-weight: 400; 70 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2) format('woff2'); 71 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 72 | } 73 | /* greek-ext */ 74 | @font-face { 75 | font-family: 'Roboto'; 76 | font-style: normal; 77 | font-weight: 400; 78 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu7mxKKTU1Kvnz.woff2) format('woff2'); 79 | unicode-range: U+1F00-1FFF; 80 | } 81 | /* greek */ 82 | @font-face { 83 | font-family: 'Roboto'; 84 | font-style: normal; 85 | font-weight: 400; 86 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4WxKKTU1Kvnz.woff2) format('woff2'); 87 | unicode-range: U+0370-03FF; 88 | } 89 | /* vietnamese */ 90 | @font-face { 91 | font-family: 'Roboto'; 92 | font-style: normal; 93 | font-weight: 400; 94 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu7WxKKTU1Kvnz.woff2) format('woff2'); 95 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; 96 | } 97 | /* latin-ext */ 98 | @font-face { 99 | font-family: 'Roboto'; 100 | font-style: normal; 101 | font-weight: 400; 102 | src: url(./KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2) format('woff2'); 103 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 104 | } 105 | /* latin */ 106 | @font-face { 107 | font-family: 'Roboto'; 108 | font-style: normal; 109 | font-weight: 400; 110 | src: url(./KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2) format('woff2'); 111 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 112 | } 113 | /* cyrillic-ext */ 114 | @font-face { 115 | font-family: 'Roboto'; 116 | font-style: normal; 117 | font-weight: 500; 118 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fCRc4AMP6lbBP.woff2) format('woff2'); 119 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 120 | } 121 | /* cyrillic */ 122 | @font-face { 123 | font-family: 'Roboto'; 124 | font-style: normal; 125 | font-weight: 500; 126 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fABc4AMP6lbBP.woff2) format('woff2'); 127 | unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 128 | } 129 | /* greek-ext */ 130 | @font-face { 131 | font-family: 'Roboto'; 132 | font-style: normal; 133 | font-weight: 500; 134 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fCBc4AMP6lbBP.woff2) format('woff2'); 135 | unicode-range: U+1F00-1FFF; 136 | } 137 | /* greek */ 138 | @font-face { 139 | font-family: 'Roboto'; 140 | font-style: normal; 141 | font-weight: 500; 142 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fBxc4AMP6lbBP.woff2) format('woff2'); 143 | unicode-range: U+0370-03FF; 144 | } 145 | /* vietnamese */ 146 | @font-face { 147 | font-family: 'Roboto'; 148 | font-style: normal; 149 | font-weight: 500; 150 | src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fCxc4AMP6lbBP.woff2) format('woff2'); 151 | unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB; 152 | } 153 | /* latin-ext */ 154 | @font-face { 155 | font-family: 'Roboto'; 156 | font-style: normal; 157 | font-weight: 500; 158 | src: url(./KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2) format('woff2'); 159 | unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 160 | } 161 | /* latin */ 162 | @font-face { 163 | font-family: 'Roboto'; 164 | font-style: normal; 165 | font-weight: 500; 166 | src: url(./KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2) format('woff2'); 167 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 168 | } -------------------------------------------------------------------------------- /public/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/images/connected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/images/connected.png -------------------------------------------------------------------------------- /public/images/registered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/images/registered.png -------------------------------------------------------------------------------- /public/ndn.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 27 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /public/ndn_app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/ndn_app.png -------------------------------------------------------------------------------- /public/oidc-redirected.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Redirected Page 7 | 8 | 20 | 21 | 22 | Authentication completed. This page will automatically close. 23 | 24 | 25 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /public/swiftlatex/swiftlatexpdftex.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/UCLA-IRL/ndn-workspace-solid/31d3bc3613410c2218d43f7b692aa240a3383242/public/swiftlatex/swiftlatexpdftex.wasm -------------------------------------------------------------------------------- /src/Context.tsx: -------------------------------------------------------------------------------- 1 | // The Context component connecting backend and the fronent part. 2 | // Wrap up NDN stuffs into Solid signals. 3 | import { 4 | createSignal, 5 | createContext, 6 | useContext, 7 | type ParentProps, 8 | type Accessor, 9 | createEffect, 10 | Setter, 11 | } from 'solid-js' 12 | import { RootDocStore, connections } from './backend/models' 13 | import { SyncAgent } from '@ucla-irl/ndnts-aux/sync-agent' 14 | import { NdnSvsAdaptor } from '@ucla-irl/ndnts-aux/adaptors' 15 | import * as main from './backend/main' 16 | import { type Certificate } from '@ndn/keychain' 17 | import { type Theme, type Breakpoint } from '@suid/material/styles' 18 | import type { Forwarder } from '@ndn/fw' 19 | import { loadAll } from './backend/models/connections' 20 | import { Workspace } from '@ucla-irl/ndnts-aux/workspace' 21 | 22 | type ContextType = { 23 | rootDoc: Accessor 24 | syncAgent: Accessor 25 | booted: Accessor 26 | connectionStatus: Accessor 27 | currentConnConfig: Accessor 28 | connectFuncs: { 29 | connect: (config: connections.Config) => void 30 | disconnect: () => void 31 | } 32 | bootstrapWorkspace: (opts: { 33 | trustAnchor: Certificate 34 | prvKey: Uint8Array 35 | ownCertificate: Certificate 36 | inMemory?: boolean 37 | }) => Promise 38 | stopWorkspace: () => Promise 39 | trustAnchor: () => Certificate | undefined 40 | ownCertificate: () => Certificate | undefined 41 | fileSystemSupported: Accessor 42 | theme: Accessor | undefined> 43 | setTheme: Setter | undefined> 44 | fw: Forwarder 45 | yjsProvider: Accessor 46 | } 47 | 48 | const NdnWorkspaceContext = createContext() 49 | 50 | export function NdnWorkspaceProvider(props: ParentProps>) { 51 | const [rootDocSig, setRootDocSig] = createSignal() 52 | const [workspaceSig, setWorkspaceSig] = createSignal() 53 | const [booted, setBooted] = createSignal(false) 54 | const [theme, setTheme] = createSignal>() 55 | 56 | const [connStatus, setConnStatus] = createSignal(main.connectionStatus()) 57 | const [connConfig, setConnConfig] = createSignal() 58 | const [fileSystemSupported] = createSignal(typeof window.showDirectoryPicker === 'function') 59 | 60 | createEffect(() => { 61 | main.connectionStatusSig().on('update', (newValue) => setConnStatus(newValue)) 62 | }) 63 | 64 | // Execute the connection 65 | const connect = (config: connections.Config) => { 66 | setConnConfig(config) 67 | setConnStatus('CONNECTING') 68 | main.connect(config).then(() => { 69 | const status = main.connectionStatus() 70 | setConnStatus(status) 71 | if (status !== 'CONNECTED') { 72 | setConnConfig() 73 | } 74 | }) 75 | } 76 | 77 | const disconnect = () => { 78 | setConnStatus('DISCONNECTING') 79 | setConnConfig() 80 | main.disconnect().then(() => setConnStatus(main.connectionStatus())) 81 | } 82 | 83 | const bootstrapWorkspace: ContextType['bootstrapWorkspace'] = async (opts) => { 84 | await main.bootstrapWorkspace(opts) 85 | setRootDocSig(main.rootDoc) 86 | setWorkspaceSig(main.workspace) 87 | setBooted(true) 88 | } 89 | 90 | const stopWorkspace = async () => { 91 | setWorkspaceSig(undefined) 92 | setRootDocSig(undefined) 93 | await main.stopWorkspace() 94 | setBooted(false) 95 | } 96 | 97 | const value: ContextType = { 98 | rootDoc: rootDocSig, 99 | syncAgent: () => workspaceSig()?.syncAgent, 100 | booted: booted, 101 | connectionStatus: connStatus, 102 | currentConnConfig: connConfig, 103 | connectFuncs: { connect, disconnect }, 104 | bootstrapWorkspace: bootstrapWorkspace, 105 | stopWorkspace: stopWorkspace, 106 | trustAnchor: () => { 107 | return main.trustAnchor 108 | }, 109 | ownCertificate: () => main.ownCertificate, 110 | fileSystemSupported: fileSystemSupported, 111 | theme, 112 | setTheme, 113 | fw: main.forwarder, 114 | yjsProvider: () => workspaceSig()?.yjsAdaptor, 115 | } 116 | 117 | return {props.children} 118 | } 119 | 120 | export function useNdnWorkspace() { 121 | return useContext(NdnWorkspaceContext) 122 | } 123 | 124 | export async function initTestbed() { 125 | const ctx = useNdnWorkspace() 126 | if (!ctx) throw new Error('NdnWorkspaceContext not initialized') 127 | 128 | if (ctx.connectionStatus() !== 'DISCONNECTED') { 129 | return // already connected or connecting 130 | } 131 | 132 | // Attempt to connect 133 | const configs = await loadAll() 134 | for (const config of configs) { 135 | if (config.kind !== 'testbed') continue 136 | ctx.connectFuncs.connect(config) 137 | return 138 | } 139 | // for (const config of configs) { 140 | // if (config.kind !== 'nfdWs') continue 141 | // } 142 | } 143 | -------------------------------------------------------------------------------- /src/adaptors/solid-synced-store.ts: -------------------------------------------------------------------------------- 1 | import { observeDeep } from '@syncedstore/core' 2 | import { Accessor, createEffect, createSignal, from, onCleanup } from 'solid-js' 3 | 4 | /** 5 | * Solid hook to export a subobject of the store as a signal. 6 | * Immitate the official `useSyncedStore`. 7 | * 8 | * @example 9 | * 10 | * // Store setup: 11 | * const globalStore = SyncedStore({ people: [] }) 12 | * globalStore.people.push({ name: "Alice" }) 13 | * globalStore.people.push({ name: "Bob" }) 14 | * 15 | * // In your component: 16 | * const people = createSyncedStore(globalStore.people) 17 | *
{people()![1].name}
18 | * 19 | * @param syncedObject The subobject to sync on 20 | * @returns a signal tracking the subobject 21 | */ 22 | export function createSyncedStore(syncedObject: T): Accessor<{ value: T } | undefined> { 23 | return from<{ value: T }>((set) => { 24 | if (syncedObject !== undefined) { 25 | set({ value: syncedObject }) 26 | const cancel = observeDeep(syncedObject, () => { 27 | // Shallow copy to refresh the signal 28 | set({ value: syncedObject }) 29 | }) 30 | return cancel 31 | } else { 32 | return () => {} 33 | } 34 | }) 35 | } 36 | 37 | /** 38 | * Solid hook to export a subobject of the store as a signal. 39 | * Created from another signal. 40 | * 41 | * @example 42 | * 43 | * // Store setup: 44 | * const globalStore = SyncedStore({ people: [] }) 45 | * const [storeSig, setStoreSig] = createSignal(globalStore) 46 | * globalStore.people.push({ name: "Alice" }) 47 | * globalStore.people.push({ name: "Bob" }) 48 | * 49 | * // In your component: 50 | * const peopleSig = () => storeSig()?.people // non-tracking 51 | * const people = createSyncedStore(peopleSig) // tracking 52 | *
{people()?.value[1].name}
53 | * 54 | * @param syncedObject The subobject to sync on 55 | * @returns a signal tracking the subobject 56 | */ 57 | export function createSyncedStoreSig(signal: Accessor): Accessor<{ value: T } | undefined> { 58 | const [ret, setRet] = createSignal<{ value: T }>() 59 | 60 | createEffect(() => { 61 | const value: T | undefined = signal() 62 | if (value !== undefined) { 63 | setRet({ value }) 64 | const cancel = observeDeep(value, () => { 65 | // Shallow copy to refresh the signal 66 | setRet({ value }) 67 | }) 68 | onCleanup(cancel) 69 | } else { 70 | setRet(undefined) 71 | } 72 | }) 73 | 74 | return ret 75 | } 76 | -------------------------------------------------------------------------------- /src/backend/file-mapper/diff.ts: -------------------------------------------------------------------------------- 1 | // Ref: https://github.com/motifland/yfs 2 | import * as Diff from 'diff' 3 | 4 | type YDelta = { retain: number } | { delete: number } | { insert: string } 5 | 6 | // Compute the set of Yjs delta operations (that is, `insert` and 7 | // `delete`) required to go from initialText to finalText. 8 | // Based on https://github.com/kpdecker/jsdiff. 9 | export const getDeltaOperations = (initialText: string, finalText: string): YDelta[] => { 10 | if (initialText === finalText) { 11 | return [] 12 | } 13 | 14 | const edits = Diff.diffChars(initialText, finalText) 15 | let prevOffset = 0 16 | let deltas: YDelta[] = [] 17 | 18 | for (const edit of edits) { 19 | if (edit.removed && edit.value) { 20 | deltas = [...deltas, ...[...(prevOffset > 0 ? [{ retain: prevOffset }] : []), { delete: edit.value.length }]] 21 | prevOffset = 0 22 | } else if (edit.added && edit.value) { 23 | deltas = [...deltas, ...[{ retain: prevOffset }, { insert: edit.value }]] 24 | prevOffset = edit.value.length 25 | } else { 26 | prevOffset = edit.value.length 27 | } 28 | } 29 | return deltas 30 | } 31 | -------------------------------------------------------------------------------- /src/backend/file-mapper/index.ts: -------------------------------------------------------------------------------- 1 | // Ref: https://github.com/motifland/yfs 2 | import { getDeltaOperations } from './diff' 3 | import * as project from '../models/project' 4 | import { SyncAgent } from '@ucla-irl/ndnts-aux/sync-agent' 5 | import { Name } from '@ndn/packet' 6 | import { RootDocStore } from '../models' 7 | 8 | type LastWriteCacheData = { 9 | // Last modified date, used to capture the updates from the disk file 10 | lastModified: number 11 | // Last content, used to capture the updates from remote peers 12 | content: string 13 | } 14 | 15 | export class FileMapper { 16 | private caches: { [id: string]: LastWriteCacheData } = {} 17 | private inSync = false 18 | 19 | constructor( 20 | readonly syncAgent: SyncAgent, 21 | readonly rootDoc: RootDocStore, 22 | readonly rootHandle: FileSystemDirectoryHandle, 23 | ) {} 24 | 25 | private async UpdateYDocContent(item: project.Item, fileObj: File) { 26 | // Note: modifying item has side effects. Preceed with caution. 27 | if (item.kind === 'xmldoc') { 28 | // ignore XML docs 29 | return 30 | } else if (item.kind === 'markdowndoc') { 31 | // ignore markdown docs 32 | return 33 | } else if (item.kind === 'blob') { 34 | // Update version 35 | const arrBuf = await fileObj.arrayBuffer() 36 | const buf = new Uint8Array(arrBuf) 37 | if (buf.length === 0) { 38 | return 39 | } 40 | const blobName = await this.syncAgent.publishBlob('latexBlob', buf) 41 | // In a conflict, we take local, the adaptor should propagate these deltas at this time 42 | item.blobName = blobName.toString() 43 | // No write back 44 | this.caches[item.id] = { 45 | lastModified: fileObj.lastModified, 46 | content: item.blobName, 47 | } 48 | } else if (item.kind === 'text') { 49 | const diskContent = await fileObj.text() 50 | // Do not use item.text.toString(). It may contain new updates. 51 | const deltas = getDeltaOperations(this.caches[item.id].content, diskContent) 52 | // Apply local deltas to remote. Do not update caches to trigger writting back 53 | // The adaptor should propagate these deltas at this time 54 | item.text.applyDelta(deltas) 55 | } else { 56 | console.error(`CaptureDeltaFromFile is not supposed to be called on directories: ${item.id}`) 57 | } 58 | } 59 | 60 | private async WriteBackLocalFile( 61 | item: project.Item, 62 | remoteContent: string, 63 | writeHandle: FileSystemWritableFileStream, 64 | ) { 65 | if (item.kind === 'xmldoc') { 66 | // ignore XML docs 67 | return 68 | } else if (item.kind === 'markdowndoc') { 69 | // ignore markdown docs 70 | return 71 | } else if (item.kind === 'blob') { 72 | const objName = new Name(remoteContent) 73 | const blobContent = await this.syncAgent.getBlob(objName) 74 | if (blobContent !== undefined) { 75 | await writeHandle.write(blobContent) 76 | } 77 | } else if (item.kind === 'text') { 78 | await writeHandle.write(remoteContent) 79 | } else { 80 | console.error(`CaptureDeltaFromFile is not supposed to be called on directories: ${item.id}`) 81 | } 82 | } 83 | 84 | private async SyncWithItem(item: project.Item, handle: FileSystemHandle) { 85 | if (handle.kind === 'file') { 86 | const fileHandle = handle as FileSystemFileHandle 87 | // Update version 88 | const fileObj = await fileHandle.getFile() 89 | // Init 90 | if (this.caches[item.id] === undefined) { 91 | this.caches[item.id] = { 92 | content: '', 93 | lastModified: fileObj.lastModified, 94 | } 95 | } 96 | if (fileObj.lastModified !== this.caches[item.id].lastModified) { 97 | await this.UpdateYDocContent(item, fileObj) 98 | } 99 | const remoteContent = (() => { 100 | if (item.kind === 'blob') { 101 | return item.blobName 102 | } else if (item.kind === 'text') { 103 | return item.text.toString() 104 | } else { 105 | return undefined 106 | } 107 | })() 108 | // Write back remote updates 109 | if (remoteContent !== undefined && remoteContent !== this.caches[item.id].content) { 110 | const writeHandle = await fileHandle.createWritable({ 111 | keepExistingData: false, 112 | }) 113 | await this.WriteBackLocalFile(item, remoteContent, writeHandle) 114 | await writeHandle.close() 115 | 116 | const fileObj = await fileHandle.getFile() 117 | this.caches[item.id] = { 118 | lastModified: fileObj.lastModified, 119 | content: remoteContent, 120 | } 121 | } 122 | } else if (handle.kind === 'directory') { 123 | const dirHandle = handle as FileSystemDirectoryHandle 124 | if (item.kind === 'folder') { 125 | // Recursive check 126 | for (const uid of item.items) { 127 | const subItem = this.rootDoc.latex[uid] 128 | if (subItem?.kind === 'folder') { 129 | const subHandle = await dirHandle.getDirectoryHandle(subItem.name, { 130 | create: true, 131 | }) 132 | await this.SyncWithItem(subItem, subHandle) 133 | } else if (subItem !== undefined) { 134 | const subHandle = await dirHandle.getFileHandle(subItem.name, { 135 | create: true, 136 | }) 137 | await this.SyncWithItem(subItem, subHandle) 138 | } 139 | } 140 | } 141 | } 142 | } 143 | 144 | public async SyncAll() { 145 | if (this.inSync) { 146 | return 147 | } 148 | this.inSync = true 149 | const rootItem = this.rootDoc.latex[project.RootId] 150 | if (rootItem !== undefined) { 151 | await this.SyncWithItem(rootItem, this.rootHandle) 152 | } 153 | this.inSync = false 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/backend/models/chats.ts: -------------------------------------------------------------------------------- 1 | // create message type for chatbox 2 | export type Message = { 3 | sender: string 4 | content: string 5 | timestamp: number 6 | channel: string 7 | } 8 | -------------------------------------------------------------------------------- /src/backend/models/connections.ts: -------------------------------------------------------------------------------- 1 | import { openRoot } from '../../utils' 2 | import { TypedModel } from './typed-models' 3 | 4 | export type ConfigBase = { 5 | kind: string 6 | } 7 | 8 | export type NfdWs = ConfigBase & { 9 | kind: 'nfdWs' 10 | uri: string 11 | isLocal: boolean 12 | prvKeyB64: string 13 | ownCertificateB64: string 14 | } 15 | 16 | export type PeerJs = ConfigBase & { 17 | kind: 'peerJs' 18 | 19 | /** Server host. */ 20 | host: string 21 | 22 | /** Server port number. */ 23 | port: number 24 | 25 | /** Connection key for server API calls. Defaults to `peerjs`. */ 26 | key?: string 27 | 28 | /** The path where your self-hosted PeerServer is running. Defaults to `'/'`. */ 29 | path?: string 30 | 31 | /** Optional ID of this peer provided by the user. */ 32 | peerId?: string 33 | } 34 | 35 | export type Ble = ConfigBase & { 36 | kind: 'ble' 37 | } 38 | 39 | export type Testbed = ConfigBase & { 40 | kind: 'testbed' 41 | prvKeyB64: string 42 | ownCertificateB64: string 43 | } 44 | 45 | export type Config = NfdWs | PeerJs | Ble | Testbed 46 | 47 | export function getName(conn?: Config): string { 48 | if (conn === undefined) { 49 | return '' 50 | } 51 | switch (conn.kind) { 52 | case 'nfdWs': 53 | return conn.uri 54 | case 'peerJs': 55 | return `peerjs://${conn.host}:${conn.port}${conn.path}` 56 | case 'ble': 57 | return `ble` 58 | case 'testbed': 59 | return `testbed` 60 | } 61 | } 62 | 63 | export const storageFolder = 'connections' 64 | 65 | export const connections = new TypedModel('connections', getName) 66 | 67 | export async function initDefault() { 68 | const rootHandle = await openRoot() 69 | try { 70 | await rootHandle.getDirectoryHandle(storageFolder) 71 | return 72 | } catch { 73 | connections.save({ 74 | kind: 'nfdWs', 75 | uri: 'ws://localhost:9696/', 76 | isLocal: true, 77 | prvKeyB64: '', 78 | ownCertificateB64: '', 79 | }) 80 | } 81 | } 82 | 83 | export const save = connections.save.bind(connections) 84 | export const remove = connections.remove.bind(connections) 85 | export const isExisting = connections.isExisting.bind(connections) 86 | export const loadAll = connections.loadAll.bind(connections) 87 | -------------------------------------------------------------------------------- /src/backend/models/index.ts: -------------------------------------------------------------------------------- 1 | import { syncedStore, Box } from '@syncedstore/core' 2 | import * as project from './project' 3 | import * as connections from './connections' 4 | import * as profiles from './profiles' 5 | import * as chats from './chats' 6 | import * as Y from 'yjs' 7 | 8 | export { project, connections, profiles, chats } 9 | 10 | export type RootDocType = { 11 | latex: project.Items 12 | chats: Box[] 13 | } 14 | export type RootDocStore = ReturnType> 15 | 16 | export function initRootDoc(guid: string): RootDocStore { 17 | return syncedStore( 18 | { 19 | latex: {}, 20 | chats: [], 21 | } as RootDocType, 22 | new Y.Doc({ guid }), 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/backend/models/profiles.ts: -------------------------------------------------------------------------------- 1 | import { Certificate } from '@ndn/keychain' 2 | import { base64ToBytes, bytesToBase64 } from '../../utils' 3 | import { Decoder, Encoder } from '@ndn/tlv' 4 | import { Data } from '@ndn/packet' 5 | import { TypedModel } from './typed-models' 6 | 7 | export type Profile = { 8 | workspaceName: string 9 | nodeId: string 10 | trustAnchorB64: string 11 | prvKeyB64: string 12 | ownCertificateB64: string 13 | } 14 | 15 | export const profiles = new TypedModel('profiles', (profile) => profile.nodeId) 16 | 17 | export function toBootParams(profile: Profile) { 18 | const prvKey = base64ToBytes(profile.prvKeyB64) 19 | const anchorBytes = base64ToBytes(profile.trustAnchorB64) 20 | const trustAnchor = Certificate.fromData(Decoder.decode(anchorBytes, Data)) 21 | const certBytes = base64ToBytes(profile.ownCertificateB64) 22 | const ownCertificate = Certificate.fromData(Decoder.decode(certBytes, Data)) 23 | return { 24 | trustAnchor, 25 | prvKey, 26 | ownCertificate, 27 | } 28 | } 29 | 30 | export function fromBootParams(params: { 31 | trustAnchor: Certificate 32 | prvKey: Uint8Array 33 | ownCertificate: Certificate 34 | }): Profile { 35 | const certWire = Encoder.encode(params.ownCertificate.data) 36 | const certB64 = bytesToBase64(certWire) 37 | 38 | const anchorWire = Encoder.encode(params.trustAnchor.data) 39 | const anchorB64 = bytesToBase64(anchorWire) 40 | 41 | const prvKeyB64 = bytesToBase64(params.prvKey) 42 | 43 | const nodeId = params.ownCertificate.name.getPrefix(params.ownCertificate.name.length - 4) 44 | const appPrefix = params.trustAnchor.name.getPrefix(params.trustAnchor.name.length - 4) 45 | 46 | return { 47 | workspaceName: appPrefix.toString(), 48 | nodeId: nodeId.toString(), 49 | trustAnchorB64: anchorB64, 50 | prvKeyB64: prvKeyB64, 51 | ownCertificateB64: certB64, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/backend/models/typed-models.ts: -------------------------------------------------------------------------------- 1 | import { encodeKey as encodePath, openRoot } from '../../utils' 2 | 3 | export class TypedModel { 4 | constructor( 5 | readonly storageFolder: string, 6 | readonly getName: (object: T) => string, 7 | ) {} 8 | 9 | async save(object: T) { 10 | const rootHandle = await openRoot() 11 | const connections = await rootHandle.getDirectoryHandle(this.storageFolder, { create: true }) 12 | const fileHandle = await connections.getFileHandle(encodePath(this.getName(object)), { create: true }) 13 | const textFile = await fileHandle.createWritable() 14 | await textFile.write(JSON.stringify(object)) 15 | await textFile.close() 16 | } 17 | 18 | async remove(connName: string) { 19 | const rootHandle = await openRoot() 20 | const objects = await rootHandle.getDirectoryHandle(this.storageFolder, { 21 | create: true, 22 | }) 23 | try { 24 | await objects.removeEntry(encodePath(connName), { recursive: true }) 25 | await rootHandle.removeEntry(encodePath(connName), { recursive: true }) 26 | } catch { 27 | return false 28 | } 29 | } 30 | 31 | async isExisting(connName: string) { 32 | const rootHandle = await openRoot() 33 | const objects = await rootHandle.getDirectoryHandle(this.storageFolder, { 34 | create: true, 35 | }) 36 | try { 37 | await objects.getFileHandle(encodePath(connName), { create: false }) 38 | return true 39 | } catch { 40 | return false 41 | } 42 | } 43 | 44 | async load(connName: string) { 45 | const rootHandle = await openRoot() 46 | const objects = await rootHandle.getDirectoryHandle(this.storageFolder, { 47 | create: true, 48 | }) 49 | try { 50 | const file = await objects.getFileHandle(encodePath(connName), { 51 | create: false, 52 | }) 53 | const jsonFile = await file.getFile() 54 | const jsonText = await jsonFile.text() 55 | const object = JSON.parse(jsonText) as T 56 | return object 57 | } catch { 58 | return undefined 59 | } 60 | } 61 | 62 | async loadAll() { 63 | const rootHandle = await openRoot() 64 | 65 | const objects = await rootHandle.getDirectoryHandle(this.storageFolder, { 66 | create: true, 67 | }) 68 | const ret: Array = [] 69 | for await (const [, handle] of objects.entries()) { 70 | if (handle.kind === 'file') { 71 | const jsonFile = await handle.getFile() 72 | const jsonText = await jsonFile.text() 73 | const object = JSON.parse(jsonText) as T 74 | ret.push(object) 75 | } 76 | } 77 | 78 | return ret 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/build-meta.ts: -------------------------------------------------------------------------------- 1 | export const REVISION = 'v1.3.8 f4b57ab' 2 | // eslint-disable-next-line @typescript-eslint/no-loss-of-precision, prettier/prettier 3 | export const TIMESTAMP = 1740601888 4 | -------------------------------------------------------------------------------- /src/components/chat/add-channel-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogTitle, 4 | DialogContent, 5 | DialogActions, 6 | Button, 7 | TextField, 8 | Checkbox, 9 | FormControlLabel, 10 | } from '@suid/material' 11 | import { createSignal } from 'solid-js' 12 | 13 | interface AddChannelDialogProps { 14 | open: boolean 15 | onClose: () => void 16 | onConfirm: (channelName: string) => void 17 | } 18 | 19 | export function AddChannelDialog(props: AddChannelDialogProps) { 20 | const [channelName, setChannelName] = createSignal('') 21 | const [confirmed, setConfirmed] = createSignal(false) 22 | 23 | const handleSubmit = () => { 24 | if (channelName().trim() && confirmed()) { 25 | props.onConfirm(channelName().trim()) 26 | setChannelName('') 27 | setConfirmed(false) 28 | } 29 | } 30 | 31 | const handleClose = () => { 32 | setChannelName('') 33 | setConfirmed(false) 34 | props.onClose() 35 | } 36 | 37 | return ( 38 | 39 | Add New Channel 40 | 41 |
50 | setChannelName(e.target.value)} 55 | fullWidth 56 | /> 57 | setConfirmed(checked)} />} 59 | label="I understand that this channel cannot be deleted after the first message is sent and cannot be renamed" 60 | /> 61 |
62 |
63 | 64 | 65 | 68 | 69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /src/components/chat/chat-state-store.tsx: -------------------------------------------------------------------------------- 1 | import { createSignal } from 'solid-js' 2 | 3 | export function useChatState(keyName: string, defaultValue: T) { 4 | const [chatState, setChatState] = createSignal( 5 | JSON.parse(localStorage.getItem(keyName) || JSON.stringify(defaultValue)), 6 | ) 7 | 8 | const updateChatState = (newValue: T) => { 9 | setChatState(() => newValue) 10 | localStorage.setItem(keyName, JSON.stringify(newValue)) 11 | } 12 | 13 | return { chatState, updateChatState } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/chat/styles.module.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --msg-radius-thin: 5px; 3 | 4 | --msg-pad-thin: 8px; 5 | --msg-pad-normal: 16px; 6 | 7 | --msg-border-thin: 1px; 8 | --msg-border-style: solid; 9 | 10 | --msg-font-size-regular: 10px; 11 | --msg-font-size-header: 16px; 12 | 13 | --msg-local-border-color: #46a9ff66; 14 | --msg-local-background-color: #cff0ff; 15 | --msg-foreign-border-color: black; 16 | --msg-foreign-background-color: #ebebeb 17 | } 18 | 19 | .App { 20 | border-radius: 5px; 21 | height: 100vh; 22 | overflow-y: hidden; 23 | scroll-behavior: smooth; 24 | display: flex; 25 | flex-direction: column; 26 | background: #fff; 27 | } 28 | 29 | .App_header { 30 | background: #fafafa; 31 | border-bottom: 3px solid #e0e0e0; 32 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 33 | display: flex; 34 | flex-direction: column; 35 | gap: 8px; 36 | padding: 15px 25px; 37 | margin: 0; 38 | } 39 | 40 | /* Message input section styling */ 41 | .App__input { 42 | display: flex; 43 | align-items: center; 44 | padding: 10px; 45 | background: #f9f9f9; 46 | border-top: 1px solid #ddd; 47 | } 48 | 49 | .App__input textarea { 50 | flex-grow: 1; 51 | height: 50px; 52 | background: #fafafa; 53 | border: 1px solid #ddd; 54 | border-radius: 25px; 55 | padding: 10px 20px; 56 | color: #333; 57 | font-weight: 400; 58 | resize: none; 59 | } 60 | 61 | .App__input textarea:focus { 62 | outline: none; 63 | border-color: #aaa; 64 | } 65 | 66 | /* Send Message button Styling */ 67 | .App__button { 68 | background: #223387; 69 | padding: 10px 20px; 70 | border: none; 71 | color: #fff; 72 | border-radius: 25px; 73 | font-size: var(--msg-font-size-header); 74 | margin-left: 10px; 75 | cursor: pointer; 76 | } 77 | 78 | .App__button:hover { 79 | background: #0056b3; 80 | } 81 | 82 | /* All Messages Styling */ 83 | .App__messages { 84 | flex-grow: 1; 85 | width: 100%; 86 | padding: 20px; 87 | overflow-y: auto; 88 | background: #f5f5f5; 89 | } 90 | 91 | .App__message { 92 | display: flex; 93 | flex-direction: row; 94 | align-items: start; 95 | padding: 10px; 96 | border-radius: 8px; 97 | margin-bottom: 10px; 98 | } 99 | 100 | .App__messageCode { 101 | white-space: pre-wrap; 102 | border: var(--msg-border-thin) solid #ddd; 103 | background-color: #e0e0e0; 104 | border-radius: var(--msg-border-thin); 105 | padding: var(--msg-pad-normal); 106 | } 107 | 108 | .App__message img { 109 | height: 40px; 110 | width: 40px; 111 | border-radius: 50%; 112 | margin-right: 15px; 113 | } 114 | 115 | .App__msgHeader { 116 | flex-grow: 1; 117 | color: black; 118 | font-weight: 1000; 119 | font-size: var(--msg-font-size-header); 120 | border-style: var(--msg-border-style); 121 | border-width: var(--msg-border-thin); 122 | border-top-left-radius: var(--msg-radius-thin); 123 | border-top-right-radius: var(--msg-radius-thin); 124 | padding: var(--msg-pad-thin); 125 | padding-left: var(--msg-pad-normal); 126 | padding-right: var(--msg-pad-normal); 127 | margin-bottom: 0; 128 | margin-top: 0; 129 | } 130 | 131 | .App__borderLocal { 132 | border-color: var(--msg-local-border-color); 133 | } 134 | 135 | .App__backgroundLocal { 136 | background-color: var(--msg-local-background-color); 137 | } 138 | 139 | .App__borderForeign { 140 | border-color: var(--msg-foreign-border-color); 141 | } 142 | 143 | .App__backgroundForeign { 144 | background-color: var(--msg-foreign-background-color); 145 | } 146 | 147 | .App__msgContent { 148 | flex-grow: 1; 149 | overflow-x: auto; 150 | } 151 | 152 | .App__msgContent span { 153 | color: grey; 154 | font-weight: 300; 155 | font-size: var(--msg-font-size-regular); 156 | } 157 | 158 | .App__msgContent p { 159 | margin: 2px 0; 160 | } 161 | 162 | .App_msgContentSolid { 163 | border-style: var(--msg-border-style); 164 | border-width: var(--msg-border-thin); 165 | border-bottom-left-radius: var(--msg-radius-thin); 166 | border-bottom-right-radius: var(--msg-radius-thin); 167 | border-top-width: 0; 168 | padding: var(--msg-pad-normal); 169 | margin-top: 0; 170 | background-color: inherit; 171 | color: #333; 172 | } 173 | 174 | /* Webkit browsers */ 175 | .App__messages::-webkit-scrollbar { 176 | width: 8px; 177 | background-color: transparent; 178 | } 179 | 180 | .App__messages::-webkit-scrollbar-thumb { 181 | background-color: #555; 182 | border-radius: 10px; 183 | } 184 | 185 | .App__messages::-webkit-scrollbar-thumb:hover { 186 | background-color: #777; 187 | } 188 | 189 | .ChannelButton { 190 | background-color: transparent; 191 | border-radius: 20px; 192 | color: #333; 193 | cursor: pointer; 194 | font-size: var(--msg-font-size-header); 195 | outline: 1px solid #223387; 196 | padding: 6px 16px; 197 | margin-bottom: 8px; 198 | margin-right: 8px; 199 | transition: background-color 0.3s; 200 | 201 | position: relative; 202 | display: inline-flex; 203 | align-items: center; 204 | 205 | &:hover { 206 | background-color: #f4f4f4; 207 | } 208 | } 209 | 210 | .ActiveChannelButton { 211 | background-color: #223387; 212 | border: none; 213 | border-radius: 20px; 214 | color: white; 215 | cursor: pointer; 216 | font-size: var(--msg-font-size-header); 217 | outline: 1px solid #223387; 218 | padding: 6px 16px; 219 | margin-right: 8px; 220 | margin-bottom: 8px; 221 | transition: background-color 0.3s; 222 | 223 | position: relative; 224 | display: inline-flex; 225 | align-items: center; 226 | 227 | &:hover { 228 | background-color: #0056b3; 229 | } 230 | } 231 | 232 | .AddChannelButton { 233 | background-color: #ddd; 234 | border: none; 235 | border-radius: 20px; 236 | color: black; 237 | cursor: pointer; 238 | font-size: var(--msg-font-size-header); 239 | padding: 6px 14px; 240 | margin-right: 8px; 241 | margin-bottom: 8px; 242 | transition: background-color 0.3s; 243 | 244 | &:hover { 245 | background-color: #eee; 246 | } 247 | } 248 | 249 | .ChannelHeading { 250 | border-left: 3px solid #223387; 251 | color: #333; 252 | font-size: 18px; 253 | font-weight: 600; 254 | margin: 0; 255 | padding-left: 12px; 256 | } 257 | 258 | .HideChannelButton { 259 | display: inline-flex; 260 | align-items: center; 261 | justify-content: center; 262 | width: 16px; 263 | height: 16px; 264 | border-radius: 50%; 265 | background-color: #e0e0e0; 266 | color: #555; 267 | font-size: 8px; 268 | font-weight: bold; 269 | cursor: pointer; 270 | transition: all 0.2s ease; 271 | user-select: none; 272 | line-height: 1; 273 | margin-left: 0.5rem; 274 | 275 | vertical-align: middle; 276 | margin-top: auto; 277 | margin-bottom: auto; 278 | } 279 | 280 | .HideChannelButton:hover { 281 | background-color: #f9f9f9; 282 | } 283 | 284 | .HideChannelButton:active { 285 | transform: scale(0.95); 286 | } 287 | 288 | .HideChannelButton:focus { 289 | outline: 2px solid #0096ff; 290 | outline-offset: 2px; 291 | } 292 | -------------------------------------------------------------------------------- /src/components/chat/toggle-visibility-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogTitle, 4 | DialogContent, 5 | DialogActions, 6 | Button, 7 | List, 8 | ListItem, 9 | Checkbox, 10 | FormControlLabel, 11 | } from '@suid/material' 12 | import { createSignal, createEffect, For } from 'solid-js' 13 | 14 | interface ToggleChannelVisibilityDialogProps { 15 | open: boolean 16 | channels: () => string[] // visible channels 17 | hiddenChannels: () => string[] // hidden channels 18 | setHiddenChannels: (channels: string[]) => void 19 | onClose: () => void 20 | } 21 | 22 | export function ToggleChannelVisibilityDialog(props: ToggleChannelVisibilityDialogProps) { 23 | // Track all displayed channels and their checked state 24 | const [selectedChannels, setSelectedChannels] = createSignal([]) 25 | 26 | // Initialize selected channels when dialog opens 27 | createEffect(() => { 28 | if (props.open) { 29 | // Start with currently visible channels 30 | setSelectedChannels([...props.channels()]) 31 | } 32 | }) 33 | 34 | const handleToggleChannel = (channel: string, checked: boolean) => { 35 | if (checked) { 36 | setSelectedChannels([...selectedChannels(), channel]) 37 | } else { 38 | setSelectedChannels(selectedChannels().filter((c) => c !== channel)) 39 | } 40 | } 41 | 42 | const handleSave = () => { 43 | if (selectedChannels().length === 0) { 44 | // At least one channel must be visible 45 | return 46 | } 47 | 48 | // Calculate which channels should be hidden (all channels minus selected ones) 49 | const allChannels = [...props.channels(), ...props.hiddenChannels()] 50 | const newHiddenChannels = allChannels.filter((channel) => !selectedChannels().includes(channel)) 51 | props.setHiddenChannels(newHiddenChannels) 52 | props.onClose() 53 | } 54 | 55 | const isChannelSelected = (channel: string) => { 56 | return selectedChannels().includes(channel) 57 | } 58 | 59 | // Get all channels (visible and hidden) 60 | const allChannels = () => { 61 | const combined = [...props.channels(), ...props.hiddenChannels()] 62 | return [...new Set(combined)].sort() // Remove duplicates and sort 63 | } 64 | 65 | return ( 66 | 67 | Toggle Channel Visibility 68 | 69 | 70 | 71 | {(channel) => ( 72 | 73 | handleToggleChannel(channel, checked)} 78 | /> 79 | } 80 | label={`#${channel}`} 81 | sx={{ width: '100%', margin: '4px 0' }} 82 | /> 83 | 84 | )} 85 | 86 | 87 | 88 | 89 | 90 | 93 | 94 | 95 | ) 96 | } 97 | -------------------------------------------------------------------------------- /src/components/common.scss: -------------------------------------------------------------------------------- 1 | @forward "tailwindcss"; 2 | 3 | /** Globals */ 4 | ::-webkit-scrollbar { 5 | width: 6px; 6 | height: 6px; 7 | } 8 | 9 | ::-webkit-scrollbar-thumb { 10 | background-color: #aaa; 11 | border-radius: 3px; 12 | } 13 | 14 | ::-webkit-scrollbar-track { 15 | background-color: #eee; 16 | } 17 | 18 | * { 19 | scrollbar-width: thin; 20 | } 21 | 22 | /** Utilities */ 23 | .h-full { 24 | height: 100%; 25 | } 26 | 27 | .w-half { 28 | width: 50%; 29 | } 30 | 31 | /** Patches to 3rdparty components */ 32 | .MuiSelect-root { 33 | height: 40px; 34 | } 35 | 36 | /** 37 | * This file is for global styles only. Please use CSS Modules for single-component styles: 38 | * https://github.com/css-modules/css-modules 39 | */ 40 | -------------------------------------------------------------------------------- /src/components/config/config-page.tsx: -------------------------------------------------------------------------------- 1 | // import { onMount, createSignal } from 'solid-js' 2 | import { Stack } from '@suid/material' 3 | import VersionTable from './version-table' 4 | import WorkspaceState from './workspace-state' 5 | import UpdateInspect from './update-inspect' 6 | import FileHistory from './file-history' 7 | import RebuildCache from './rebuild-cache' 8 | import YjsStateVector from './yjs-state-vector' 9 | 10 | export default function ConfigPage() { 11 | return ( 12 | <> 13 |

DEBUG PAGE

14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/config/file-history/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Grid, TextField } from '@suid/material' 2 | import { createSignal } from 'solid-js' 3 | import { useNdnWorkspace } from '../../../Context' 4 | import toast from 'solid-toast' 5 | import { getNamespace } from '@ucla-irl/ndnts-aux/sync-agent' 6 | import { SequenceNum } from '@ndn/naming-convention2' 7 | import { Decoder } from '@ndn/tlv' 8 | import { Data, Name } from '@ndn/packet' 9 | import * as Y from 'yjs' 10 | import { initRootDoc, project } from '../../../backend/models' 11 | import { getYjsDoc } from '@syncedstore/core' 12 | 13 | export default function FileHistory() { 14 | const { syncAgent, booted } = useNdnWorkspace()! 15 | const [stateVector, setStateVector] = createSignal('') 16 | const [fileUUID, setFileUUID] = createSignal('') 17 | 18 | const onObtain = async () => { 19 | const svStr = stateVector() 20 | const uuidStr = fileUUID() 21 | const agent = syncAgent()! 22 | let svJson 23 | try { 24 | svJson = JSON.parse(svStr) as Record 25 | } catch (err) { 26 | toast.error('Unable to parse state vector JSON') 27 | console.error(`Unable to parse state vector JSON: ${err}`) 28 | return 29 | } 30 | 31 | // TODO: Reuse the init code 32 | const appPrefix = agent.appPrefix 33 | const aloSyncPrefix = appPrefix.append(getNamespace().syncKeyword, getNamespace().atLeastOnceKeyword) 34 | const newDoc = initRootDoc(project.WorkspaceDocId) 35 | const yDoc = getYjsDoc(newDoc) 36 | const clientID = yDoc.clientID 37 | yDoc.clientID = 1 // Set the client Id to be a common one to make the change common 38 | newDoc.latex[project.RootId] = { 39 | id: project.RootId, 40 | name: '', 41 | parentId: undefined, 42 | kind: 'folder', 43 | items: [], 44 | deleted: false, 45 | } 46 | yDoc.clientID = clientID 47 | for (const [idStr, seq] of Object.entries(svJson)) { 48 | if (typeof idStr !== 'string' || typeof seq !== 'number') { 49 | toast.error(`Invalid State Vector: ${idStr}= ${seq}`) 50 | console.error(`Invalid State Vector: ${idStr}= ${seq}`) 51 | return 52 | } 53 | const nodeId = agent.appPrefix.append(idStr) 54 | 55 | // Replay. The same as Update Inspect 56 | for (let seqNum = 1; seqNum <= seq; seqNum++) { 57 | const pktName = getNamespace().baseName(nodeId, aloSyncPrefix).append(SequenceNum.create(seqNum)) 58 | const outerWire = await agent.persistStorage.get(pktName.toString()) 59 | if (!outerWire) { 60 | toast.error(`You missed update: ${idStr}= ${seqNum}`) 61 | console.error(`You missed update: ${idStr}= ${seqNum}`) 62 | continue 63 | } 64 | const outerData = Decoder.decode(outerWire, Data) 65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 | const inner = (await (agent as any).parseInnerData(outerData.content)) as { 67 | channel: string 68 | topic: string 69 | content: Uint8Array 70 | } 71 | 72 | if (inner.topic !== 'doc') continue 73 | let update = inner.content 74 | if (inner.channel === 'blobUpdate') { 75 | const name = Decoder.decode(inner.content, Name) 76 | const updateValue = await agent.getBlob(name) 77 | if (updateValue !== undefined) { 78 | // Notify the listener 79 | update = updateValue 80 | } else { 81 | toast.error(`You missed update: ${idStr}= ${seqNum}`) 82 | console.error(`You missed update: ${idStr}= ${seqNum}`) 83 | continue 84 | } 85 | } else if (inner.channel !== 'update') { 86 | continue 87 | } 88 | 89 | Y.applyUpdate(yDoc, update, 'replay') 90 | } 91 | } 92 | 93 | const item = newDoc.latex[uuidStr] 94 | let blob 95 | if (item?.kind === 'text') { 96 | blob = new Blob([item.text.toString()], { type: 'text/plain' }) 97 | } else if (item?.kind === 'xmldoc') { 98 | blob = new Blob([item.text.toString()], { type: 'text/plain' }) 99 | } else if (item?.kind === 'markdowndoc') { 100 | blob = new Blob([item.prosemirror.toString()], { type: 'text/plain' }) 101 | } else { 102 | blob = new Blob([JSON.stringify(item)], { type: 'application/json' }) 103 | } 104 | const objUrl = URL.createObjectURL(blob) 105 | window.open(objUrl) 106 | } 107 | 108 | return ( 109 | <> 110 |

Historical Version of File

111 | 112 | 113 | setStateVector(event.target.value)} 120 | /> 121 | 122 | 123 | setFileUUID(event.target.value)} 130 | /> 131 | 132 | 133 | 136 | 137 | 138 | 139 | ) 140 | } 141 | -------------------------------------------------------------------------------- /src/components/config/index.ts: -------------------------------------------------------------------------------- 1 | import ConfigPage from './config-page' 2 | export default ConfigPage 3 | -------------------------------------------------------------------------------- /src/components/config/rebuild-cache/index.tsx: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs' 2 | import { Button, Grid, Typography } from '@suid/material' 3 | import { createSignal, Match } from 'solid-js' 4 | import { useNdnWorkspace } from '../../../Context' 5 | import { encodeSyncState, getNamespace } from '@ucla-irl/ndnts-aux/sync-agent' 6 | import { SequenceNum } from '@ndn/naming-convention2' 7 | import { Decoder } from '@ndn/tlv' 8 | import { Data, Name } from '@ndn/packet' 9 | import { MoreHoriz as MoreHorizIcon } from '@suid/icons-material' 10 | import { getYjsDoc } from '@syncedstore/core' 11 | import { StateVector } from '@ndn/svs' 12 | import { initRootDoc, project } from '../../../backend/models' 13 | import { openRoot, encodeKey as encodePath } from '../../../utils' 14 | import { FsStorage } from '@ucla-irl/ndnts-aux/storage' 15 | import { reprStateVector } from '../workspace-state/index' 16 | import toast from 'solid-toast' 17 | 18 | const StateKey = 'localState' 19 | const SnapshotKey = 'localSnapshot' 20 | 21 | export default function RebuildCache() { 22 | const { syncAgent, booted, stopWorkspace } = useNdnWorkspace()! 23 | const [started, setStarted] = createSignal(false) 24 | 25 | const onRebuild = async () => { 26 | const agent = syncAgent() 27 | if (!agent) return 28 | setStarted(true) 29 | 30 | try { 31 | const oldState = agent.atLeastOnce.syncState 32 | const newState = new StateVector() 33 | 34 | // TODO: Reuse the init code 35 | const appPrefix = agent.appPrefix 36 | const aloSyncPrefix = appPrefix.append(getNamespace().syncKeyword, getNamespace().atLeastOnceKeyword) 37 | const newDoc = initRootDoc(project.WorkspaceDocId) 38 | const yDoc = getYjsDoc(newDoc) 39 | const clientID = yDoc.clientID 40 | yDoc.clientID = 1 // Set the client Id to be a common one to make the change common 41 | newDoc.latex[project.RootId] = { 42 | id: project.RootId, 43 | name: '', 44 | parentId: undefined, 45 | kind: 'folder', 46 | items: [], 47 | deleted: false, 48 | } 49 | yDoc.clientID = clientID 50 | 51 | // Iterate over old state vector 52 | for (const [nodeId, upperBound] of oldState) { 53 | let seqNum = 1 54 | for (seqNum = 1; seqNum <= upperBound; seqNum++) { 55 | const pktName = getNamespace().baseName(nodeId, aloSyncPrefix).append(SequenceNum.create(seqNum)) 56 | const outerWire = await agent.persistStorage.get(pktName.toString()) 57 | if (!outerWire) { 58 | console.error(`You missed update: ${nodeId}= ${seqNum}`) 59 | break 60 | } 61 | 62 | const outerData = Decoder.decode(outerWire, Data) 63 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 64 | const inner = (await (agent as any).parseInnerData(outerData.content)) as { 65 | channel: string 66 | topic: string 67 | content: Uint8Array 68 | } 69 | 70 | if (inner.topic !== 'doc') continue 71 | let update = inner.content 72 | if (inner.channel === 'blobUpdate') { 73 | const name = Decoder.decode(inner.content, Name) 74 | const updateValue = await agent.getBlob(name) 75 | if (updateValue !== undefined) { 76 | // Notify the listener 77 | update = updateValue 78 | } else { 79 | console.error(`You missed update: ${nodeId}= ${seqNum}`) 80 | break 81 | } 82 | } else if (inner.channel !== 'update') { 83 | continue 84 | } 85 | 86 | Y.applyUpdate(yDoc, update, 'replay') 87 | } 88 | 89 | newState.set(nodeId, seqNum - 1) 90 | } 91 | 92 | // 93 | ;(agent.atLeastOnce as unknown as { state: StateVector }).state = new StateVector(newState) 94 | // Pray this does not get modified by incoming SVS Interest. 95 | 96 | // Rewrite the rebuilt state vector and the yjs document 97 | // This can only be done after the workspace is closed, so we need to manually create the persistant storage. 98 | const myNodeId = agent.nodeId 99 | await stopWorkspace() 100 | // agent = undefined 101 | 102 | const handle = await openRoot() 103 | const subFolder = await handle.getDirectoryHandle(encodePath(myNodeId.toString()), { create: true }) 104 | const persistStore = new FsStorage(subFolder) 105 | 106 | const baseName = getNamespace().baseName(myNodeId, aloSyncPrefix) 107 | await persistStore.set(getNamespace().syncStateKey(baseName), encodeSyncState(newState)) 108 | 109 | const update = Y.encodeStateAsUpdate(yDoc) 110 | await persistStore.set(SnapshotKey, update) 111 | await persistStore.set(StateKey, encodeSyncState(newState)) 112 | 113 | console.log(`Rewritten to: ${JSON.stringify(reprStateVector(newState))}`) 114 | toast.success(`Rewritten to: ${JSON.stringify(reprStateVector(newState))}`) 115 | } catch (err) { 116 | console.log(`Failed to rebuild storage: ${err}`) 117 | } 118 | 119 | setStarted(false) 120 | } 121 | 122 | return ( 123 | <> 124 |

Rebuild Local Storage

125 | 126 | 127 | 128 | Please disconnect from the network and bootstrap into a workspace. Be aware that YOU MAY LOSE ALL YOUR DATA. 129 | 130 | 131 | 132 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /src/components/config/update-inspect/index.tsx: -------------------------------------------------------------------------------- 1 | import * as Y from 'yjs' 2 | import { Button, Grid, TextField } from '@suid/material' 3 | import { createSignal } from 'solid-js' 4 | import { useNdnWorkspace } from '../../../Context' 5 | import { getNamespace } from '@ucla-irl/ndnts-aux/sync-agent' 6 | import { SequenceNum } from '@ndn/naming-convention2' 7 | import { Decoder } from '@ndn/tlv' 8 | import { Data } from '@ndn/packet' 9 | import { toHex } from '@ndn/util' 10 | 11 | // TODO: Make it to utils 12 | const REF_LIST = ['GC', 'Deleted', 'JSON', 'Binary', 'String', 'Embed', 'Format', 'Type', 'Any', 'Doc', 'Skip'] 13 | // GC and Skip should not occur in content refs. 14 | // Text document updates belong to String 15 | // Chat updates belong to Any 16 | // Creating a new file will create Type and Any 17 | 18 | export default function UpdateInspect() { 19 | const { syncAgent, booted } = useNdnWorkspace()! 20 | const [nodeIdStr, setNodeIdStr] = createSignal('') 21 | const [seqStr, setSeqStr] = createSignal('') 22 | const [result, setResult] = createSignal('') 23 | 24 | const onInspect = async () => { 25 | const agent = syncAgent() 26 | if (!agent) return 27 | try { 28 | const nodeId = agent.appPrefix.append(nodeIdStr()) 29 | const seqNum = parseInt(seqStr()) 30 | const appPrefix = agent.appPrefix 31 | const aloSyncPrefix = appPrefix.append(getNamespace().syncKeyword, getNamespace().atLeastOnceKeyword) 32 | const pktName = getNamespace().baseName(nodeId, aloSyncPrefix).append(SequenceNum.create(seqNum)) 33 | const outerWire = await agent.persistStorage.get(pktName.toString()) 34 | if (!outerWire) { 35 | setResult('NOT EXISTING') 36 | return 37 | } 38 | const outerData = Decoder.decode(outerWire, Data) 39 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 40 | const inner = (await (agent as any).parseInnerData(outerData.content)) as { 41 | channel: string 42 | topic: string 43 | content: Uint8Array 44 | } 45 | if (!inner) { 46 | setResult('INVALID PACKET') 47 | return 48 | } 49 | let result = `Channel: ${inner.channel}` + '\n' 50 | result += `Topic: ${inner.topic}` + '\n' 51 | if (inner.channel === 'update' && inner.topic === 'doc') { 52 | result += 'Yjs Update:\n' 53 | const update = Y.decodeUpdate(inner.content) 54 | for (const obj of update.structs) { 55 | if (obj instanceof Y.Item) { 56 | result += ' ITEM\n' 57 | result += ` id : ${JSON.stringify(obj.id)} --- ${JSON.stringify(obj.lastId)}\n` 58 | result += ` left : ${JSON.stringify(obj.origin)}\n` 59 | result += ` right: ${JSON.stringify(obj.rightOrigin)}\n` 60 | result += ` parent: ${JSON.stringify(obj.parent)}\n` 61 | result += ` parent sub: ${obj.parentSub}` + '\n' 62 | result += ` content ref(type): ${REF_LIST[obj.content.getRef()]}\n` 63 | result += ` content:\n` 64 | if (obj.content instanceof Y.ContentString) { 65 | result += `======== BEGIN CONTENT ========\n` 66 | result += `${obj.content.str}\n` 67 | result += `======== END CONTENT ========\n` 68 | } else if (obj.content instanceof Y.ContentAny) { 69 | result += ` ${JSON.stringify(obj.content.arr)}\n` 70 | } else if (obj.content instanceof Y.ContentType) { 71 | result += ` ${obj.content.type.toJSON()}\n` 72 | } else if (obj.content instanceof Y.ContentBinary) { 73 | result += ` ${toHex(obj.content.content)}\n` 74 | } else if (obj.content instanceof Y.ContentJSON) { 75 | result += ` ${JSON.stringify(obj.content.arr)}\n` 76 | } else if (obj.content instanceof Y.ContentFormat) { 77 | result += ` ${obj.content.key} = ${obj.content.value}\n` 78 | } else if (obj.content instanceof Y.ContentDoc) { 79 | result += ` Doc GUID: ${obj.content.doc.guid}\n` 80 | } else if (obj.content instanceof Y.ContentEmbed) { 81 | result += ` ${JSON.stringify(obj.content.embed)}\n` 82 | } 83 | } else if (obj instanceof Y.GC) { 84 | result += ' GC\n' 85 | result += ` id : ${JSON.stringify(obj.id)}\n` 86 | result += ` length: ${obj.length}` + '\n' 87 | } else if (obj instanceof Y.Skip) { 88 | result += ' SKIP\n' 89 | result += ` id : ${JSON.stringify(obj.id)}\n` 90 | result += ` length: ${obj.length}` + '\n' 91 | } else { 92 | result += ` ${obj}` + '\n' 93 | } 94 | } 95 | for (const [key, dels] of update.ds.clients) { 96 | result += ` DELETION client=${key}\n` 97 | for (const del of dels) { 98 | result += ` ${JSON.stringify(del)}\n` 99 | } 100 | } 101 | } else { 102 | result += 'Content HEX:\n' + toHex(inner.content) 103 | } 104 | setResult(result) 105 | } catch (err) { 106 | setResult(`${err}`) 107 | } 108 | } 109 | 110 | return ( 111 | <> 112 |

Update Inspect

113 | 114 | 115 | setNodeIdStr(event.target.value)} 122 | /> 123 | 124 | 125 | setSeqStr(event.target.value)} 132 | /> 133 | 134 | 135 | 138 | 139 | 140 | 156 | 157 | 158 | 159 | ) 160 | } 161 | -------------------------------------------------------------------------------- /src/components/config/version-table/index.tsx: -------------------------------------------------------------------------------- 1 | import { onMount, createSignal } from 'solid-js' 2 | import { REVISION, TIMESTAMP } from '../../../build-meta' 3 | 4 | export default function VersionTable() { 5 | const [rev, setRev] = createSignal() 6 | const [times, setTimes] = createSignal() 7 | 8 | onMount(async () => { 9 | try { 10 | const response = await fetch(`${location.origin}/build-meta.json`) 11 | const json = await response.json() 12 | setRev(json['revision']) 13 | setTimes(json['timestamp']) 14 | } catch { 15 | // Ignore errors 16 | } 17 | }) 18 | 19 | return ( 20 | <> 21 |

Versions

22 |

23 | Running build: {REVISION} {new Date(TIMESTAMP * 1000).toLocaleString()} 24 |

25 |

26 | Remote build: {rev() ?? 'UNKNOWN'}{' '} 27 | {times() ? new Date(times()! * 1000).toLocaleString() : 'UNKNOWN'} 28 |

29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/config/workspace-state/index.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@suid/material' 2 | import { useNdnWorkspace } from '../../../Context' 3 | import { Match, Switch } from 'solid-js' 4 | import { StateVector } from '@ndn/svs' 5 | 6 | export const reprStateVector = (sv?: StateVector): Record => { 7 | if (!sv) return {} 8 | const ret = {} as Record 9 | let unknownNum = 0 10 | for (const [id, seq] of sv) { 11 | ret[id.get(-1)?.text ?? `Unknown-${++unknownNum}`] = seq 12 | } 13 | return ret 14 | } 15 | 16 | export default function WorkspaceState() { 17 | const { syncAgent, booted } = useNdnWorkspace()! 18 | const nodeId = () => syncAgent()?.nodeId?.toString() ?? '' 19 | const aloObtained = () => JSON.stringify(reprStateVector(syncAgent()?.atLeastOnce?.syncState)) 20 | const aloFront = () => JSON.stringify(reprStateVector(syncAgent()?.atLeastOnce?.syncInst?.currentStateVector)) 21 | 22 | // TODO: Update the sv dynamically using `createInterval` 23 | 24 | return ( 25 | <> 26 |

State

27 | Not Joined}> 28 | 29 | 30 |

Identity: {nodeId()}

31 |

32 | Persistent State: {aloObtained()} 33 |

34 |

35 | Sync Latest State: {aloFront()} 36 |

37 |
38 |
39 |
40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /src/components/config/yjs-state-vector/index.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@suid/material' 2 | import { useNdnWorkspace } from '../../../Context' 3 | import * as Y from 'yjs' 4 | import { getYjsDoc } from '@syncedstore/core' 5 | import { For } from 'solid-js' 6 | 7 | const getStateVector = (store: Y.Doc['store']): Map => { 8 | const sm = new Map() 9 | store.clients.forEach((structs, client) => { 10 | const struct = structs[structs.length - 1] 11 | sm.set(client, struct.id.clock + struct.length) 12 | }) 13 | return sm 14 | } 15 | 16 | export default function YjsStateVector() { 17 | const { rootDoc } = useNdnWorkspace()! 18 | const yDoc = () => { 19 | const store = rootDoc() 20 | return store ? getYjsDoc(store) : undefined 21 | } 22 | const stateVector = () => { 23 | const ydoc = yDoc() 24 | return ydoc ? getStateVector(ydoc.store) : undefined 25 | } 26 | const missingStructs = () => { 27 | const ydoc = yDoc() 28 | return ydoc?.store?.pendingStructs?.missing 29 | } 30 | 31 | stateVector()?.entries() 32 | 33 | return ( 34 | <> 35 |

YJS Internal State Vector

36 | 37 |

Rendered:

38 | 39 | {([uid, tim]: [number, number]) => ( 40 | <> 41 | {uid}: {tim} 42 |
43 | 44 | )} 45 |
46 |

Missing updates that lead to pending:

47 | 48 | {([uid, tim]: [number, number]) => ( 49 | <> 50 | {uid}: {tim} 51 |
52 | 53 | )} 54 |
55 |
56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /src/components/connect/conn-button.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch } from 'solid-js' 2 | import { IconButton } from '@suid/material' 3 | import { PlayArrow as PlayArrowIcon, MoreHoriz as MoreHorizIcon, Stop as StopIcon } from '@suid/icons-material' 4 | import { ConnState } from '../../backend/main' 5 | 6 | export default function ConnButton(props: { 7 | state: ConnState 8 | isCur: boolean 9 | onConnect: () => void 10 | onDisonnect: () => void 11 | }) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /src/components/connect/conn-state.tsx: -------------------------------------------------------------------------------- 1 | import { Match, Switch } from 'solid-js' 2 | import { ConnState } from '../../backend/main' 3 | import { Typography } from '@suid/material' 4 | 5 | export default function ConnStatus(props: { state: ConnState }) { 6 | return ( 7 | 8 | 9 | 10 | CONNECTED 11 | 12 | 13 | 14 | 15 | NOT CONNECTED 16 | 17 | 18 | 19 | 20 | CONNECTING 21 | 22 | 23 | 24 | 25 | DISCONNECTING 26 | 27 | 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/components/connect/connect.tsx: -------------------------------------------------------------------------------- 1 | import { Stack } from '@suid/material' 2 | import NfdWebsocket from './nfd-websocket' 3 | import PeerJs from './peer-js' 4 | import NdnTestbed from './ndn-testbed' 5 | import { connections as db, Config as Conn } from '../../backend/models/connections' 6 | import { useNavigate } from '@solidjs/router' 7 | import NdnTestbedOidc from './ndn-testbed-oidc' 8 | 9 | export default function Connect() { 10 | const navigate = useNavigate() 11 | 12 | const onAdd = async (config: Conn) => { 13 | await db.save(config) 14 | navigate('/connection', { replace: true }) 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /src/components/connect/index.ts: -------------------------------------------------------------------------------- 1 | import Connect from './connect' 2 | import StoredConns from './stored-conns' 3 | export { StoredConns, Connect } 4 | -------------------------------------------------------------------------------- /src/components/connect/nfd-websocket.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardActions, CardContent, CardHeader, Divider, TextField, Grid, Button } from '@suid/material' 2 | import { Config as Conn } from '../../backend/models/connections' 3 | import { createSignal } from 'solid-js' 4 | import { base64ToBytes, bytesToBase64 } from '../../utils' 5 | import { Decoder, Encoder } from '@ndn/tlv' 6 | import { SafeBag } from '@ndn/ndnsec' 7 | 8 | export default function NfdWebsocket(props: { onAdd: (config: Conn) => void }) { 9 | const [uriText, setUriText] = createSignal('ws://localhost:9696/') 10 | const [safebagText, setSafebagText] = createSignal('') 11 | const [passphrase, setPassphrase] = createSignal('') 12 | 13 | const onClickAdd = async () => { 14 | const safebagB64 = safebagText() 15 | const pass = passphrase() 16 | let uri = uriText() 17 | if (!uri.endsWith('/')) { 18 | uri += '/' 19 | } 20 | const hostname = new URL(uri).hostname 21 | const isLocal = ['localhost', '127.0.0.1'].some((v) => v === hostname) 22 | if (safebagB64 === '' && pass === '') { 23 | // No signing 24 | props.onAdd({ 25 | kind: 'nfdWs', 26 | uri: uri, 27 | isLocal: isLocal, 28 | ownCertificateB64: '', 29 | prvKeyB64: '', 30 | }) 31 | return 32 | } 33 | if (safebagB64 === '' || pass === '') { 34 | console.error( 35 | 'Leave both passphrase and safebag as empty to use a digest signer.' + 'Otherwise, you need to provide both.', 36 | ) 37 | return 38 | } 39 | try { 40 | // Decode certificate and private keys 41 | const safeBagWire = base64ToBytes(safebagB64) 42 | const safeBag = Decoder.decode(safeBagWire, SafeBag) 43 | const cert = safeBag.certificate 44 | const prvKeyBits = await safeBag.decryptKey(pass) 45 | // Re encode certificate and private keys for storage 46 | // TODO: Is cbor a better choice? 47 | const certB64 = bytesToBase64(Encoder.encode(cert.data)) 48 | const prvKeyB64 = bytesToBase64(prvKeyBits) 49 | props.onAdd({ 50 | kind: 'nfdWs', 51 | uri, 52 | isLocal, 53 | ownCertificateB64: certB64, 54 | prvKeyB64, 55 | }) 56 | return 57 | } catch (err) { 58 | console.error(`Unable to decode the provided credential: ${err}`) 59 | return 60 | } 61 | } 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | setUriText(event.target.value)} 77 | /> 78 | 79 | {/* TODO: Reuse workspace's safebag component */} 80 | 81 | setPassphrase(event.target.value)} 89 | /> 90 | 91 | 92 | setSafebagText(event.target.value)} 108 | /> 109 | 110 | 111 | 112 | 113 | 114 | 117 | 118 | 119 | ) 120 | } 121 | -------------------------------------------------------------------------------- /src/components/connect/peer-js.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Card, 3 | CardActions, 4 | CardContent, 5 | CardHeader, 6 | Divider, 7 | InputAdornment, 8 | TextField, 9 | Grid, 10 | Button, 11 | } from '@suid/material' 12 | import { Config as Conn } from '../../backend/models/connections' 13 | import { createSignal } from 'solid-js' 14 | 15 | export default function PeerJs(props: { onAdd: (config: Conn) => void }) { 16 | const [host, setHost] = createSignal('localhost') 17 | const [port, setPort] = createSignal(8000) 18 | const [path, setPath] = createSignal('/aincraft') 19 | const [key, setKey] = createSignal('peerjs') 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | http://, 35 | }} 36 | value={host()} 37 | onChange={(event) => setHost(event.target.value)} 38 | /> 39 | 40 | 41 | setPort(parseInt(event.target.value))} 48 | /> 49 | 50 | 51 | setPath(event.target.value)} 58 | /> 59 | 60 | 61 | setKey(event.target.value)} 68 | /> 69 | 70 | 71 | 72 | 73 | 74 | 89 | 90 | 91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/connect/stored-conns.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Card, 4 | CardActions, 5 | CardContent, 6 | CardHeader, 7 | IconButton, 8 | Table, 9 | TableBody, 10 | TableCell, 11 | TableContainer, 12 | TableHead, 13 | TableRow, 14 | Typography, 15 | } from '@suid/material' 16 | import { Delete as DeleteIcon } from '@suid/icons-material' 17 | import { For, createEffect, createMemo, createSignal } from 'solid-js' 18 | import { connections as db, Config as Conn, getName } from '../../backend/models/connections' 19 | import { useNdnWorkspace } from '../../Context' 20 | import ConnButton from './conn-button' 21 | import { useNavigate } from '@solidjs/router' 22 | 23 | /** A component listing stored connectivity profiles */ 24 | export default function StoredConns() { 25 | const navigate = useNavigate() 26 | const [conns, setConns] = createSignal([]) 27 | const { 28 | connectFuncs: { connect, disconnect }, 29 | connectionStatus: connStatus, 30 | currentConnConfig: curConf, 31 | } = useNdnWorkspace()! 32 | const curConfigName = createMemo(() => getName(curConf())) 33 | 34 | createEffect(() => { 35 | db.loadAll().then((items) => setConns(items)) 36 | }) 37 | 38 | const onRun = (id: number) => { 39 | const item = conns()[id] 40 | if (item !== undefined) { 41 | connect(item) 42 | } 43 | } 44 | 45 | const onRemove = (id: number) => { 46 | const item = conns()[id] 47 | if (item !== undefined) { 48 | db.remove(getName(item)) 49 | .then(() => db.loadAll()) 50 | .then((items) => setConns(items)) 51 | } 52 | } 53 | 54 | const onStop = () => { 55 | disconnect() 56 | } 57 | 58 | return ( 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ID 67 | Actions 68 | 69 | 70 | 71 | 72 | {(item, i) => ( 73 | 74 | 75 | 76 | {getName(item)} 77 | 78 | 79 | 80 | onRun(i())} 84 | onDisonnect={() => onStop()} 85 | /> 86 | { 89 | onRemove(i()) 90 | }} 91 | > 92 | 93 | 94 | 95 | 96 | )} 97 | 98 | 99 |
100 |
101 |
102 | 103 | 112 | 113 |
114 | ) 115 | } 116 | -------------------------------------------------------------------------------- /src/components/oauth-test/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Stack, TextField } from '@suid/material' 2 | import { createSignal, createUniqueId, onCleanup } from 'solid-js' 3 | import { GoogleOAuthClientId, GitHubOAuthClientId } from '../../constants' 4 | 5 | export default function OauthTest() { 6 | const [requestId, setRequestId] = createSignal('') 7 | const [accessCode, setAccessCode] = createSignal('') 8 | // Official way should be useLocation, but I don't think we need it if we only uses the origin 9 | const basePath = location.origin // === `${location.protocol}//${location.host}` 10 | const redirectTarget = `${basePath}/oidc-redirected.html` 11 | const channel = new BroadcastChannel('oauth-test') 12 | 13 | onCleanup(() => { 14 | channel.close() 15 | }) 16 | 17 | channel.addEventListener('message', (event) => { 18 | const data = event.data 19 | if (data.state === requestId()) { 20 | console.log(`Access code: ${data.code}`) 21 | setAccessCode(data.code) 22 | } else { 23 | console.error(`Unknown redirection: ${data.state}`) 24 | } 25 | }) 26 | 27 | const onClickGoogle = () => { 28 | setRequestId(createUniqueId()) 29 | const queryStr = new URLSearchParams({ 30 | scope: 'openid https://www.googleapis.com/auth/userinfo.email', 31 | redirect_uri: redirectTarget, 32 | response_type: 'code', 33 | client_id: GoogleOAuthClientId, 34 | state: requestId(), 35 | access_type: 'offline', 36 | }).toString() 37 | const url = 'https://accounts.google.com/o/oauth2/v2/auth?' + queryStr 38 | window.open(url) // TODO: not working on Safari 39 | } 40 | 41 | const onClickGithub = () => { 42 | setRequestId(createUniqueId()) 43 | const queryStr = new URLSearchParams({ 44 | scope: 'openid user:email', 45 | redirect_uri: redirectTarget, 46 | client_id: GitHubOAuthClientId, 47 | state: requestId(), 48 | }).toString() 49 | const url = 'https://github.com/login/oauth/authorize?' + queryStr 50 | window.open(url) // TODO: not working on Safari 51 | } 52 | 53 | return ( 54 | 55 | 58 | 61 | 70 | 71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /src/components/share-latex/app-tools.tsx: -------------------------------------------------------------------------------- 1 | import { AppBar, Button, IconButton, Toolbar, Divider, MenuItem, Menu, Select } from '@suid/material' 2 | import MenuIcon from '@suid/icons-material/Menu' 3 | import PathBread from './path-bread' 4 | import { createSignal, For, Switch, type JSX, Match, Accessor, Setter } from 'solid-js' 5 | import { SelectChangeEvent } from '@suid/material/Select' 6 | import { ViewValues } from './types' 7 | 8 | export default function AppTools(props: { 9 | rootPath: string 10 | pathIds: string[] 11 | resolveName: (id: string) => string | undefined 12 | menuItems: Array<{ name: string; onClick?: () => void; icon?: JSX.Element }> 13 | onCompile: () => Promise 14 | view: Accessor 15 | setView: Setter 16 | }) { 17 | const [menuAnchor, setMenuAnchor] = createSignal() 18 | const menuOpen = () => menuAnchor() !== undefined 19 | 20 | const closeMenu = () => setMenuAnchor(undefined) 21 | const openMenu: JSX.EventHandlerUnion = (event) => { 22 | setMenuAnchor(event.currentTarget) 23 | } 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 |
32 | 38 |
39 | 40 | 50 |
51 | 52 | 53 | 54 | {({ name, onClick, icon }) => ( 55 | 56 | 57 | 58 | 59 | 60 | { 62 | closeMenu() 63 | onClick?.call(onClick) 64 | }} 65 | disableRipple 66 | > 67 | {icon} 68 | {name} 69 | 70 | 71 | 72 | )} 73 | 74 | 75 |
76 | ) 77 | } 78 | -------------------------------------------------------------------------------- /src/components/share-latex/file-list.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableBody, 4 | TableCell, 5 | TableContainer, 6 | TableHead, 7 | TableRow, 8 | Paper, 9 | Link, 10 | IconButton, 11 | } from '@suid/material' 12 | import { 13 | Folder as FolderIcon, 14 | Description as DescriptionIcon, 15 | FilePresent as FilePresentIcon, 16 | Delete as DeleteIcon, 17 | DriveFileRenameOutline as RenameIcon, 18 | } from '@suid/icons-material' 19 | import { project } from '../../backend/models' 20 | import { For, Match, Switch } from 'solid-js' 21 | 22 | export default function FileList(props: { 23 | rootUri: string 24 | subItems: string[] 25 | resolveItem: (id: string) => project.Item | undefined 26 | deleteItem: (index: number) => void 27 | renameItem: (id: string) => void 28 | }) { 29 | const getItemIcon = (item?: project.Item) => ( 30 | }> 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) 48 | 49 | const getItemLink = (item?: project.Item) => { 50 | const to = () => props.rootUri + '/' + item?.id 51 | return ( 52 | 53 | {getItemIcon(item)} 54 | {item?.name} 55 | 56 | ) 57 | } 58 | 59 | return ( 60 | 61 | 62 | 63 | 64 | Name 65 | Action 66 | 67 | 68 | 69 | 70 | {(itemId, i) => ( 71 | 72 | 73 | {getItemLink(props.resolveItem(itemId))} 74 | 75 | 76 | props.renameItem(itemId)}> 77 | 78 | 79 | props.deleteItem(i())}> 80 | 81 | 82 | 83 | 84 | )} 85 | 86 | 87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /src/components/share-latex/index.ts: -------------------------------------------------------------------------------- 1 | import ShareLatex from './share-latex' 2 | export default ShareLatex 3 | -------------------------------------------------------------------------------- /src/components/share-latex/latex-doc/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-ignore 3 | import { createCodeMirror } from 'solid-codemirror' 4 | // @ts-ignore 5 | import { yCollab } from 'y-codemirror.next' 6 | // There is an error in y-codemirror.next's package.json. 7 | import { EditorView, basicSetup } from 'codemirror' 8 | import { StreamLanguage } from '@codemirror/language' 9 | import { stex } from '@codemirror/legacy-modes/mode/stex' 10 | import * as Y from 'yjs' 11 | import { Paper, useMediaQuery } from '@suid/material' 12 | import EditorTheme from './theme' 13 | import { NdnSvsAdaptor } from '@ucla-irl/ndnts-aux/adaptors' 14 | import { onCleanup } from 'solid-js' 15 | 16 | export default function LatexDoc(props: { doc: Y.Text; provider: NdnSvsAdaptor; username: string; subDocId: string }) { 17 | const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)') 18 | const theme = EditorView.theme(EditorTheme, { dark: prefersDarkMode() }) 19 | 20 | onCleanup(() => { 21 | props.provider.cancelAwareness() 22 | }) 23 | 24 | const getRandomColor = () => 25 | '#' + Array.from({ length: 6 }, () => '0123456789abcdef'[Math.floor(Math.random() * 16)]).join('') 26 | 27 | const { createExtension, ref } = createCodeMirror({ 28 | value: props.doc.toString(), 29 | }) 30 | 31 | props.provider.bindAwareness(props.doc.doc!, props.subDocId) 32 | 33 | props.provider.awareness!.setLocalStateField('user', { 34 | name: props.username, 35 | color: getRandomColor(), 36 | colorLight: getRandomColor(), 37 | }) 38 | 39 | // Theme 40 | createExtension(theme) 41 | createExtension(EditorView.lineWrapping) 42 | 43 | // One cannot create extension in a createEffect 44 | createExtension(basicSetup) 45 | createExtension(StreamLanguage.define(stex)) 46 | createExtension(yCollab(props.doc, props.provider.awareness)) 47 | 48 | return 49 | } 50 | -------------------------------------------------------------------------------- /src/components/share-latex/latex-doc/theme.ts: -------------------------------------------------------------------------------- 1 | const EditorTheme = { 2 | '&': { 3 | whiteSpace: 'nowrap', 4 | textAlign: 'left', 5 | height: '100%', 6 | }, 7 | '&.cm-focused': { 8 | outline: 'none', 9 | }, 10 | '.cm-content': { 11 | fontFamily: '"Roboto Mono", ui-monospace, monospace', 12 | fontSize: '15px', 13 | }, 14 | '.cm-gutters': { 15 | fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif', 16 | backgroundColor: 'var(--md-sys-color-background)', 17 | color: 'var(--md-sys-color-on-background)', 18 | }, 19 | '.cm-activeLineGutter': { 20 | backgroundColor: 'var(--md-sys-color-shadow)', 21 | color: 'var(--md-sys-color-on-primary)', 22 | }, 23 | '.ͼc': { 24 | // Tokens {HERE} 25 | color: 'var(--md-sys-color-secondary)', 26 | }, 27 | '.ͼi': { 28 | // Commands \HERE 29 | color: 'var(--theme-color-success)', 30 | }, 31 | '.ͼm': { 32 | // Comments %HERE 33 | color: 'var(--theme-color-grey-600)', 34 | }, 35 | '.ͼn': { 36 | // Error 37 | color: 'var(--md-sys-color-error)', 38 | }, 39 | '.ͼb': { 40 | // Math dollor $ <- THIS 41 | color: 'var(--md-sys-color-secondary)', 42 | }, 43 | '.ͼk': { 44 | // Symbols in math mode $HERE$ 45 | color: 'var(--md-sys-color-primary)', 46 | }, 47 | '.ͼd': { 48 | // Numbers in math mode %HERE 49 | color: 'var(--theme-color-success)', 50 | }, 51 | } 52 | 53 | export default EditorTheme 54 | -------------------------------------------------------------------------------- /src/components/share-latex/markdown-doc/index.tsx: -------------------------------------------------------------------------------- 1 | import { nord } from '@milkdown/theme-nord' 2 | import { createSignal, onCleanup, onMount } from 'solid-js' 3 | import { Editor, rootCtx } from '@milkdown/core' 4 | import { commonmark } from '@milkdown/preset-commonmark' 5 | import { history } from '@milkdown/plugin-history' 6 | import { collab, CollabService, collabServiceCtx } from '../../../adaptors/milkdown-plugin-synced-store/collab-service' 7 | import { cursor } from '@milkdown/plugin-cursor' 8 | import { indent, indentConfig, IndentConfigOptions } from '@milkdown/plugin-indent' 9 | import { clipboard } from '@milkdown/plugin-clipboard' 10 | import { NdnSvsAdaptor } from '@ucla-irl/ndnts-aux/adaptors' 11 | import * as Y from 'yjs' 12 | 13 | import '@milkdown/theme-nord/style.css' 14 | 15 | import './style.scss' 16 | 17 | export default function MarkdownDoc(props: { 18 | doc: Y.XmlFragment 19 | provider: NdnSvsAdaptor 20 | username: string 21 | subDocId: string 22 | }) { 23 | const [collabService, setCollabService] = createSignal() 24 | 25 | let ref!: HTMLDivElement 26 | let editor: Editor 27 | onMount(async () => { 28 | editor = await Editor.make() 29 | .config((ctx) => { 30 | ctx.set(rootCtx, ref) 31 | }) 32 | .config((ctx) => { 33 | ctx.set(indentConfig.key, { 34 | type: 'space', 35 | size: 4, 36 | } satisfies IndentConfigOptions) 37 | }) 38 | .config(nord) 39 | .use(commonmark) 40 | .use(history) 41 | .use(cursor) 42 | .use(indent) 43 | .use(clipboard) 44 | .use(collab) 45 | .create() 46 | 47 | props.provider.bindAwareness(props.doc.doc!, props.subDocId) 48 | 49 | editor.action((ctx) => { 50 | const collabSrv = ctx.get(collabServiceCtx) 51 | setCollabService(collabSrv) 52 | 53 | collabSrv 54 | // bind doc and awareness 55 | .bindFragment(props.doc) 56 | .setAwareness(props.provider.awareness!) 57 | // connect yjs with milkdown 58 | .connect() 59 | }) 60 | }) 61 | 62 | onCleanup(() => { 63 | collabService()?.disconnect() 64 | props.provider.cancelAwareness() 65 | editor.destroy() 66 | }) 67 | 68 | return
69 | } 70 | -------------------------------------------------------------------------------- /src/components/share-latex/markdown-doc/style.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .milkdown { 6 | @apply bg-slate-50 px-2 py-4 m-5 border rounded; 7 | } 8 | 9 | .editor { 10 | @apply mx-auto; 11 | } 12 | 13 | .prose { 14 | max-width: unset; 15 | } 16 | 17 | :focus { 18 | outline: none; 19 | } 20 | 21 | // Taken from the example at https://milkdown.dev/docs/guide/collaborative-editing 22 | .ProseMirror > .ProseMirror-yjs-cursor:first-child { 23 | margin-top: 16px; 24 | } 25 | .ProseMirror p:first-child, 26 | .ProseMirror h1:first-child, 27 | .ProseMirror h2:first-child, 28 | .ProseMirror h3:first-child, 29 | .ProseMirror h4:first-child, 30 | .ProseMirror h5:first-child, 31 | .ProseMirror h6:first-child { 32 | margin-top: 16px; 33 | } 34 | 35 | .ProseMirror-yjs-cursor { 36 | position: absolute; 37 | border-left: 2px solid black; 38 | border-color: orange; 39 | word-break: normal; 40 | pointer-events: none; 41 | } 42 | 43 | .ProseMirror-yjs-cursor > div { 44 | position: absolute; 45 | top: -1em; 46 | left: -2px; 47 | font-size: 10pt; 48 | background-color: orange; 49 | border-radius: 2px; 50 | border-radius: 3px; 51 | padding: 1px 3px; 52 | font-style: normal; 53 | font-weight: normal; 54 | line-height: normal; 55 | user-select: none; 56 | color: black; 57 | white-space: nowrap; 58 | animation: fade90 2s forwards; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/share-latex/new-item-modal.tsx: -------------------------------------------------------------------------------- 1 | import { AttachFile as AttachFileIcon, Clear as ClearIcon, CloudUpload as CloudUploadIcon } from '@suid/icons-material' 2 | import { 3 | Dialog, 4 | Button, 5 | DialogTitle, 6 | DialogContent, 7 | DialogActions, 8 | TextField, 9 | InputAdornment, 10 | IconButton, 11 | styled, 12 | } from '@suid/material' 13 | import { Show, createSignal } from 'solid-js' 14 | 15 | // NOTE: Please refer to src/backend/models/project.ts:Item.kind 16 | // Should we use that type? Will create more coupling, though 17 | export type FileType = '' | 'folder' | 'doc' | 'upload' | 'richDoc' | 'markdownDoc' 18 | 19 | const VisuallyHiddenInput = styled('input')({ 20 | clip: 'rect(0 0 0 0)', 21 | clipPath: 'inset(50%)', 22 | height: 1, 23 | overflow: 'hidden', 24 | position: 'absolute', 25 | bottom: 0, 26 | left: 0, 27 | whiteSpace: 'nowrap', 28 | width: 1, 29 | }) 30 | 31 | export default function NewItemModal(props: { 32 | fileType: FileType 33 | onSubmit: (name: string, state: FileType, blob?: Uint8Array) => void 34 | onCancel: () => void 35 | }) { 36 | const [name, setName] = createSignal('') 37 | const [inputRef, setInputRef] = createSignal() 38 | const [fileName, setFileName] = createSignal('') 39 | const [blob, setBlob] = createSignal(new Uint8Array()) 40 | const title = () => { 41 | switch (props.fileType) { 42 | case 'folder': 43 | return 'New folder' 44 | case 'doc': 45 | return 'New .tex file' 46 | case 'richDoc': 47 | return 'New .xml rich document' 48 | case 'upload': 49 | return 'Upload blob file' 50 | case 'markdownDoc': 51 | return 'New .md document' 52 | default: 53 | return '' 54 | } 55 | } 56 | 57 | const onUpload = () => { 58 | const files = inputRef()?.files 59 | if (files && files.length > 0) { 60 | setFileName(files[0].name) 61 | if (name() === '') { 62 | setName(files[0].name) 63 | } 64 | files[0].arrayBuffer().then((arrayBuf) => { 65 | setBlob(new Uint8Array(arrayBuf)) 66 | }) 67 | } 68 | } 69 | 70 | return ( 71 | 72 | {title()} 73 | 74 | setName(event.target.value)} 81 | /> 82 | 83 | 93 | 94 | 95 | ), 96 | endAdornment: ( 97 | 98 | 99 | 100 | 101 | 102 | ), 103 | }} 104 | /> 105 | 109 | 110 | 111 | 112 | 115 | 129 | 130 | 131 | ) 132 | } 133 | -------------------------------------------------------------------------------- /src/components/share-latex/path-bread.tsx: -------------------------------------------------------------------------------- 1 | import { Breadcrumbs, Link, Typography } from '@suid/material' 2 | import HomeIcon from '@suid/icons-material/Home' 3 | import { For, Match, Show, Switch, Setter } from 'solid-js' 4 | import { ViewValues } from './types' 5 | 6 | export default function PathBread(props: { 7 | rootPath: string 8 | pathIds: string[] 9 | resolveName: (id: string) => string | undefined 10 | setView: Setter 11 | }) { 12 | return ( 13 | 14 | 15 | {(value, index) => { 16 | const isFirst = () => index() === 0 17 | const isLast = () => index() === props.pathIds.length - 1 18 | const to = () => props.rootPath + '/' + value 19 | 20 | const handleClick = () => { 21 | // Force Setview to Editor after click path 22 | props.setView('Editor') 23 | } 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | <> 31 | ROOT 32 | 33 | 34 | 35 | 36 | 37 | 44 | 45 | <> 46 | ROOT 47 | 48 | 49 | 50 | 51 | 52 | ) 53 | }} 54 | 55 | 56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /src/components/share-latex/rename-item.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle, TextField } from '@suid/material' 2 | import { FileType } from './new-item-modal' 3 | import { createSignal } from 'solid-js' 4 | 5 | export default function RenameItem(props: { 6 | fileType: FileType 7 | fileId: string 8 | onSubmit: (id: string, newName: string) => void 9 | onCancel: () => void 10 | }) { 11 | const [newName, setNewName] = createSignal('') 12 | const title = () => { 13 | switch (props.fileType) { 14 | case 'folder': 15 | return 'Rename folder' 16 | case 'doc': 17 | return 'Rename .tex file' 18 | case 'richDoc': 19 | return 'Rename .xml rich document' 20 | case 'upload': 21 | return 'Rename blob file' 22 | case 'markdownDoc': 23 | return 'Rename .md document' 24 | default: 25 | return 'Rename file' 26 | } 27 | } 28 | 29 | return ( 30 | 31 | {title()} 32 | 33 | setNewName(event.target.value)} 40 | autoFocus 41 | /> 42 | 43 | 44 | 47 | 50 | 51 | 52 | ) 53 | } 54 | -------------------------------------------------------------------------------- /src/components/share-latex/rich-doc/cmd-icon.tsx: -------------------------------------------------------------------------------- 1 | import { createEditorTransaction } from 'solid-tiptap' 2 | import { type ChainedCommands, type Editor } from '@tiptap/core' 3 | import { ParentProps } from 'solid-js' 4 | import { IconButton } from '@suid/material' 5 | 6 | const CmdIcon = ( 7 | props: ParentProps<{ 8 | editor: Editor | undefined 9 | toggle: (cmd?: ChainedCommands) => ChainedCommands | undefined 10 | activeName?: string 11 | activeAttr?: object 12 | noChecking?: boolean 13 | }>, 14 | ) => { 15 | const onClick = () => props.toggle(props.editor?.chain().focus())?.run() 16 | const disabled = createEditorTransaction( 17 | () => props.editor, 18 | (editor) => { 19 | if (props.noChecking) { 20 | return props.editor === undefined 21 | } else { 22 | return !props.toggle(editor?.can().chain().focus())?.run() 23 | } 24 | }, 25 | ) 26 | const isActive = createEditorTransaction( 27 | () => props.editor, 28 | (editor) => { 29 | if (props.activeName !== undefined) { 30 | return editor?.isActive(props.activeName, props.activeAttr) ?? false 31 | } else { 32 | return false 33 | } 34 | }, 35 | ) 36 | 37 | return ( 38 | 39 | {props.children} 40 | 41 | ) 42 | } 43 | 44 | export default CmdIcon 45 | -------------------------------------------------------------------------------- /src/components/share-latex/rich-doc/color-list.tsx: -------------------------------------------------------------------------------- 1 | import { createEditorTransaction } from 'solid-tiptap' 2 | import { type Editor } from '@tiptap/core' 3 | import { For } from 'solid-js' 4 | import { Select, MenuItem } from '@suid/material' 5 | import { SelectChangeEvent } from '@suid/material/Select' 6 | 7 | const colors: Record = { 8 | primary: 'var(--md-sys-color-primary)', 9 | 'primary-light': 'var(--md-sys-color-primary-container)', 10 | secondary: 'var(--md-sys-color-secondary)', 11 | 'secondary-light': 'var(--md-sys-color-secondary-container)', 12 | tertiary: 'var(--md-sys-color-tertiary)', 13 | 'tertiary-light': 'var(--md-sys-color-tertiary-container)', 14 | success: 'var(--theme-color-success)', 15 | 'success-light': 'var(--theme-color-success-container)', 16 | error: 'var(--md-sys-color-error)', 17 | 'error-light': 'var(--md-sys-color-error-container)', 18 | default: '', 19 | shadow: 'var(--theme-color-grey-600)', 20 | } 21 | 22 | const colorsInv = Object.fromEntries(Object.entries(colors).map(([k, v]) => [v, k])) 23 | 24 | const ColorList = (props: { editor: Editor | undefined }) => { 25 | const value = createEditorTransaction( 26 | () => props.editor, 27 | (editor) => colorsInv[editor?.getAttributes('textStyle')?.color] ?? 'default', 28 | ) 29 | 30 | const onChange = (name: string) => props.editor?.chain().focus()?.setColor(colors[name])?.run() 31 | const handleChange = (event: SelectChangeEvent) => { 32 | const name = event.target.value as string 33 | onChange(name) 34 | } 35 | 36 | return ( 37 | <> 38 | 55 | 56 | ) 57 | } 58 | 59 | export default ColorList 60 | -------------------------------------------------------------------------------- /src/components/share-latex/rich-doc/icons.tsx: -------------------------------------------------------------------------------- 1 | import { SvgIcon } from '@suid/material' 2 | 3 | export const H1Icon = () => ( 4 | 5 | 6 | 7 | ) 8 | 9 | export const H2Icon = () => ( 10 | 11 | 12 | 13 | ) 14 | 15 | export const H3Icon = () => ( 16 | 17 | 18 | 19 | ) 20 | 21 | export const H4Icon = () => ( 22 | 23 | 24 | 25 | ) 26 | -------------------------------------------------------------------------------- /src/components/share-latex/rich-doc/styles.module.scss: -------------------------------------------------------------------------------- 1 | /** Rich editor */ 2 | .editor { 3 | padding: 0 4px; 4 | height: 100%; 5 | } 6 | 7 | .editor:global > .ProseMirror { 8 | outline: none !important; 9 | height: 100%; 10 | 11 | h1 { 12 | margin: 0.4em 0; 13 | } 14 | 15 | h2, p { 16 | margin: 0.3em 0; 17 | } 18 | 19 | h3, h4, h5 { 20 | margin: 0.2em 0; 21 | } 22 | 23 | ul { 24 | margin: 0.1em 0; 25 | } 26 | 27 | /** Temporary typography */ 28 | & { 29 | font-family: 'Roboto', sans-serif; 30 | font-weight: 400; 31 | } 32 | 33 | h1, h2, h3, h4, h5 { 34 | font-weight: 500; 35 | } 36 | 37 | /* Give a remote user a caret */ 38 | :global .collaboration-cursor__caret { 39 | border-left: 1px solid #0d0d0d; 40 | border-right: 1px solid #0d0d0d; 41 | margin-left: -1px; 42 | margin-right: -1px; 43 | pointer-events: none; 44 | position: relative; 45 | word-break: normal; 46 | } 47 | 48 | /* Render the username above the caret */ 49 | :global .collaboration-cursor__label { 50 | border-radius: 3px 3px 3px 0; 51 | color: #0d0d0d; 52 | font-size: 12px; 53 | font-style: normal; 54 | font-weight: 600; 55 | left: -1px; 56 | line-height: normal; 57 | padding: 0.1rem 0.3rem; 58 | position: absolute; 59 | top: -1.4em; 60 | user-select: none; 61 | white-space: nowrap; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/share-latex/share-latex/styles.module.scss: -------------------------------------------------------------------------------- 1 | /** ShareLatex Component */ 2 | .outer { 3 | flex: 1; 4 | overflow: hidden; 5 | display: flex; 6 | flex-direction: row; 7 | height: 100%; 8 | padding: 4px; 9 | 10 | > .panel { 11 | height: 100%; 12 | overflow: auto; 13 | max-width: 100%; 14 | flex: 1; 15 | } 16 | 17 | .log { 18 | height: 100%; 19 | overflow: auto; 20 | border: 1px solid #c0c0c0; 21 | border-radius: 4px; 22 | padding: 8px; 23 | } 24 | 25 | .pdf { 26 | height: 100%; 27 | overflow: auto; 28 | border: 1px; 29 | border-radius: 4px; 30 | padding: 8px; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/share-latex/simple-pdf/pdf-viewer.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'solid-js' 2 | 3 | interface PDFViewerProps { 4 | pdfUrl: string 5 | width?: string 6 | height?: string 7 | } 8 | 9 | const PDFViewer: Component = (props) => { 10 | return ( 11 |