├── .eslintignore ├── .eslintrc.cjs ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── playwright.yml │ └── publish.yml ├── .gitignore ├── .npmrc ├── .prettierrc.cjs ├── LICENSE ├── README.md ├── fission.yaml ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.cjs ├── src ├── app.html ├── components │ ├── Footer.svelte │ ├── Header.svelte │ ├── about │ │ └── AboutThisTemplate.svelte │ ├── auth │ │ ├── backup │ │ │ ├── AreYouSure.svelte │ │ │ └── Backup.svelte │ │ ├── delegate-account │ │ │ ├── ConnectBackupDevice.svelte │ │ │ └── DelegateAccount.svelte │ │ ├── link-device │ │ │ └── LinkDevice.svelte │ │ ├── recover │ │ │ ├── HasRecoveryKit.svelte │ │ │ └── RecoveryKitButton.svelte │ │ └── register │ │ │ ├── Register.svelte │ │ │ └── Welcome.svelte │ ├── common │ │ ├── FilesystemActivity.svelte │ │ ├── FullScreenLoadingSpinner.svelte │ │ └── LoadingSpinner.svelte │ ├── home │ │ ├── Authed.svelte │ │ └── Public.svelte │ ├── icons │ │ ├── About.svelte │ │ ├── Alert.svelte │ │ ├── BrandLogo.svelte │ │ ├── BrandWordmark.svelte │ │ ├── CheckIcon.svelte │ │ ├── CheckThinIcon.svelte │ │ ├── ClipboardIcon.svelte │ │ ├── Connect.svelte │ │ ├── DarkMode.svelte │ │ ├── Disconnect.svelte │ │ ├── Discord.svelte │ │ ├── Download.svelte │ │ ├── ExternalLink.svelte │ │ ├── FileUploadIcon.svelte │ │ ├── Github.svelte │ │ ├── Hamburger.svelte │ │ ├── Home.svelte │ │ ├── InfoThinIcon.svelte │ │ ├── LeftArrow.svelte │ │ ├── LightMode.svelte │ │ ├── OnePassword.svelte │ │ ├── PhotoGallery.svelte │ │ ├── RightArrow.svelte │ │ ├── Settings.svelte │ │ ├── Share.svelte │ │ ├── Shield.svelte │ │ ├── Trash.svelte │ │ ├── Upload.svelte │ │ ├── WarningThinIcon.svelte │ │ ├── WelcomeCheckIcon.svelte │ │ ├── XIcon.svelte │ │ └── XThinIcon.svelte │ ├── nav │ │ ├── AlphaTag.svelte │ │ ├── NavItem.svelte │ │ └── SidebarNav.svelte │ ├── notifications │ │ ├── Notification.svelte │ │ └── Notifications.svelte │ └── settings │ │ ├── Avatar.svelte │ │ ├── AvatarUpload.svelte │ │ ├── ConnectedDevices.svelte │ │ ├── RecoveryKit.svelte │ │ ├── RecoveryKitModal.svelte │ │ ├── ThemePreferences.svelte │ │ ├── TruncatedUsername.svelte │ │ └── Username.svelte ├── global.css ├── global.d.ts ├── lib │ ├── account-settings.ts │ ├── app-info.ts │ ├── auth │ │ ├── account.ts │ │ ├── backup.ts │ │ └── linking.ts │ ├── init.ts │ ├── notifications.ts │ ├── session.ts │ ├── theme.ts │ ├── utils.ts │ └── views.ts ├── routes │ ├── +error.svelte │ ├── +layout.js │ ├── +layout.svelte │ ├── +page.svelte │ ├── about │ │ └── +page.svelte │ ├── backup │ │ └── +page.svelte │ ├── delegate-account │ │ └── +page.svelte │ ├── gallery │ │ ├── +page.svelte │ │ ├── components │ │ │ ├── icons │ │ │ │ └── FileUploadIcon.svelte │ │ │ ├── imageGallery │ │ │ │ ├── ImageCard.svelte │ │ │ │ ├── ImageGallery.svelte │ │ │ │ └── ImageModal.svelte │ │ │ └── upload │ │ │ │ ├── Dropzone.svelte │ │ │ │ └── FileUploadCard.svelte │ │ ├── lib │ │ │ └── gallery.ts │ │ └── stores.ts │ ├── link-device │ │ └── +page.svelte │ ├── recover │ │ └── +page.svelte │ ├── register │ │ └── +page.svelte │ └── settings │ │ └── +page.svelte └── stores.ts ├── static ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-dark.ico ├── favicon-light.ico ├── favicon.ico ├── fonts │ ├── uncut-sans-bold-webfont.woff │ ├── uncut-sans-bold-webfont.woff2 │ ├── uncut-sans-medium-webfont.woff │ ├── uncut-sans-medium-webfont.woff2 │ ├── uncut-sans-regular-webfont.woff │ └── uncut-sans-regular-webfont.woff2 ├── icon-192.png ├── icon-512.png ├── icon.svg ├── ipfs-404.html ├── manifest.webmanifest ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── odd-ui.png ├── preview.png ├── safari-pinned-tab.svg ├── still-circle-animation.svg ├── style │ └── output.css ├── wn-404.gif └── wnfs-gallery-screenshot.png ├── svelte.config.js ├── tailwind.config.cjs ├── tests └── homepage.spec.ts ├── tsconfig.json ├── tsnode-loader.js └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.cjs 2 | svelte.config.js 3 | tsnode-loader.js 4 | src/hooks.ts -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | ecmaVersion: 2020, 5 | sourceType: 'module', 6 | tsconfigRootDir: __dirname, 7 | project: ['./tsconfig.json'], 8 | extraFileExtensions: ['.svelte'] 9 | }, 10 | env: { 11 | es6: true, 12 | browser: true 13 | }, 14 | settings: { 15 | 'svelte3/typescript': () => require('typescript'), 16 | }, 17 | plugins: ['svelte3', '@typescript-eslint'], 18 | ignorePatterns: ['node_modules'], 19 | extends: [ 20 | 'eslint:recommended', 21 | 'plugin:@typescript-eslint/recommended', 22 | // 'plugin:@typescript-eslint/recommended-requiring-type-checking' 23 | ], 24 | overrides: [ 25 | { 26 | files: ['*.svelte'], 27 | processor: 'svelte3/svelte3' 28 | } 29 | ], 30 | rules: { 31 | "@typescript-eslint/ban-ts-comment": ['error', { 'ts-ignore': 'allow-with-description'}], 32 | '@typescript-eslint/member-delimiter-style': ['error', { 33 | 'multiline': { 34 | 'delimiter': 'none', 35 | 'requireLast': false 36 | } 37 | }], 38 | '@typescript-eslint/no-use-before-define': ['off'], 39 | '@typescript-eslint/semi': ['error', 'never'], 40 | '@typescript-eslint/quotes': ['error', 'single', { 41 | allowTemplateLiterals: true 42 | }], 43 | // If you want to *intentionally* run a promise without awaiting, prepend it with "void " instead of "await " 44 | '@typescript-eslint/no-floating-promises': ['error'] 45 | } 46 | } -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. 4 | 5 | ## Link to issue 6 | 7 | Please add a link to any relevant issues/tickets 8 | 9 | ## Type of change 10 | 11 | Please delete options that are not relevant. 12 | 13 | - [ ] Bug fix (non-breaking change that fixes an issue) 14 | - [ ] New feature (non-breaking change that adds functionality) 15 | - [ ] Refactor (non-breaking change that updates existing functionality) 16 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 17 | - [ ] This change requires a documentation update 18 | - [ ] Comments have been added/updated 19 | 20 | ## Screenshots/Screencaps 21 | 22 | Please add previews of any UI Changes 23 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | 7 | jobs: 8 | test_setup: 9 | name: Test setup 10 | runs-on: ubuntu-latest 11 | outputs: 12 | preview_url: ${{ steps.waitForVercelPreviewDeployment.outputs.url }} 13 | steps: 14 | - name: Wait for Vercel preview deployment to be ready 15 | uses: patrickedqvist/wait-for-vercel-preview@main 16 | id: waitForVercelPreviewDeployment 17 | with: 18 | token: ${{ secrets.GITHUB_TOKEN }} 19 | max_timeout: 300 20 | test_e2e: 21 | needs: test_setup 22 | name: Playwright tests 23 | timeout-minutes: 5 24 | runs-on: ubuntu-latest 25 | steps: 26 | - name: Prepare testing env 27 | uses: actions/checkout@v2 28 | - uses: actions/setup-node@v2 29 | with: 30 | node-version: "16" 31 | - run: npm ci 32 | - run: npx playwright install --with-deps 33 | - name: Run tests 34 | run: npm run test:e2e 35 | env: 36 | PLAYWRIGHT_TEST_BASE_URL: ${{ needs.test_setup.outputs.preview_url }} 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Publish 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | publish_job: 8 | name: '🚀 Publish' 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: 📥 Checkout repository 12 | uses: actions/checkout@v2 13 | - name: 🧱 Setup node 14 | uses: actions/setup-node@v2 15 | with: 16 | node-version: '16' 17 | - name: 📦 Install packages 18 | run: npm install 19 | - name: 🏗 Build assets 20 | run: npm run build 21 | - name: 🚀 Publish to production 22 | uses: fission-suite/publish-action@v1 23 | with: 24 | machine_key: ${{ secrets.FISSION_MACHINE_KEY }} 25 | app_url: odd-template.fission.app 26 | build_dir: ./build 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node 2 | node_modules/ 3 | 4 | # svelte 5 | .svelte-kit/ 6 | build/ 7 | 8 | # macOS 9 | .DS_Store 10 | 11 | # playwright 12 | /test-results/ 13 | /playwright-report/ 14 | /playwright/.cache/ 15 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true -------------------------------------------------------------------------------- /.prettierrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | htmlWhitespaceSensitivity: 'ignore', 4 | semi: false, 5 | singleQuote: true, 6 | svelteBracketNewLine: true, 7 | svelteSortOrder: 'options-scripts-markup-styles', 8 | svelteStrictMode: false, 9 | svelteIndentScriptAndStyle: true, 10 | tabWidth: 2, 11 | trailingComma: 'none', 12 | overrides: [ 13 | { 14 | files: '*.md', 15 | options: { 16 | tabWidth: 4, 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Fission 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ODD App Template 2 | 3 | [![Built by FISSION](https://img.shields.io/badge/⌘-Built_by_FISSION-purple.svg)](https://fission.codes) [![Built by FISSION](https://img.shields.io/badge/@oddjs/odd-v0.37.0-purple.svg)](https://github.com/oddsdk/ts-odd) [![Discord](https://img.shields.io/discord/478735028319158273.svg)](https://discord.gg/zAQBDEq) [![Discourse](https://img.shields.io/discourse/https/talk.fission.codes/topics)](https://talk.fission.codes) 4 | 5 | ![ODD UI Screenshot](static/odd-ui.png) 6 | 7 | The ODD App Template is a clone-and-go template for building a web application using the ODD SDK, fast. Clone, customize, and deploy to have a running distributed app in mere minutes. 8 | 9 |
10 |

The ODD SDK is alpha software.

11 |

We recommend you do not develop production applications using the ODD App Template at this time. We're working on making it reliable, fast, and awesome, but we're not there yet!

12 |
13 | 14 | ## 🤔 What's The ODD SDK? 15 | 16 | [The ODD SDK](https://github.com/oddsdk/ts-odd) empowers developers to build fully distributed web applications without needing a complex back-end. The SDK provides: 17 | 18 | - user accounts (via [the browser's Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)), 19 | - authorization (using [UCAN](https://ucan.xyz)) 20 | - encrypted file storage (via the [ODD File System](https://guide.fission.codes/developers/odd/file-system-wnfs), backed by the [InterPlanetary File System](https://ipfs.io/), or IPFS) 21 | - and key management (via websockets and a two-factor auth-like flow). 22 | 23 | ODD applications work offline and store data encrypted for the user by leveraging the power of the web platform. You can read more about the ODD SDK in Fission's [ODD SDK Guide](https://guide.fission.codes/developers/odd). 24 | 25 | ## 📦 What does this template give me? 26 | 27 | ### 🥰 Silky-smooth end-user flows 28 | 29 | The ODD App Template provides a _silky-smooth user experience_ out of the box. Creating an account and linking a second device feels familiar, comfortable, and obvious. ODD SDK authentication is key-based rather than password-based, so we've focused heavily on the authentication flows, borrowing language and screens from two-factor auth flows. 30 | 31 | ### 🧱 Built with a modern web stack 32 | 33 | The app template is built with modern web technologies: 34 | 35 | - [SvelteKit](https://kit.svelte.dev/) (powered by [Vite](https://vitejs.dev/) under the hood) 36 | - [TypeScript](https://www.typescriptlang.org/) 37 | - [Tailwind](https://tailwindcss.com/) 38 | - [DaisyUI](https://daisyui.com/) 39 | 40 | ### 👩‍🏫 A simple ODD demo to learn from 41 | 42 | ![WNFS Image Gallery Screenshot](static/wnfs-gallery-screenshot.png) 43 | 44 | The app template includes a functioning application: an image gallery. Check out the image gallery code to learn how an ODD application handles things like file uploads, directories, etc. 45 | 46 | ## 🚀 Getting Started 47 | 48 | You can try out the template yourself [here](https://odd-template.fission.app/). 49 | 50 | Ready? Let's go. 51 | 52 | Prerequiste: ensure you are running Node 16.14 or greater, but _not_ Node 17 (18 is fine though!). 53 | 54 | 1. Clone the repository: 55 | 56 | ```shell 57 | git clone git@github.com:oddsdk/odd-app-template.git 58 | ``` 59 | 60 | 2. Navigate to the downloaded template directory: 61 | 62 | ```shell 63 | cd odd-app-template 64 | ``` 65 | 66 | 3. Install the dependencies: 67 | 68 | ```shell 69 | npm install 70 | ``` 71 | 72 | 4. Start the local development server: 73 | 74 | ```shell 75 | npm run dev 76 | ``` 77 | 78 | 5. Navigate to `http://localhost:5173` in your web browser. 79 | 80 | ## 🛠 Customize your app 81 | 82 | The app template is designed to be easy for you to _make it your own._ Here's how: 83 | 84 | 1. Rename your application. 85 | 86 | In `/src/lib/app-info.ts`: 87 | 88 | - Change `appName` to the name of your app. 89 | - Change `appDescription` to a simple, 1-sentence description of your app. 90 | - Update `oddNamespace` with your project details. 91 | - Once you [deploy](#-deploy) your app, change `appURL` to the production URL. 92 | 93 | In `package.json`, change `name` to your application's name. 94 | 95 | 1. Customize your app's logo. 96 | 97 | - App Logo SVG can be customized in `/src/components/icons/Brand.svelte`. Target an image that is 35 pixels high. 98 | - Replace the favicon files in `/static` by following the instructions in [this blog post](https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs) 99 | - Generate a Twitter/Social Media Embed image. 100 | - In `/src/lib/app-info.ts`, change `appImageURL` to match the URL of your embed image. 101 | - In `/src/routes/+layout.svelte`, update `og:image:width` and `og:image:height` to the size of your embed image. 102 | 103 | 1. Customize the look and feel. 104 | 105 | The app template is built using [Tailwind](https://tailwindcss.com/) and [DaisyUI](https://daisyui.com/). You can customize basic theme colors by editing `/tailwind.config.css`. Check out the [DaisyUI Theme Generator](https://daisyui.com/theme-generator/) to play around with theme colors or read the [customization guide](https://daisyui.com/docs/customize/) to customize the component appearance. 106 | 107 | 1. Clear out the app's home page. 108 | 109 | The home page content is in `/src/routes/+page.svelte`. Delete everything in the file (but don't delete the file!) to start over with a blank home page. 110 | 111 | 1. Remove the image gallery demo app code. 112 | 113 | If you're not building an image gallery, you don't need the gallery demo code, except perhaps to learn from. To get rid of it, delete: 114 | 115 | - `/src/routes/gallery` 116 | - the `initializeFilesystem` function in `/src/lib/auth/account.ts` creates directories used by WNFS. Change those to what you need for your app or delete them if you're not using WNFS. 117 | 118 | 👏 You're ready to start adding custom functionality! 🚀 119 | 120 | Check out the [ODD SDK Guide](https://guide.fission.codes/developers/odd) for ODD SDK questions or [UCAN.xyz](https://ucan.xyz) for UCAN questions. 121 | 122 | ## 📛 Usernames 123 | 124 | When you go through the registration flow in WAT, the username you type in the form field has a `#{DID}` appended to it in the background. We did this to enable discord style usernames where users can share the same usernames, but have unique identifiers attached to the end to distinguish them from one another. We then create a hash of the `fullUsername`(the one with the `#{DID}` appended to the end) that is passed to the ODD SDK. So the ODD SDK only has a notion of the `hashed` username currently. This should also allow users to create usernames using emojis or non-English characters. Also, this is the only username schema that currently supports our File System recovery flow. 125 | 126 | You don’t necessarily need to follow that same pattern though. If you were to register two of the same usernames in the app without hashing them, you would be able to call `session.authStrategy.isUsernameAvailable(username)` to ensure duplicate usernames aren't present in the app. We will be working on porting some of this functionality over to the `ts-ODD` library over the next while and we will be updating the docs to reflect that. 127 | 128 | [Please take a look at our init function](https://github.com/oddsdk/odd-app-template/blob/main/src/lib/init.ts#L34-L38) to see how we are currently constructing the username schema. 129 | 130 | ## 🧨 Deploy 131 | 132 | Any static hosting platform should be supported. The ODD App Template is currently deployed on: 133 | 134 | - [Fission](#fission-app-hosting) 135 | - [Netlify](#netlify) 136 | - [Vercel](#vercel) 137 | - [Cloudflare Pages](#cloudflare-pages) 138 | 139 | ### Fission App Hosting 140 | 141 | Try out [ODD App Template on Fission](https://odd-template.fission.app) 142 | 143 | An ODD application can be published to IPFS with the [Fission CLI](https://guide.fission.codes/developers/cli) or the [Fission GitHub publish action](https://github.com/fission-suite/publish-action). 144 | 145 | **To publish with the Fission CLI:** 146 | 147 | 1. [Install the CLI](https://guide.fission.codes/developers/installation) 148 | 2. Run `fission setup` to make a Fission account 149 | 3. Run `npm run build` to build the app 150 | 4. Delete `fission.yaml` 151 | 5. Run `fission app register` to register a new Fission app (accept the `./build` directory suggestion for your build directory) 152 | 6. Run `fission app publish` to publish your app to the web 153 | 154 | Your app will be available online at the domain assigned by the register command. 155 | 156 | **To set up the GitHub publish action:** 157 | 158 | 1. Register the app with the CLI 159 | 2. Export your machine key with `base64 ~/.config/fission/key/machine_id.ed25519` 160 | 3. Add your machine key as a GH Repository secret named `FISSION_MACHINE_KEY` 161 | 4. Update the `publish.yml` with the name of your registered app 162 | 163 | See the [Fission Guide](https://guide.fission.codes/developers/installation) and the publish action README for more details. 164 | 165 | ### Netlify 166 | 167 | [![Netlify Status](https://api.netlify.com/api/v1/badges/7b7418ef-86eb-43c4-a668-0118568c7f46/deploy-status)](https://app.netlify.com/sites/odd/deploys) 168 | 169 | In order to deploy your ODD application on Netlify: 170 | 171 | 1. Create a new Netlify site and connect your app's git repository. (If you don't have your application stored in a git repository, you can upload the output of a [static build](#static-build).) 172 | 2. Just click Deploy. Netlify takes care of the rest. No Netlify-specific configuration is needed. 173 | 3. There is no step 3. 174 | 175 | ### Vercel 176 | 177 | Try out the [ODD App Template on Vercel](https://odd-app-template.vercel.app/). 178 | 179 | In order to deploy your ODD application on Vercel: 180 | 181 | 1. Create a new Vercel project and connect your app's git repository. (If you don't have your application stored in a git repository, you can upload the output of a [static build](#static-build).) 182 | 2. Override the default output directory and set it to `build`. 183 | 3. Deploy. That's it! 184 | 185 | ### Cloudflare Pages 186 | 187 | Try out the [ODD App Template on Cloudflare Pages](https://odd-template.pages.dev/). 188 | 189 | In order to deploy your ODD application on Cloudflare Pages: 190 | 191 | 1. Create a new Pages project and connect your app's git repository. (If you don't have your application stored in a git repository, you can upload the output of a [static build](#static-build).) 192 | 2. Select `SvelteKit` from the "Framework preset". 193 | 3. Set the "Build output directory" to `build`. 194 | 4. Under "Environment variables", add a variable with name of `NODE_VERSION` and value of `16`. 195 | 5. Add the same environment variable to the "Preview" environment. 196 | 6. Click "Save and Deploy". 197 | 198 | ### Static Build 199 | 200 | Export a static build. 201 | 202 | ```shell 203 | npm run build 204 | ``` 205 | 206 | The build outputs the static site to the `build` directory. 207 | -------------------------------------------------------------------------------- /fission.yaml: -------------------------------------------------------------------------------- 1 | ignore: [] 2 | url: odd-template.fission.app 3 | build: ./build 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odd-app-template", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "dev": "vite dev", 6 | "build": "vite build", 7 | "preview": "vite preview", 8 | "test": "ava src/**/*.test.ts", 9 | "test:e2e": "playwright test", 10 | "report:e2e": "playwright show-report", 11 | "check": "svelte-check --tsconfig ./tsconfig.json", 12 | "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", 13 | "lint": "eslint './src/**/*.{js,ts,svelte}'", 14 | "format": "prettier --write --plugin-search-dir=. ." 15 | }, 16 | "devDependencies": { 17 | "@playwright/test": "^1.29.2", 18 | "@sveltejs/adapter-static": "^1.0.0", 19 | "@sveltejs/kit": "^1.0.1", 20 | "@tailwindcss/typography": "^0.5.2", 21 | "@types/qrcode-svg": "^1.1.1", 22 | "@typescript-eslint/eslint-plugin": "^4.33.0", 23 | "@typescript-eslint/parser": "^4.33.0", 24 | "autoprefixer": "^10.4.13", 25 | "ava": "^4.3.1", 26 | "daisyui": "^2.0.2", 27 | "eslint": "^7.32.0", 28 | "eslint-config-prettier": "^8.1.0", 29 | "eslint-plugin-svelte3": "^3.2.1", 30 | "events": "^3.3.0", 31 | "one-webcrypto": "^1.0.1", 32 | "prettier": "~2.2.1", 33 | "prettier-plugin-svelte": "^2.2.0", 34 | "svelte": "^3.34.0", 35 | "svelte-check": "^2.0.0", 36 | "svelte-preprocess": "^4.0.0", 37 | "svelte-seo": "^1.2.1", 38 | "tailwindcss": "^3.2.1", 39 | "ts-node": "^10.4.0", 40 | "tsconfig-paths": "^3.12.0", 41 | "tslib": "^2.0.0", 42 | "typescript": "^4.4.4", 43 | "vite": "^4.0.0" 44 | }, 45 | "type": "module", 46 | "ava": { 47 | "extensions": { 48 | "ts": "module" 49 | }, 50 | "require": [ 51 | "ts-node/register", 52 | "tsconfig-paths/register" 53 | ], 54 | "nodeArguments": [ 55 | "--loader=./tsnode-loader.js" 56 | ] 57 | }, 58 | "dependencies": { 59 | "clipboard-copy": "^4.0.1", 60 | "qrcode-svg": "^1.1.0", 61 | "uint8arrays": "^4.0.2", 62 | "@oddjs/odd": "0.37.0" 63 | }, 64 | "engines": { 65 | "node": ">=16.14" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: './tests', 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000 23 | }, 24 | /* Run tests in files in parallel */ 25 | fullyParallel: true, 26 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 27 | forbidOnly: !!process.env.CI, 28 | /* Retry on CI only */ 29 | retries: process.env.CI ? 2 : 0, 30 | /* Opt out of parallel tests on CI. */ 31 | workers: process.env.CI ? 1 : undefined, 32 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 33 | reporter: 'html', 34 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 35 | use: { 36 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 37 | actionTimeout: 0, 38 | /* Base URL to use in actions like `await page.goto('/')`. */ 39 | baseURL: process.env.PLAYWRIGHT_TEST_BASE_URL || 'http://127.0.0.1:5173', 40 | 41 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 42 | trace: 'on-first-retry' 43 | }, 44 | 45 | /* Configure projects for major browsers */ 46 | projects: [ 47 | { 48 | name: 'chromium', 49 | use: { 50 | ...devices['Desktop Chrome'] 51 | } 52 | }, 53 | 54 | { 55 | name: 'firefox', 56 | use: { 57 | ...devices['Desktop Firefox'] 58 | } 59 | }, 60 | 61 | { 62 | name: 'webkit', 63 | use: { 64 | ...devices['Desktop Safari'] 65 | } 66 | }, 67 | 68 | /* Test against mobile viewports. */ 69 | { 70 | name: 'Mobile Chrome', 71 | use: { 72 | ...devices['Pixel 5'], 73 | }, 74 | }, 75 | { 76 | name: 'Mobile Safari', 77 | use: { 78 | ...devices['iPhone 12'], 79 | }, 80 | }, 81 | ], 82 | 83 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 84 | // outputDir: 'test-results/', 85 | 86 | /* Run your local dev server before starting the tests */ 87 | webServer: { 88 | command: 'npm run dev', 89 | port: 5173 90 | } 91 | } 92 | 93 | export default config; 94 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')] 3 | } 4 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | %sveltekit.head% 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
%sveltekit.body%
29 | 30 | 31 | -------------------------------------------------------------------------------- /src/components/Footer.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
13 | {#if $themeStore.selectedTheme === 'light'} 14 |

19 | *** Experimental *** - You are currently previewing ODD SDK Alpha 0.2 20 |

21 | {:else} 22 |

25 | *** Experimental *** - You are currently previewing ODD SDK Alpha 0.2 26 |

27 | {/if} 28 |
29 | -------------------------------------------------------------------------------- /src/components/Header.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | 83 | -------------------------------------------------------------------------------- /src/components/about/AboutThisTemplate.svelte: -------------------------------------------------------------------------------- 1 |
4 |

About This Template

5 | 6 |
7 |

8 | 13 | ODD SDK 14 | 15 | 16 | is a true local-first edge computing stack. 17 |

18 |

19 | You can fork this 20 | 25 | template 26 | 27 | 28 | to start writing your own ODD app. Learn more in the 29 | 34 | ODD SDK Guide 35 | 36 | 37 | . 38 |

39 |
40 |
41 | -------------------------------------------------------------------------------- /src/components/auth/backup/AreYouSure.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 45 | -------------------------------------------------------------------------------- /src/components/auth/backup/Backup.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 44 | -------------------------------------------------------------------------------- /src/components/auth/delegate-account/ConnectBackupDevice.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 51 | -------------------------------------------------------------------------------- /src/components/auth/delegate-account/DelegateAccount.svelte: -------------------------------------------------------------------------------- 1 | 25 | 26 | 32 | 70 | -------------------------------------------------------------------------------- /src/components/auth/link-device/LinkDevice.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 61 | -------------------------------------------------------------------------------- /src/components/auth/recover/HasRecoveryKit.svelte: -------------------------------------------------------------------------------- 1 | 88 | 89 |
92 |

Recover your account

93 | 94 | {#if state === RECOVERY_STATES.Done} 95 |

96 | Account recovered! 97 |

98 |

99 | Welcome back {$sessionStore.username.trimmed}. 100 | We were able to successfully recover all of your private data. 101 |

102 | {:else} 103 |

104 | If you’ve lost access to all of your connected devices, you can use your 105 | recovery kit to restore access to your private data. 106 |

107 | {/if} 108 | 109 | {#if state === RECOVERY_STATES.Error} 110 |

111 | We were unable to recover your account. Please double check that you 112 | uploaded the correct file. 113 |

114 | {/if} 115 | 116 |
117 | 118 | 119 | {#if state !== RECOVERY_STATES.Done} 120 |

121 | {`It should be a file named ODD-RecoveryKit-{yourUsername}.txt`} 122 |

123 | {/if} 124 |
125 |
126 | -------------------------------------------------------------------------------- /src/components/auth/recover/RecoveryKitButton.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 | {#if state === RECOVERY_STATES.Ready || state === RECOVERY_STATES.Error} 35 | 41 | 48 | {:else} 49 | {@const { $$on_click, ...props } = buttonData[state].props} 50 | 65 | {/if} 66 | -------------------------------------------------------------------------------- /src/components/auth/register/Register.svelte: -------------------------------------------------------------------------------- 1 | 73 | 74 | {#if initializingFilesystem} 75 | 76 | {:else} 77 |
80 |

Connect this device

81 | 82 | 83 |
87 |

Choose a username

88 |
89 | 99 | {#if checkingUsername} 100 | 103 | {/if} 104 |
105 | 106 | {#if !registrationSuccess} 107 | 108 | 113 | {/if} 114 | 115 |
116 | 121 | 122 | 128 | 138 |
139 | 140 |
141 | 145 | Cancel 146 | 147 | 157 |
158 |
159 | 160 | 161 |
162 | 170 | {#if existingAccount} 171 |
174 |

175 | Which device are you connected on? 176 |

177 |

To connect your existing account, you'll need to:

178 |
    179 |
  1. Find a device the account is already connected on
  2. 180 |
  3. Navigate to your Account Settings
  4. 181 |
  5. Click "Connect a new device"
  6. 182 |
183 |
184 | {/if} 185 |
186 | 187 | 188 | Recover an account 189 |
190 | {/if} 191 | -------------------------------------------------------------------------------- /src/components/auth/register/Welcome.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 40 | -------------------------------------------------------------------------------- /src/components/common/FilesystemActivity.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 18 | -------------------------------------------------------------------------------- /src/components/common/FullScreenLoadingSpinner.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 | 7 |
8 | -------------------------------------------------------------------------------- /src/components/common/LoadingSpinner.svelte: -------------------------------------------------------------------------------- 1 |
4 | -------------------------------------------------------------------------------- /src/components/home/Authed.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
10 |

Welcome, {$sessionStore.username.trimmed}!

11 | 12 |
13 |

Photo Gallery Demo

14 |

15 | The ODD SDK makes it easy to implement private, encrypted, user-owned 16 | storage in your app. See it in action with our photo gallery demo. 17 |

18 | Try the Photo Gallery Demo 19 |
20 | 21 |
22 |

Device Connection Demo

23 |

24 | With the ODD SDK, a user’s account lives only on their connected devices — 25 | entirely under their control. It’s easy for them to connect as many 26 | devices as they’d like. For recoverability, we recommend they always 27 | connect at least two. 28 |

29 | 32 |
33 |
34 | -------------------------------------------------------------------------------- /src/components/home/Public.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
11 |

Welcome to the {appName}

12 | 13 |
14 |

15 | The ODD SDK is a true local-first edge computing stack. Effortlessly give 16 | your users: 17 |

18 | 19 |
    20 |
  • 21 | modern, passwordless accounts 22 | , without a complex and costly cloud-native back-end 23 |
  • 24 |
  • 25 | user-controlled data 26 | , secured by default with our encrypted-at-rest file storage protocol 27 |
  • 28 |
  • 29 | local-first functionality 30 | , including the ability to work offline and collaborate across multiple devices 31 |
  • 32 |
33 | 34 | {#if $sessionStore.error === 'Unsupported Browser'} 35 |
36 |

37 | 38 | Unsupported device 39 |

40 |

41 | It appears this device isn’t supported. ODD requires IndexedDB in 42 | order to function. This browser doesn’t appear to implement this API. 43 | Are you in a Firefox private window? 44 |

45 |
46 | {:else} 47 | 55 | {/if} 56 |
57 |
58 | -------------------------------------------------------------------------------- /src/components/icons/About.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Alert.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/icons/BrandLogo.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 10 | 15 | 20 | 26 | 27 | -------------------------------------------------------------------------------- /src/components/icons/BrandWordmark.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/icons/CheckIcon.svelte: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/CheckThinIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 13 | 14 | 15 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/components/icons/ClipboardIcon.svelte: -------------------------------------------------------------------------------- 1 | 8 | 15 | 16 | -------------------------------------------------------------------------------- /src/components/icons/Connect.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/DarkMode.svelte: -------------------------------------------------------------------------------- 1 | 2 | 12 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/icons/Disconnect.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Discord.svelte: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/icons/Download.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/ExternalLink.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/FileUploadIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 20 | -------------------------------------------------------------------------------- /src/components/icons/Github.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Hamburger.svelte: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/icons/Home.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/InfoThinIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/icons/LeftArrow.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/LightMode.svelte: -------------------------------------------------------------------------------- 1 | 2 | 12 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/icons/OnePassword.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/icons/PhotoGallery.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/RightArrow.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Settings.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Share.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Shield.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Trash.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/Upload.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/icons/WarningThinIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 19 | 20 | -------------------------------------------------------------------------------- /src/components/icons/WelcomeCheckIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/icons/XIcon.svelte: -------------------------------------------------------------------------------- 1 | 8 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/icons/XThinIcon.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/nav/AlphaTag.svelte: -------------------------------------------------------------------------------- 1 | 4 | ALPHA 5 | 6 | -------------------------------------------------------------------------------- /src/components/nav/NavItem.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |
  • 9 | {#if item.callback} 10 | 22 | {:else} 23 | 31 | {item.label} 32 | 33 | {/if} 34 |
  • 35 | -------------------------------------------------------------------------------- /src/components/nav/SidebarNav.svelte: -------------------------------------------------------------------------------- 1 | 55 | 56 | 57 | {#if $sessionStore.session} 58 |
    59 | 65 |
    66 | 67 |
    68 |
    75 |
    106 |
    107 | {:else} 108 | 109 | {/if} 110 | -------------------------------------------------------------------------------- /src/components/notifications/Notification.svelte: -------------------------------------------------------------------------------- 1 | 40 | 41 |
    48 |
    49 |
    50 | 54 | {@html notification.msg} 55 |
    56 |
    57 |
    58 | -------------------------------------------------------------------------------- /src/components/notifications/Notifications.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | {#if $notificationStore.length} 9 |
    10 | {#each $notificationStore as notification (notification.id)} 11 |
    12 | 13 |
    14 | {/each} 15 |
    16 | {/if} 17 | -------------------------------------------------------------------------------- /src/components/settings/Avatar.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | {#if $accountSettingsStore.avatar} 19 | {#if $accountSettingsStore.loading} 20 |
    23 | 26 |
    27 | {:else} 28 | User Avatar 33 | {/if} 34 | {:else} 35 |
    38 | {$sessionStore.username.trimmed[0]} 39 |
    40 | {/if} 41 | -------------------------------------------------------------------------------- /src/components/settings/AvatarUpload.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
    24 |

    Avatar

    25 |
    26 | 27 | 28 | 31 | 38 |
    39 |
    40 | -------------------------------------------------------------------------------- /src/components/settings/ConnectedDevices.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
    8 |

    Connected devices

    9 | {#if $sessionStore.backupCreated} 10 |

    You have connected at least one other device.

    11 | {:else} 12 |

    You have no other connected devices.

    13 | {/if} 14 | 17 |
    18 | -------------------------------------------------------------------------------- /src/components/settings/RecoveryKit.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
    10 |

    Recovery Kit

    11 |

    12 | Your recovery kit will restore access to your data in the event that you 13 | lose access to all of your connected devices. We recommend you store your 14 | kit in a safe place, separate from those devices. 15 |

    16 | 17 | 20 |
    21 | 22 | {#if modalOpen} 23 | 24 | {/if} 25 | -------------------------------------------------------------------------------- /src/components/settings/RecoveryKitModal.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | 88 | -------------------------------------------------------------------------------- /src/components/settings/ThemePreferences.svelte: -------------------------------------------------------------------------------- 1 | 48 | 49 |
    50 |

    Theme preference

    51 | 52 |

    53 | Your theme preference is saved per device. Any newly connected device will 54 | adopt the preference from the device it was connected by. 55 |

    56 | 57 |
    58 |
    59 | 68 |
    69 | {#each options as option} 70 |
    71 | 83 |
    84 | {/each} 85 |
    86 |
    87 | -------------------------------------------------------------------------------- /src/components/settings/TruncatedUsername.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {usernameParts[0]} 8 | 9 | #{usernameParts[1].substring(0, 12)}...{usernameParts[1].substring( 10 | usernameParts[1].length - 5 11 | )} 12 | 13 | -------------------------------------------------------------------------------- /src/components/settings/Username.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 |
    16 |

    Username

    17 |
    18 |

    19 | 20 |

    21 | 27 |
    28 |
    29 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: 'UncutSans'; 7 | src: url('/fonts/uncut-sans-regular-webfont.woff2') format('woff2'), 8 | url('/fonts/uncut-sans-regular-webfont.woff') format('woff'); 9 | font-weight: 400; 10 | font-style: normal; 11 | } 12 | 13 | @font-face { 14 | font-family: 'UncutSans'; 15 | src: url('/fonts/uncut-sans-medium-webfont.woff2') format('woff2'), 16 | url('/fonts/uncut-sans-medium-webfont.woff') format('woff'); 17 | font-weight: 500; 18 | font-style: normal; 19 | } 20 | 21 | @font-face { 22 | font-family: 'UncutSans'; 23 | src: url('/fonts/uncut-sans-medium-webfont.woff2') format('woff2'), 24 | url('/fonts/uncut-sans-medium-webfont.woff') format('woff'); 25 | font-weight: 600; 26 | font-style: normal; 27 | } 28 | 29 | @font-face { 30 | font-family: 'UncutSans'; 31 | src: url('/fonts/uncut-sans-bold-webfont.woff2') format('woff2'), 32 | url('/fonts/uncut-sans-bold-webfont.woff') format('woff'); 33 | font-weight: 700; 34 | font-style: normal; 35 | } 36 | 37 | h1, h2, h3 { 38 | @apply font-bold; 39 | } 40 | 41 | h4, h5, h6 { 42 | @apply font-medium; 43 | } 44 | 45 | body, input { 46 | @apply font-sans; 47 | } 48 | 49 | /* Button default styles */ 50 | .btn { 51 | @apply font-medium; 52 | @apply border-2; 53 | @apply min-h-0; 54 | } 55 | 56 | .btn-circle { 57 | @apply text-base-100; 58 | @apply bg-base-content; 59 | } 60 | 61 | .btn-circle:hover { 62 | @apply bg-base-content; 63 | } 64 | 65 | .btn-outline { 66 | @apply text-sm; 67 | @apply text-base-content; 68 | @apply border-base-content; 69 | @apply bg-base-100; 70 | @apply shadow-orange; 71 | @apply h-10; 72 | @apply px-4; 73 | } 74 | 75 | .btn-primary { 76 | @apply text-sm; 77 | @apply text-neutral-900; 78 | @apply border-neutral-900; 79 | @apply shadow-orange; 80 | @apply bg-gradient-to-r; 81 | @apply from-orange-300; 82 | @apply to-orange-600; 83 | @apply h-10; 84 | @apply px-4; 85 | } 86 | 87 | .btn-primary:disabled { 88 | @apply opacity-50; 89 | @apply text-neutral-900; 90 | @apply border-neutral-900; 91 | } 92 | 93 | .btn-primary:hover, .btn-warning:hover { 94 | @apply border-neutral-900; 95 | } 96 | 97 | .btn-link { 98 | @apply text-blue-500; 99 | } 100 | 101 | /* Input default styles */ 102 | .input-bordered { 103 | @apply text-base-content; 104 | @apply border-2; 105 | @apply border-base-content; 106 | } 107 | 108 | /* Modal default styles */ 109 | .modal-box { 110 | @apply p-8; 111 | @apply border-2; 112 | @apply border-base-content; 113 | } 114 | 115 | /* Label default styles */ 116 | .label { 117 | @apply px-0; 118 | } 119 | 120 | .label-text-alt { 121 | @apply text-sm; 122 | } 123 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/lib/account-settings.ts: -------------------------------------------------------------------------------- 1 | import { get as getStore } from 'svelte/store' 2 | import * as odd from '@oddjs/odd' 3 | import { retrieve } from '@oddjs/odd/common/root-key' 4 | import * as uint8arrays from 'uint8arrays' 5 | import type { CID } from 'multiformats/cid' 6 | import type { PuttableUnixTree, File as WNFile } from '@oddjs/odd/fs/types' 7 | import type { Metadata } from '@oddjs/odd/fs/metadata' 8 | 9 | import { accountSettingsStore, filesystemStore, sessionStore } from '$src/stores' 10 | import { addNotification } from '$lib/notifications' 11 | import { fileToUint8Array } from './utils' 12 | 13 | export type Avatar = { 14 | cid: string 15 | ctime: number 16 | name: string 17 | size?: number 18 | src: string 19 | } 20 | 21 | export type AccountSettings = { 22 | avatar: Avatar 23 | loading: boolean 24 | } 25 | interface AvatarFile extends PuttableUnixTree, WNFile { 26 | cid: CID 27 | content: Uint8Array 28 | header: { 29 | content: Uint8Array 30 | metadata: Metadata 31 | } 32 | } 33 | 34 | export const ACCOUNT_SETTINGS_DIR = odd.path.directory('private', 'settings') 35 | const AVATAR_DIR = odd.path.combine(ACCOUNT_SETTINGS_DIR, odd.path.directory('avatars')) 36 | const AVATAR_ARCHIVE_DIR = odd.path.combine(AVATAR_DIR, odd.path.directory('archive')) 37 | const AVATAR_FILE_NAME = 'avatar' 38 | const FILE_SIZE_LIMIT = 20 39 | 40 | /** 41 | * Move old avatar to the archive directory 42 | */ 43 | const archiveOldAvatar = async (): Promise => { 44 | const fs = getStore(filesystemStore) 45 | 46 | // Return if user has not uploaded an avatar yet 47 | const avatarDirExists = await fs.exists(AVATAR_DIR) 48 | if (!avatarDirExists) { 49 | return 50 | } 51 | 52 | // Find the filename of the old avatar 53 | const links = await fs.ls(AVATAR_DIR) 54 | const oldAvatarFileName = Object.keys(links).find(key => 55 | key.includes(AVATAR_FILE_NAME) 56 | ) 57 | const oldFileNameArray = oldAvatarFileName.split('.')[ 0 ] 58 | const archiveFileName = `${oldFileNameArray[ 0 ]}-${Date.now()}.${oldFileNameArray[ 1 ] 59 | }` 60 | 61 | // Move old avatar to archive dir 62 | const fromPath = odd.path.combine(AVATAR_DIR, odd.path.file(oldAvatarFileName)) 63 | const toPath = odd.path.combine(AVATAR_ARCHIVE_DIR, odd.path.file(archiveFileName)) 64 | await fs.mv(fromPath, toPath) 65 | 66 | // Announce the changes to the server 67 | await fs.publish() 68 | } 69 | 70 | /** 71 | * Get the Avatar from the user's WNFS and construct its `src` 72 | */ 73 | export const getAvatarFromWNFS = async (): Promise => { 74 | try { 75 | // Set loading: true on the accountSettingsStore 76 | accountSettingsStore.update(store => ({ ...store, loading: true })) 77 | 78 | const fs = getStore(filesystemStore) 79 | 80 | // If the avatar dir doesn't exist, silently fail and let the UI handle it 81 | const avatarDirExists = await fs.exists(AVATAR_DIR) 82 | if (!avatarDirExists) { 83 | accountSettingsStore.update(store => ({ 84 | ...store, 85 | loading: false 86 | })) 87 | return 88 | } 89 | 90 | // Find the file that matches the AVATAR_FILE_NAME 91 | const links = await fs.ls(AVATAR_DIR) 92 | const avatarName = Object.keys(links).find(key => 93 | key.includes(AVATAR_FILE_NAME) 94 | ) 95 | 96 | // If user has not uploaded an avatar, silently fail and let the UI handle it 97 | if (!avatarName) { 98 | accountSettingsStore.update(store => ({ 99 | ...store, 100 | loading: false 101 | })) 102 | return 103 | } 104 | 105 | const file = await fs.get(odd.path.combine(AVATAR_DIR, odd.path.file(`${avatarName}`))) 106 | 107 | // The CID for private files is currently located in `file.header.content` 108 | const cid = (file as AvatarFile).header.content.toString() 109 | 110 | // Create a base64 string to use as the image `src` 111 | const src = `data:image/jpeg;base64, ${uint8arrays.toString( 112 | (file as AvatarFile).content, 113 | 'base64' 114 | )}` 115 | 116 | const avatar = { 117 | cid, 118 | ctime: (file as AvatarFile).header.metadata.unixMeta.ctime, 119 | name: avatarName, 120 | src 121 | } 122 | 123 | // Push images to the accountSettingsStore 124 | accountSettingsStore.update(store => ({ 125 | ...store, 126 | avatar, 127 | loading: false 128 | })) 129 | } catch (error) { 130 | console.error(error) 131 | accountSettingsStore.update(store => ({ 132 | ...store, 133 | avatar: null, 134 | loading: false 135 | })) 136 | } 137 | } 138 | 139 | /** 140 | * Upload an avatar image to the user's private WNFS 141 | * @param image 142 | */ 143 | export const uploadAvatarToWNFS = async (image: File): Promise => { 144 | try { 145 | // Set loading: true on the accountSettingsStore 146 | accountSettingsStore.update(store => ({ ...store, loading: true })) 147 | 148 | const fs = getStore(filesystemStore) 149 | 150 | // Reject files over 20MB 151 | const imageSizeInMB = image.size / (1024 * 1024) 152 | if (imageSizeInMB > FILE_SIZE_LIMIT) { 153 | throw new Error('Image can be no larger than 20MB') 154 | } 155 | 156 | // Archive old avatar 157 | await archiveOldAvatar() 158 | 159 | // Rename the file to `avatar.[extension]` 160 | const updatedImage = new File( 161 | [ image ], 162 | `${AVATAR_FILE_NAME}.${image.name.split('.')[ 1 ]}`, 163 | { 164 | type: image.type 165 | } 166 | ) 167 | 168 | // Create a sub directory and add the avatar 169 | await fs.write( 170 | odd.path.combine(AVATAR_DIR, odd.path.file(updatedImage.name)), 171 | await fileToUint8Array(updatedImage) 172 | ) 173 | 174 | // Announce the changes to the server 175 | await fs.publish() 176 | 177 | addNotification(`Your avatar has been updated!`, 'success') 178 | } catch (error) { 179 | addNotification(error.message, 'error') 180 | console.error(error) 181 | } 182 | } 183 | 184 | export const generateRecoveryKit = async (): Promise => { 185 | const { 186 | program: { 187 | components: { crypto, reference } 188 | }, 189 | username: { 190 | full, 191 | hashed, 192 | trimmed, 193 | } 194 | } = getStore(sessionStore) 195 | 196 | // Get the user's read-key and base64 encode it 197 | const accountDID = await reference.didRoot.lookup(hashed) 198 | const readKey = await retrieve({ crypto, accountDID }) 199 | const encodedReadKey = uint8arrays.toString(readKey, 'base64pad') 200 | 201 | // Get today's date to display in the kit 202 | const options: Intl.DateTimeFormatOptions = { 203 | weekday: 'short', 204 | year: 'numeric', 205 | month: 'short', 206 | day: 'numeric' 207 | } 208 | const date = new Date() 209 | 210 | const content = `# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% 211 | # @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 212 | # %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% 213 | # @@@@@% %@@@@@@% %@@@@@@@% %@@@@@ 214 | # @@@@@ @@@@@% @@@@@@ @@@@@ 215 | # @@@@@% @@@@@ %@@@@@ %@@@@@ 216 | # @@@@@@% @@@@@ %@@% @@@@@ %@@@@@@ 217 | # @@@@@@@ @@@@@ %@@@@% @@@@@ @@@@@@@ 218 | # @@@@@@@ @@@@% @@@@@@ @@@@@ @@@@@@@ 219 | # @@@@@@@ %@@@@ @@@@@@ @@@@@% @@@@@@@ 220 | # @@@@@@@ @@@@@ @@@@@@ %@@@@@ @@@@@@@ 221 | # @@@@@@@ @@@@@@@@@@@@@@@@ @@@@@ @@@@@@@ 222 | # @@@@@@@ %@@@@@@@@@@@@@@@ @@@@% @@@@@@@ 223 | # @@@@@@@ %@@% @@@@@@ %@@% @@@@@@@ 224 | # @@@@@@@ @@@@@@ @@@@@@@ 225 | # @@@@@@@% %@@@@@@% %@@@@@@@ 226 | # @@@@@@@@@% %@@@@@@@@@@% %@@@@@@@@@ 227 | # %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% 228 | # @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ 229 | # %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@% 230 | # 231 | # This is your recovery kit. (It’s a yaml text file) 232 | # 233 | # Created for ${trimmed} on ${date.toLocaleDateString('en-US', options)} 234 | # 235 | # Store this somewhere safe. 236 | # 237 | # Anyone with this file will have read access to your private files. 238 | # Losing it means you won’t be able to recover your account 239 | # in case you lose access to all your linked devices. 240 | # 241 | # Our team will never ask you to share this file. 242 | # 243 | # To use this file, go to ${window.location.origin}/recover/ 244 | # Learn how to customize this kit for your users: https://guide.fission.codes/ 245 | 246 | username: ${full} 247 | key: ${encodedReadKey}` 248 | 249 | return content 250 | } 251 | -------------------------------------------------------------------------------- /src/lib/app-info.ts: -------------------------------------------------------------------------------- 1 | export const appName = 'ODD SDK Demo' 2 | export const appDescription = 'This is another awesome ODD app.' 3 | export const appURL = 'https://odd.netlify.app' 4 | export const appImageURL = `${appURL}/preview.png` 5 | export const ipfsGatewayUrl = 'runfission.com' 6 | export const oddNamespace = { creator: 'Fission', name: 'OAT' } 7 | -------------------------------------------------------------------------------- /src/lib/auth/account.ts: -------------------------------------------------------------------------------- 1 | import * as uint8arrays from 'uint8arrays' 2 | import { sha256 } from '@oddjs/odd/components/crypto/implementation/browser' 3 | import { publicKeyToDid } from '@oddjs/odd/did/transformers' 4 | import type { Crypto } from '@oddjs/odd' 5 | import type FileSystem from '@oddjs/odd/fs/index' 6 | import { get as getStore } from 'svelte/store' 7 | 8 | import { asyncDebounce } from '$lib/utils' 9 | import { filesystemStore, sessionStore } from '../../stores' 10 | import { getBackupStatus } from '$lib/auth/backup' 11 | import { ACCOUNT_SETTINGS_DIR } from '$lib/account-settings' 12 | import { AREAS } from '$routes/gallery/stores' 13 | import { GALLERY_DIRS } from '$routes/gallery/lib/gallery' 14 | 15 | export const USERNAME_STORAGE_KEY = 'fullUsername' 16 | 17 | export enum RECOVERY_STATES { 18 | Ready, 19 | Processing, 20 | Error, 21 | Done 22 | } 23 | 24 | export const isUsernameValid = async (username: string): Promise => { 25 | const session = getStore(sessionStore) 26 | return session.authStrategy.isUsernameValid(username) 27 | } 28 | 29 | const _isUsernameAvailable = async ( 30 | username: string 31 | ) => { 32 | const session = getStore(sessionStore) 33 | return session.authStrategy.isUsernameAvailable(username) 34 | } 35 | 36 | const debouncedIsUsernameAvailable = asyncDebounce( 37 | _isUsernameAvailable, 38 | 300 39 | ) 40 | 41 | export const isUsernameAvailable = async ( 42 | username: string 43 | ): Promise => { 44 | return debouncedIsUsernameAvailable(username) 45 | } 46 | 47 | export const createDID = async (crypto: Crypto.Implementation): Promise => { 48 | const pubKey = await crypto.keystore.publicExchangeKey() 49 | const ksAlg = await crypto.keystore.getAlgorithm() 50 | 51 | return publicKeyToDid(crypto, pubKey, ksAlg) 52 | } 53 | 54 | export const prepareUsername = async (username: string): Promise => { 55 | const normalizedUsername = username.normalize('NFD') 56 | const hashedUsername = await sha256( 57 | new TextEncoder().encode(normalizedUsername) 58 | ) 59 | 60 | return uint8arrays 61 | .toString(hashedUsername, 'base32') 62 | .slice(0, 32) 63 | } 64 | 65 | export const register = async (hashedUsername: string): Promise => { 66 | const { authStrategy, program: { components: { storage } } } = getStore(sessionStore) 67 | 68 | const { success } = await authStrategy.register({ username: hashedUsername }) 69 | 70 | if (!success) return success 71 | 72 | const session = await authStrategy.session() 73 | filesystemStore.set(session.fs) 74 | 75 | // TODO Remove if only public and private directories are needed 76 | await initializeFilesystem(session.fs) 77 | 78 | const fullUsername = await storage.getItem(USERNAME_STORAGE_KEY) as string 79 | 80 | sessionStore.update(state => ({ 81 | ...state, 82 | username: { 83 | full: fullUsername, 84 | hashed: hashedUsername, 85 | trimmed: fullUsername.split('#')[ 0 ] 86 | }, 87 | session 88 | })) 89 | 90 | return success 91 | } 92 | 93 | /** 94 | * Create additional directories and files needed by the app 95 | * 96 | * @param fs FileSystem 97 | */ 98 | const initializeFilesystem = async (fs: FileSystem): Promise => { 99 | await fs.mkdir(GALLERY_DIRS[ AREAS.PUBLIC ]) 100 | await fs.mkdir(GALLERY_DIRS[ AREAS.PRIVATE ]) 101 | await fs.mkdir(ACCOUNT_SETTINGS_DIR) 102 | } 103 | 104 | export const loadAccount = async (hashedUsername: string, fullUsername: string): Promise => { 105 | const { authStrategy, program: { components: { storage } } } = getStore(sessionStore) 106 | const session = await authStrategy.session() 107 | 108 | filesystemStore.set(session.fs) 109 | 110 | const backupStatus = await getBackupStatus(session.fs) 111 | 112 | await storage.setItem(USERNAME_STORAGE_KEY, fullUsername) 113 | 114 | sessionStore.update(state => ({ 115 | ...state, 116 | username: { 117 | full: fullUsername, 118 | hashed: hashedUsername, 119 | trimmed: fullUsername.split('#')[ 0 ], 120 | }, 121 | session, 122 | backupCreated: backupStatus.created 123 | })) 124 | } 125 | 126 | export async function waitForDataRoot(username: string): Promise { 127 | const session = getStore(sessionStore) 128 | const reference = session.program?.components.reference 129 | const EMPTY_CID = 'Qmc5m94Gu7z62RC8waSKkZUrCCBJPyHbkpmGzEePxy2oXJ' 130 | 131 | if (!reference) throw new Error('Program must be initialized to check for data root') 132 | 133 | let dataRoot = await reference.dataRoot.lookup(username) 134 | 135 | if (dataRoot.toString() !== EMPTY_CID) return 136 | 137 | return new Promise((resolve) => { 138 | const maxRetries = 50 139 | let attempt = 0 140 | 141 | const dataRootInterval = setInterval(async () => { 142 | dataRoot = await reference.dataRoot.lookup(username) 143 | 144 | if (dataRoot.toString() === EMPTY_CID && attempt < maxRetries) { 145 | attempt++ 146 | return 147 | } 148 | 149 | clearInterval(dataRootInterval) 150 | resolve() 151 | }, 500) 152 | }) 153 | } 154 | -------------------------------------------------------------------------------- /src/lib/auth/backup.ts: -------------------------------------------------------------------------------- 1 | import * as odd from '@oddjs/odd' 2 | import type FileSystem from '@oddjs/odd/fs/index' 3 | 4 | export type BackupStatus = { created: boolean } | null 5 | 6 | export const setBackupStatus = async (fs: FileSystem, status: BackupStatus): Promise => { 7 | const backupStatusPath = odd.path.file('private', 'backup-status.json') 8 | await fs.write(backupStatusPath, new TextEncoder().encode(JSON.stringify(status))) 9 | await fs.publish() 10 | } 11 | 12 | export const getBackupStatus = async (fs: FileSystem): Promise => { 13 | const backupStatusPath = odd.path.file('private', 'backup-status.json') 14 | 15 | if (await fs.exists(backupStatusPath)) { 16 | const fileContent = await fs.read(backupStatusPath) 17 | 18 | try { 19 | return JSON.parse( 20 | new TextDecoder().decode(fileContent) 21 | ) as BackupStatus 22 | } catch (err) { 23 | console.warn(`Unable to load backup status: ${err.message || err}`) 24 | } 25 | 26 | return { created: false } 27 | } else { 28 | return { created: false } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/lib/auth/linking.ts: -------------------------------------------------------------------------------- 1 | import type * as odd from '@oddjs/odd' 2 | import { get as getStore } from 'svelte/store' 3 | 4 | import { sessionStore } from '$src/stores' 5 | 6 | 7 | export const createAccountLinkingConsumer = async ( 8 | username: string 9 | ): Promise => { 10 | const session = getStore(sessionStore) 11 | if (session.authStrategy) return session.authStrategy.accountConsumer(username) 12 | 13 | // Wait for program to be initialised 14 | return new Promise((resolve) => { 15 | sessionStore.subscribe(updatedState => { 16 | if (!updatedState.authStrategy) return 17 | const consumer = updatedState.authStrategy.accountConsumer(username) 18 | resolve(consumer) 19 | }) 20 | }) 21 | } 22 | 23 | export const createAccountLinkingProducer = async ( 24 | username: string 25 | ): Promise => { 26 | const session = getStore(sessionStore) 27 | return session.authStrategy.accountProducer(username) 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/init.ts: -------------------------------------------------------------------------------- 1 | import * as odd from '@oddjs/odd' 2 | 3 | import { dev } from '$app/environment' 4 | import { filesystemStore, sessionStore } from '../stores' 5 | import { getBackupStatus, type BackupStatus } from '$lib/auth/backup' 6 | import { USERNAME_STORAGE_KEY, createDID } from '$lib/auth/account' 7 | import { oddNamespace } from '$lib/app-info' 8 | 9 | export const initialize = async (): Promise => { 10 | try { 11 | let backupStatus: BackupStatus = null 12 | 13 | const program: odd.Program = await odd.program({ 14 | namespace: oddNamespace, 15 | debug: dev 16 | }) 17 | 18 | if (program.session) { 19 | // Authed 20 | backupStatus = await getBackupStatus(program.session.fs) 21 | 22 | let fullUsername = await program.components.storage.getItem(USERNAME_STORAGE_KEY) as string 23 | 24 | // If the user is migrating from a version odd-app-template before https://github.com/oddsdk/odd-app-template/pull/97/files#diff-a180510e798b8f833ebfdbe691c5ec4a1095076980d3e2388de29c849b2b8361R44, 25 | // their username won't contain a did, so we will need to manually append a DID and add it storage here 26 | if (!fullUsername) { 27 | const did = await createDID(program.components.crypto) 28 | fullUsername = `${program.session.username}#${did}` 29 | await program.components.storage.setItem(USERNAME_STORAGE_KEY, fullUsername) 30 | window.location.reload() 31 | } 32 | 33 | sessionStore.set({ 34 | username: { 35 | full: fullUsername, 36 | hashed: program.session.username, 37 | trimmed: fullUsername.split('#')[0], 38 | }, 39 | session: program.session, 40 | authStrategy: program.auth, 41 | program, 42 | loading: false, 43 | backupCreated: backupStatus.created 44 | }) 45 | 46 | filesystemStore.set(program.session.fs) 47 | 48 | } else { 49 | // Not authed 50 | sessionStore.set({ 51 | username: null, 52 | session: null, 53 | authStrategy: program.auth, 54 | program, 55 | loading: false, 56 | backupCreated: null 57 | }) 58 | 59 | } 60 | 61 | } catch (error) { 62 | console.error(error) 63 | 64 | switch (error) { 65 | case odd.ProgramError.InsecureContext: 66 | sessionStore.update(session => ({ 67 | ...session, 68 | loading: false, 69 | error: 'Insecure Context' 70 | })) 71 | break 72 | 73 | case odd.ProgramError.UnsupportedBrowser: 74 | sessionStore.update(session => ({ 75 | ...session, 76 | loading: false, 77 | error: 'Unsupported Browser' 78 | })) 79 | break 80 | } 81 | 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/lib/notifications.ts: -------------------------------------------------------------------------------- 1 | import { notificationStore } from '../stores' 2 | 3 | export type Notification = { 4 | id?: string 5 | msg?: string 6 | type?: NotificationType 7 | timeout?: number 8 | } 9 | 10 | type NotificationType = 'success' | 'error' | 'info' | 'warning' 11 | 12 | export const removeNotification: (id: string) => void = id => { 13 | notificationStore.update(all => 14 | all.filter(notification => notification.id !== id) 15 | ) 16 | } 17 | 18 | export const addNotification: ( 19 | msg: string, 20 | type?: NotificationType, 21 | timeout?: number 22 | ) => void = (msg, type = 'info', timeout = 5000) => { 23 | // uuid for each notification 24 | const id = crypto.randomUUID() 25 | 26 | // adding new notifications to the bottom of the list so they stack from bottom to top 27 | notificationStore.update(rest => [ 28 | ...rest, 29 | { 30 | id, 31 | msg, 32 | type, 33 | timeout, 34 | } 35 | ]) 36 | 37 | // removing the notification after a specified timeout 38 | const timer = setTimeout(() => { 39 | removeNotification(id) 40 | clearTimeout(timer) 41 | }, timeout) 42 | 43 | // return the id 44 | return id 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/session.ts: -------------------------------------------------------------------------------- 1 | import type * as odd from '@oddjs/odd' 2 | 3 | import { appName } from '$lib/app-info' 4 | 5 | type Username = { 6 | full: string 7 | hashed: string 8 | trimmed: string 9 | } 10 | 11 | export type Session = { 12 | username: Username 13 | session: odd.Session | null 14 | authStrategy: odd.AuthenticationStrategy | null 15 | program: odd.Program 16 | loading: boolean 17 | backupCreated: boolean 18 | error?: SessionError 19 | } 20 | 21 | type SessionError = 'Insecure Context' | 'Unsupported Browser' 22 | 23 | export const errorToMessage = (error: SessionError): string => { 24 | switch (error) { 25 | case 'Insecure Context': 26 | return `${appName} requires a secure context (HTTPS)` 27 | 28 | case 'Unsupported Browser': 29 | return `Your browser does not support ${appName}` 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/theme.ts: -------------------------------------------------------------------------------- 1 | import { browser } from '$app/environment' 2 | 3 | export type ThemeOptions = 'light' | 'dark' 4 | 5 | export type Theme = { 6 | selectedTheme: ThemeOptions 7 | useDefault: boolean 8 | } 9 | 10 | export const DEFAULT_THEME_KEY = 'useDefaultTheme' 11 | export const THEME_KEY = 'theme' 12 | 13 | export const getSystemDefaultTheme = (): ThemeOptions => 14 | window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' 15 | 16 | export const loadTheme = (): Theme => { 17 | if (browser) { 18 | const useDefault = localStorage.getItem(DEFAULT_THEME_KEY) !== 'undefined' && JSON.parse(localStorage.getItem(DEFAULT_THEME_KEY)) 19 | const browserTheme = localStorage.getItem(THEME_KEY) as ThemeOptions 20 | const osTheme = getSystemDefaultTheme() 21 | 22 | if (useDefault) { 23 | return { 24 | selectedTheme: getSystemDefaultTheme(), 25 | useDefault, 26 | } 27 | } 28 | 29 | return { 30 | selectedTheme: browserTheme ?? (osTheme as ThemeOptions) ?? 'light', 31 | useDefault, 32 | } 33 | } 34 | } 35 | 36 | export const storeTheme = (theme: ThemeOptions): void => { 37 | if (browser) { 38 | localStorage.setItem('theme', theme) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function asyncDebounce( 2 | fn: (...args: A) => Promise, 3 | wait: number 4 | ): (...args: A) => Promise { 5 | let lastTimeoutId: ReturnType | undefined = undefined 6 | 7 | return (...args: A): Promise => { 8 | clearTimeout(lastTimeoutId) 9 | 10 | return new Promise((resolve, reject) => { 11 | const currentTimeoutId = setTimeout(async () => { 12 | try { 13 | if (currentTimeoutId === lastTimeoutId) { 14 | const result = await fn(...args) 15 | resolve(result) 16 | } 17 | } catch (err) { 18 | reject(err) 19 | } 20 | }, wait) 21 | 22 | lastTimeoutId = currentTimeoutId 23 | }) 24 | } 25 | } 26 | 27 | export const extractSearchParam = (url: URL, param: string): string | null => { 28 | const val = url.searchParams.get(param) 29 | 30 | // clear the param from the URL 31 | url.searchParams.delete(param) 32 | history.replaceState(null, document.title, url.toString()) 33 | 34 | return val 35 | } 36 | 37 | /** 38 | * File to Uint8Array 39 | */ 40 | export async function fileToUint8Array(file: File): Promise { 41 | return new Uint8Array( 42 | await new Blob([ file ]).arrayBuffer() 43 | ) 44 | } -------------------------------------------------------------------------------- /src/lib/views.ts: -------------------------------------------------------------------------------- 1 | export type BackupView = 'backup' | 'are-you-sure' 2 | 3 | export type ConnectView = 'connect' | 'open-connected-device' 4 | 5 | export type DelegateAccountView = 'connect-backup-device' | 'delegate-account' 6 | 7 | export type LinkDeviceView = 'link-device' | 'load-filesystem' 8 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 44 | -------------------------------------------------------------------------------- /src/routes/+layout.js: -------------------------------------------------------------------------------- 1 | export const csr = true 2 | export const ssr = false 3 | export const prerender = true 4 | export const trailingSlash = 'always' 5 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | {appName} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
    43 | 44 | 45 | {#if $sessionStore.loading} 46 | 47 | {:else} 48 | 49 |
    50 |
    51 | 52 |
    53 | 54 | {/if} 55 |
    56 |
    57 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {#if $sessionStore?.session} 8 | 9 | {:else} 10 | 11 | {/if} 12 | -------------------------------------------------------------------------------- /src/routes/about/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/routes/backup/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | {#if view === 'backup'} 17 | 18 | {:else if view === 'are-you-sure'} 19 | 20 | {/if} 21 | -------------------------------------------------------------------------------- /src/routes/delegate-account/+page.svelte: -------------------------------------------------------------------------------- 1 | 121 | 122 | {#if view === 'connect-backup-device'} 123 | 124 | {:else if view === 'delegate-account'} 125 | 131 | {/if} 132 | -------------------------------------------------------------------------------- /src/routes/gallery/+page.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 |
    31 | {#if $sessionStore.session} 32 |
    33 |
    34 | {#each Object.keys(AREAS) as area} 35 | 44 | {/each} 45 |
    46 |
    47 | 48 | 49 | 50 | 51 | {/if} 52 |
    53 | -------------------------------------------------------------------------------- /src/routes/gallery/components/icons/FileUploadIcon.svelte: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /src/routes/gallery/components/imageGallery/ImageCard.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 |
    14 |
    17 |
    18 | {`Gallery 23 |
    24 |
    25 | -------------------------------------------------------------------------------- /src/routes/gallery/components/imageGallery/ImageGallery.svelte: -------------------------------------------------------------------------------- 1 | 50 | 51 |
    52 |
    53 |
    56 | 57 | {#each $galleryStore.selectedArea === AREAS.PRIVATE ? $galleryStore.privateImages : $galleryStore.publicImages as image} 58 | 59 | {/each} 60 |
    61 |
    62 | 63 | {#if selectedImage} 64 | 69 | {/if} 70 |
    71 | -------------------------------------------------------------------------------- /src/routes/gallery/components/imageGallery/ImageModal.svelte: -------------------------------------------------------------------------------- 1 | 91 | 92 | 93 | 94 | {#if !!image} 95 | 96 | 102 | 189 | {/if} 190 | -------------------------------------------------------------------------------- /src/routes/gallery/components/upload/Dropzone.svelte: -------------------------------------------------------------------------------- 1 | 57 | 58 | 70 | -------------------------------------------------------------------------------- /src/routes/gallery/components/upload/FileUploadCard.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |