Initial commit
|
|
@ -0,0 +1,4 @@
|
||||||
|
.eslintrc.cjs
|
||||||
|
svelte.config.js
|
||||||
|
tsnode-loader.js
|
||||||
|
src/hooks.ts
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
sourceType: 'module',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
extraFileExtensions: ['.svelte']
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
es6: true,
|
||||||
|
browser: true
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'svelte3/typescript': () => require('typescript'),
|
||||||
|
},
|
||||||
|
plugins: ['svelte3', '@typescript-eslint'],
|
||||||
|
ignorePatterns: ['node_modules'],
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
// 'plugin:@typescript-eslint/recommended-requiring-type-checking'
|
||||||
|
],
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['*.svelte'],
|
||||||
|
processor: 'svelte3/svelte3'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/ban-ts-comment": ['error', { 'ts-ignore': 'allow-with-description'}],
|
||||||
|
'@typescript-eslint/member-delimiter-style': ['error', {
|
||||||
|
'multiline': {
|
||||||
|
'delimiter': 'none',
|
||||||
|
'requireLast': false
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
'@typescript-eslint/no-use-before-define': ['off'],
|
||||||
|
'@typescript-eslint/semi': ['error', 'never'],
|
||||||
|
'@typescript-eslint/quotes': ['error', 'single', {
|
||||||
|
allowTemplateLiterals: true
|
||||||
|
}],
|
||||||
|
// If you want to *intentionally* run a promise without awaiting, prepend it with "void " instead of "await "
|
||||||
|
'@typescript-eslint/no-floating-promises': ['error']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Description
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Link to issue
|
||||||
|
|
||||||
|
Please add a link to any relevant issues/tickets
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
Please delete options that are not relevant.
|
||||||
|
|
||||||
|
- [ ] Bug fix (non-breaking change that fixes an issue)
|
||||||
|
- [ ] New feature (non-breaking change that adds functionality)
|
||||||
|
- [ ] Refactor (non-breaking change that updates existing functionality)
|
||||||
|
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||||
|
- [ ] This change requires a documentation update
|
||||||
|
- [ ] Comments have been added/updated
|
||||||
|
|
||||||
|
## Screenshots/Screencaps
|
||||||
|
|
||||||
|
Please add previews of any UI Changes
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
name: 🚀 Publish
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
publish_job:
|
||||||
|
name: '🚀 Publish'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout repository
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: 🧱 Setup node
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '16'
|
||||||
|
- name: 📦 Install packages
|
||||||
|
run: npm install
|
||||||
|
- name: 🏗 Build assets
|
||||||
|
run: npm run build
|
||||||
|
- name: 🚀 Publish to production
|
||||||
|
uses: fission-suite/publish-action@v1
|
||||||
|
with:
|
||||||
|
machine_key: ${{ secrets.FISSION_MACHINE_KEY }}
|
||||||
|
app_url: webnative-template.fission.app
|
||||||
|
build_dir: ./build
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
# node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# svelte
|
||||||
|
.svelte-kit/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
module.exports = {
|
||||||
|
arrowParens: 'avoid',
|
||||||
|
htmlWhitespaceSensitivity: 'ignore',
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
svelteBracketNewLine: true,
|
||||||
|
svelteSortOrder: 'options-scripts-markup-styles',
|
||||||
|
svelteStrictMode: false,
|
||||||
|
svelteIndentScriptAndStyle: true,
|
||||||
|
tabWidth: 2,
|
||||||
|
trailingComma: 'none',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: '*.md',
|
||||||
|
options: {
|
||||||
|
tabWidth: 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022 Fission
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -0,0 +1,191 @@
|
||||||
|
# Webnative App Template
|
||||||
|
|
||||||
|
[](https://fission.codes) [](https://github.com/fission-suite/webnative) [](https://discord.gg/zAQBDEq) [](https://talk.fission.codes)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The Webnative App Template is a clone-and-go template for building a web application using Webnative, fast. Clone, customize, and deploy to have a running distributed app in mere minutes.
|
||||||
|
|
||||||
|
<div style="background-color: #FEEB80; padding: 1px 20px; border-radius: 10px;">
|
||||||
|
<p class="text-align: center;"><strong>❗️Webnative is alpha software.</strong></p>
|
||||||
|
<p>We recommend you <u>do not develop production applications using the Webnative App Template</u> at this time. We're working on making it reliable, fast, and awesome, but we're not there yet!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 🤔 What's Webnative?
|
||||||
|
|
||||||
|
[The Webnative SDK](https://github.com/fission-codes/webnative) empowers developers to build fully distributed web applications without needing a complex back-end. The SDK provides:
|
||||||
|
|
||||||
|
- user accounts (via [the browser's Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API)),
|
||||||
|
- authorization (using [UCAN](https://ucan.xyz))
|
||||||
|
- encrypted file storage (via the [Webnative File System](https://guide.fission.codes/developers/webnative/file-system-wnfs), backed by the [InterPlanetary File System](https://ipfs.io/), or IPFS)
|
||||||
|
- and key management (via websockets and a two-factor auth-like flow).
|
||||||
|
|
||||||
|
Webnative applications work offline and store data encrypted for the user by leveraging the power of the web platform. You can read more about Webnative in Fission's [Webnative Guide](https://guide.fission.codes/developers/webnative).
|
||||||
|
|
||||||
|
## 📦 What does this template give me?
|
||||||
|
|
||||||
|
### 🥰 Silky-smooth end-user flows
|
||||||
|
|
||||||
|
The Webnative 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. Webnative 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.
|
||||||
|
|
||||||
|
### 🧱 Built with a modern web stack
|
||||||
|
|
||||||
|
The app template is built with modern web technologies:
|
||||||
|
|
||||||
|
- [SvelteKit](https://kit.svelte.dev/) (powered by [Vite](https://vitejs.dev/) under the hood)
|
||||||
|
- [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
- [Tailwind](https://tailwindcss.com/)
|
||||||
|
- [DaisyUI](https://daisyui.com/)
|
||||||
|
|
||||||
|
### 👩🏫 A simple Webnative demo to learn from
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The app template includes a functioning application: an image gallery. Check out the image gallery code to learn how a Webnative application handles things like file uploads, directories, etc.
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
You can try out the template yourself [here](https://webnative-template.fission.app/).
|
||||||
|
|
||||||
|
Ready? Let's go.
|
||||||
|
|
||||||
|
Prerequiste: ensure you are running Node 16.14 or greater, but _not_ Node 17 (18 is fine though!).
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
git clone git@github.com:webnative-examples/webnative-app-template.git
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install the dependencies.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the local development server.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Navigate to `http://localhost:5173` in your web browser.
|
||||||
|
|
||||||
|
## 🛠 Customize your app
|
||||||
|
|
||||||
|
The app template is designed to be easy for you to _make it your own._ Here's how:
|
||||||
|
|
||||||
|
1. Rename your application.
|
||||||
|
|
||||||
|
In `/src/lib/app-info.ts`:
|
||||||
|
|
||||||
|
- Change `appName` to the name of your app.
|
||||||
|
- Change `appDescription` to a simple, 1-sentence description of your app.
|
||||||
|
- Once you [deploy](#deploy) your app, change `appURL` to the production URL.
|
||||||
|
|
||||||
|
In `package.json`, change `name` to your application's name.
|
||||||
|
|
||||||
|
1. Customize your app's logo.
|
||||||
|
|
||||||
|
- App Logo SVG can be customized in `/src/components/icons/Brand.svelte`. Target an image that is 35 pixels high.
|
||||||
|
- 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)
|
||||||
|
- Generate a Twitter/Social Media Embed image.
|
||||||
|
- In `/src/lib/app-info.ts`, change `appImageURL` to match the URL of your embed image.
|
||||||
|
- In `/src/routes/+layout.svelte`, update `og:image:width` and `og:image:height` to the size of your embed image.
|
||||||
|
|
||||||
|
1. Customize the look and feel.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
1. Clear out the app's home page.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
1. Remove the image gallery demo app code.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- `/src/routes/gallery`
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
👏 You're ready to start adding custom functionality! 🚀
|
||||||
|
|
||||||
|
Check out the [Webnative Guide](https://guide.fission.codes/developers/webnative) for Webnative questions or [UCAN.xyz](https://ucan.xyz) for UCAN questions.
|
||||||
|
|
||||||
|
## 🧨 Deploy
|
||||||
|
|
||||||
|
Any static hosting platform should be supported. The Webnative App Template is currently deployed on:
|
||||||
|
|
||||||
|
- [Fission](#fission-app-hosting)
|
||||||
|
- [Netlify](#netlify)
|
||||||
|
- [Vercel](#vercel)
|
||||||
|
- [Cloudflare Pages](#cloudflare-pages)
|
||||||
|
|
||||||
|
### Fission App Hosting
|
||||||
|
|
||||||
|
Try out [Webnative App Template on Fission](https://webnative-template.fission.app)
|
||||||
|
|
||||||
|
A Webnative 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).
|
||||||
|
|
||||||
|
**To publish with the Fission CLI:**
|
||||||
|
|
||||||
|
1. [Install the CLI](https://guide.fission.codes/developers/installation)
|
||||||
|
2. Run `fission setup` to make a Fission account
|
||||||
|
3. Run `npm run build` to build the app
|
||||||
|
4. Delete `fission.yaml`
|
||||||
|
5. Run `fission app register` to register a new Fission app (accept the `./build` directory suggestion for your build directory)
|
||||||
|
6. Run `fission app publish` to publish your app to the web
|
||||||
|
|
||||||
|
Your app will be available online at the domain assigned by the register command.
|
||||||
|
|
||||||
|
**To set up the GitHub publish action:**
|
||||||
|
|
||||||
|
1. Register the app with the CLI
|
||||||
|
2. Export your machine key with `base64 ~/.config/fission/key/machine_id.ed25519`
|
||||||
|
3. Add your machine key as a GH Repository secret named `FISSION_MACHINE_KEY`
|
||||||
|
4. Update the `publish.yml` with the name of your registered app
|
||||||
|
|
||||||
|
See the [Fission Guide](https://guide.fission.codes/developers/installation) and the publish action README for more details.
|
||||||
|
|
||||||
|
### Netlify
|
||||||
|
|
||||||
|
[](https://app.netlify.com/sites/webnative/deploys)
|
||||||
|
|
||||||
|
In order to deploy your Webnative application on Netlify:
|
||||||
|
|
||||||
|
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).)
|
||||||
|
2. Just click Deploy. Netlify takes care of the rest. No Netlify-specific configuration is needed.
|
||||||
|
3. There is no step 3.
|
||||||
|
|
||||||
|
### Vercel
|
||||||
|
|
||||||
|
Try out the [Webnative App Template on Vercel](https://webnative-app-template.vercel.app/).
|
||||||
|
|
||||||
|
In order to deploy your Webnative application on Vercel:
|
||||||
|
|
||||||
|
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).)
|
||||||
|
2. Override the default output directory and set it to `build`.
|
||||||
|
3. Deploy. That's it!
|
||||||
|
|
||||||
|
### Cloudflare Pages
|
||||||
|
|
||||||
|
Try out the [Webnative App Template on Cloudflare Pages](https://webnative-template.pages.dev/).
|
||||||
|
|
||||||
|
In order to deploy your Webnative application on Cloudflare Pages:
|
||||||
|
|
||||||
|
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).)
|
||||||
|
2. Select `SvelteKit` from the "Framework preset".
|
||||||
|
3. Set the "Build output directory" to `build`.
|
||||||
|
4. Under "Environment variables", add a variable with name of `NODE_VERSION` and value of `16`.
|
||||||
|
5. Add the same environment variable to the "Preview" environment.
|
||||||
|
6. Click "Save and Deploy".
|
||||||
|
|
||||||
|
### Static Build
|
||||||
|
|
||||||
|
Export a static build.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The build outputs the static site to the `build` directory.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
ignore: []
|
||||||
|
url: webnative-template.fission.app
|
||||||
|
build: ./build
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
{
|
||||||
|
"name": "webnative-app-template",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "ava src/**/*.test.ts",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint './src/**/*.{js,ts,svelte}'",
|
||||||
|
"format": "prettier --write --plugin-search-dir=. ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/adapter-static": "1.0.0-next.43",
|
||||||
|
"@sveltejs/kit": "1.0.0-next.489",
|
||||||
|
"@tailwindcss/typography": "^0.5.2",
|
||||||
|
"@types/qrcode-svg": "^1.1.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||||
|
"@typescript-eslint/parser": "^4.33.0",
|
||||||
|
"autoprefixer": "^10.4.13",
|
||||||
|
"ava": "^4.3.1",
|
||||||
|
"daisyui": "^2.0.2",
|
||||||
|
"eslint": "^7.32.0",
|
||||||
|
"eslint-config-prettier": "^8.1.0",
|
||||||
|
"eslint-plugin-svelte3": "^3.2.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"one-webcrypto": "^1.0.1",
|
||||||
|
"prettier": "~2.2.1",
|
||||||
|
"prettier-plugin-svelte": "^2.2.0",
|
||||||
|
"svelte": "^3.34.0",
|
||||||
|
"svelte-check": "^2.0.0",
|
||||||
|
"svelte-preprocess": "^4.0.0",
|
||||||
|
"svelte-seo": "^1.2.1",
|
||||||
|
"tailwindcss": "^3.2.1",
|
||||||
|
"ts-node": "^10.4.0",
|
||||||
|
"tsconfig-paths": "^3.12.0",
|
||||||
|
"tslib": "^2.0.0",
|
||||||
|
"typescript": "^4.4.4",
|
||||||
|
"vite": "^3.0.0"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"ava": {
|
||||||
|
"extensions": {
|
||||||
|
"ts": "module"
|
||||||
|
},
|
||||||
|
"require": [
|
||||||
|
"ts-node/register",
|
||||||
|
"tsconfig-paths/register"
|
||||||
|
],
|
||||||
|
"nodeArguments": [
|
||||||
|
"--loader=./tsnode-loader.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clipboard-copy": "^4.0.1",
|
||||||
|
"qrcode-svg": "^1.1.0",
|
||||||
|
"uint8arrays": "^3.1.0",
|
||||||
|
"webnative": "^0.34.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
module.exports = {
|
||||||
|
plugins: [require('tailwindcss'), require('autoprefixer')]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="svelte" class="h-screen">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { themeStore } from '../stores'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Firefox doesn't fully support background-clip so the marquee text is cut off half way through the animation
|
||||||
|
*/
|
||||||
|
const isFirefox = navigator.userAgent.indexOf('Firefox') !== -1
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed z-0 lg:z-20 right-0 bottom-0 left-0 h-8 flex items-center motion-reduce:justify-center motion-safe:justify-end bg-base-content overflow-x-hidden"
|
||||||
|
>
|
||||||
|
{#if $themeStore === 'light'}
|
||||||
|
<p
|
||||||
|
class="motion-safe:animate-marquee motion-safe:left-full whitespace-nowrap font-bold text-xxs {isFirefox
|
||||||
|
? 'text-orange-500'
|
||||||
|
: 'text-transparent bg-clip-text bg-gradient-to-r from-orange-600 to-orange-300'}"
|
||||||
|
>
|
||||||
|
*** Experimental *** - You are currently previewing Webnative SDK Alpha
|
||||||
|
0.2
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p
|
||||||
|
class="motion-safe:animate-marquee motion-safe:left-full whitespace-nowrap font-bold text-xxs text-[#DD1F13]"
|
||||||
|
>
|
||||||
|
*** Experimental *** - You are currently previewing Webnative SDK Alpha
|
||||||
|
0.2
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { sessionStore, themeStore } from '../stores'
|
||||||
|
import { storeTheme, type Theme } from '$lib/theme'
|
||||||
|
import AlphaTag from '$components/nav/AlphaTag.svelte'
|
||||||
|
import Avatar from '$components/settings/Avatar.svelte'
|
||||||
|
import BrandLogo from '$components/icons/BrandLogo.svelte'
|
||||||
|
import BrandWordmark from '$components/icons/BrandWordmark.svelte'
|
||||||
|
import DarkMode from '$components/icons/DarkMode.svelte'
|
||||||
|
import Hamburger from '$components/icons/Hamburger.svelte'
|
||||||
|
import LightMode from '$components/icons/LightMode.svelte'
|
||||||
|
import Shield from '$components/icons/Shield.svelte'
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
themeStore.set(newTheme)
|
||||||
|
storeTheme(newTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="navbar flex bg-base-100 pt-4">
|
||||||
|
<div class="lg:hidden">
|
||||||
|
{#if $sessionStore.authed}
|
||||||
|
<label
|
||||||
|
for="sidebar-nav"
|
||||||
|
class="drawer-button cursor-pointer -translate-x-2"
|
||||||
|
>
|
||||||
|
<Hamburger />
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex items-center cursor-pointer gap-3"
|
||||||
|
on:click={() => goto('/')}
|
||||||
|
>
|
||||||
|
<BrandLogo />
|
||||||
|
<AlphaTag />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Even if the user is not authed, render this header in the connection flow -->
|
||||||
|
{#if !$sessionStore.authed || $page.url.pathname.match(/register|backup|delegate/)}
|
||||||
|
<div
|
||||||
|
class="hidden lg:flex flex-1 items-center cursor-pointer gap-3"
|
||||||
|
on:click={() => goto('/')}
|
||||||
|
>
|
||||||
|
<BrandLogo />
|
||||||
|
<div class="hidden lg:inline-block">
|
||||||
|
<BrandWordmark />
|
||||||
|
</div>
|
||||||
|
<div class="hidden sm:inline-block">
|
||||||
|
<AlphaTag />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ml-auto">
|
||||||
|
{#if !$sessionStore.loading && !$sessionStore.authed}
|
||||||
|
<div class="flex-none">
|
||||||
|
<a class="btn btn-primary btn-sm !h-10" href="/connect">Connect</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !$sessionStore.loading && $sessionStore.backupCreated === false}
|
||||||
|
<span
|
||||||
|
on:click={() => goto('/delegate-account')}
|
||||||
|
class="btn btn-sm h-10 btn-warning rounded-full bg-orange-300 border-2 border-neutral font-medium text-neutral transition-colors ease-in hover:bg-orange-300"
|
||||||
|
>
|
||||||
|
<span class="mr-2">Backup recommended</span>
|
||||||
|
<Shield />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $sessionStore.authed}
|
||||||
|
<a href="/settings" class="ml-2 cursor-pointer">
|
||||||
|
<Avatar size="small" />
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="ml-2 cursor-pointer">
|
||||||
|
{#if $themeStore === 'light'}
|
||||||
|
<span on:click={() => setTheme('dark')}>
|
||||||
|
<LightMode />
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span on:click={() => setTheme('light')}>
|
||||||
|
<DarkMode />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-128px)] md:min-h-[calc(100vh-160px)] pt-8 md:pt-16 flex flex-col items-start max-w-[690px] m-auto gap-10 pb-5 text-sm"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl">About This Template</h1>
|
||||||
|
|
||||||
|
<div class="max-w-[573px]">
|
||||||
|
<p class="mb-5">
|
||||||
|
<a
|
||||||
|
class="link link-primary whitespace-nowrap"
|
||||||
|
href="https://github.com/fission-codes/webnative"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Webnative SDK
|
||||||
|
<span class="-scale-x-100 scale-y-100 inline-block">⎋</span>
|
||||||
|
</a>
|
||||||
|
is a true local-first edge computing stack.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can fork this
|
||||||
|
<a
|
||||||
|
class="link link-primary whitespace-nowrap"
|
||||||
|
href="https://github.com/webnative-examples/webnative-app-template"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
template
|
||||||
|
<span class="-scale-x-100 scale-y-100 inline-block">⎋</span>
|
||||||
|
</a>
|
||||||
|
to start writing your own Webnative app. Learn more in the
|
||||||
|
<a
|
||||||
|
class="link link-primary whitespace-nowrap"
|
||||||
|
href="https://guide.fission.codes/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Webnative Guide
|
||||||
|
<span class="-scale-x-100 scale-y-100 inline-block">⎋</span>
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
import { filesystemStore, sessionStore } from '../../../stores'
|
||||||
|
import { setBackupStatus } from '$lib/auth/backup'
|
||||||
|
|
||||||
|
const skipBackup = () => {
|
||||||
|
setBackupStatus($filesystemStore, { created: false })
|
||||||
|
|
||||||
|
sessionStore.update(session => ({
|
||||||
|
...session,
|
||||||
|
backupCreated: false
|
||||||
|
}))
|
||||||
|
|
||||||
|
goto('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="are-you-sure-modal" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-8 text-base">Are you sure?</h3>
|
||||||
|
|
||||||
|
<p class="mb-7 text-left">
|
||||||
|
Without a backup device, if you lose this device or reset your browser,
|
||||||
|
you will not be able to recover your account data.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={() => goto('/delegate-account')}
|
||||||
|
>
|
||||||
|
Connect a backup device
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="text-error underline block mt-4 text-sm text-red-600 cursor-pointer"
|
||||||
|
on:click={skipBackup}
|
||||||
|
>
|
||||||
|
YOLO—I'll risk just one device for now
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
import type { BackupView } from '$lib/views'
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const navigate = (view: BackupView) => {
|
||||||
|
dispatch('navigate', { view })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="backup-modal" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<div id="backup-message" class="peer-checked:hidden">
|
||||||
|
<h3 class="mb-8 text-base">Backup your account</h3>
|
||||||
|
<p class="mb-5 text-left">
|
||||||
|
Your {appName} account & its data live only on your devices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="mb-8 text-left">
|
||||||
|
We highly recommend backing up your account on at least one additional
|
||||||
|
device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
on:click={() => goto('/delegate-account')}
|
||||||
|
>
|
||||||
|
Connect a backup device
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-link text-sm underline mt-4"
|
||||||
|
on:click={() => navigate('are-you-sure')}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
import type { ConnectView } from '$lib/views'
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const navigate = (view: ConnectView) => {
|
||||||
|
dispatch('navigate', { view })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="connect-modal" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<a href="/" class="btn btn-xs btn-circle absolute right-2 top-2">✕</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-7 text-base">Connect to {appName}</h3>
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-primary mb-5 w-full" href="/register">
|
||||||
|
Create a new account
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline w-full"
|
||||||
|
on:click={() => navigate('open-connected-device')}
|
||||||
|
>
|
||||||
|
I have an existing account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="open-connected-device-modal"
|
||||||
|
checked
|
||||||
|
class="modal-toggle"
|
||||||
|
/>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-96 sm:w-wideModal relative text-center">
|
||||||
|
<a href="/" class="btn btn-xs btn-circle absolute right-2 top-2">✕</a>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-8 text-base">Connect your existing account</h3>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-left mb-6">
|
||||||
|
To connect your existing account on this device, you’ll need a device
|
||||||
|
you are already connected on.
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-left">
|
||||||
|
On that device, click “Connect a new device” and follow the
|
||||||
|
instructions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import clipboardCopy from 'clipboard-copy'
|
||||||
|
|
||||||
|
import Share from '$components/icons/Share.svelte'
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
|
||||||
|
export let qrcode: HTMLOrSVGElement
|
||||||
|
export let connectionLink: string
|
||||||
|
export let backupCreated: boolean
|
||||||
|
|
||||||
|
const copyLink = async () => {
|
||||||
|
await clipboardCopy(connectionLink)
|
||||||
|
addNotification('Copied to clipboard', 'success')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="backup-device-modal" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-8 text-base">Connect a backup device</h3>
|
||||||
|
<div class="w-max m-auto mb-7 rounded-lg overflow-hidden">
|
||||||
|
{@html qrcode}
|
||||||
|
</div>
|
||||||
|
<p class="mb-7 text-left">
|
||||||
|
Scan this code on the new device, or share the connection link.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-outline" on:click={copyLink}>
|
||||||
|
<Share />
|
||||||
|
<span class="ml-2">Share connection link</span>
|
||||||
|
</button>
|
||||||
|
{#if !backupCreated}
|
||||||
|
<button
|
||||||
|
class="btn btn-xs btn-link text-sm font-normal underline mt-4"
|
||||||
|
on:click={() => goto('/backup?view=are-you-sure')}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class="btn btn-xs btn-link text-sm font-normal underline mt-4"
|
||||||
|
href="/"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
|
||||||
|
export let pinInput: string
|
||||||
|
export let pinError: boolean
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const cancelConnection = () => {
|
||||||
|
dispatch('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto submit the form when the pinInput is equal to the TARGET_PIN_LENGTH
|
||||||
|
*/
|
||||||
|
const TARGET_PIN_LENGTH = 6
|
||||||
|
const checkPin = () => {
|
||||||
|
if (pinInput.length === TARGET_PIN_LENGTH) {
|
||||||
|
dispatch('checkpin')
|
||||||
|
} else {
|
||||||
|
pinError = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="delegate-account-modal"
|
||||||
|
checked
|
||||||
|
class="modal-toggle"
|
||||||
|
/>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-8 text-base">
|
||||||
|
A new device would like to connect to your account
|
||||||
|
</h3>
|
||||||
|
<div class="mb-5">
|
||||||
|
<input
|
||||||
|
id="pin"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered w-full max-w-[197px] mb-2 rounded-full h-[68px] focus:outline-none font-mono text-deviceCode text-center tracking-[0.1em] font-light {pinError
|
||||||
|
? '!text-red-500 !border-red-500'
|
||||||
|
: ''}"
|
||||||
|
maxlength="6"
|
||||||
|
bind:value={pinInput}
|
||||||
|
on:keyup={checkPin}
|
||||||
|
/>
|
||||||
|
<label for="pin" class="label">
|
||||||
|
{#if !pinError}
|
||||||
|
<span class="label-text-alt">
|
||||||
|
Enter the connection code from that device to approve this
|
||||||
|
connection.
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="label-text-alt text-error">
|
||||||
|
Entered pin does not match a pin from a known device.
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-outline w-full" on:click={cancelConnection}>
|
||||||
|
Cancel Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from 'svelte'
|
||||||
|
import clipboardCopy from 'clipboard-copy'
|
||||||
|
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
|
||||||
|
export let pin: string
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const cancelConnection = () => {
|
||||||
|
dispatch('cancel')
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyCode = async () => {
|
||||||
|
await clipboardCopy(pin)
|
||||||
|
addNotification('Copied to clipboard', 'success')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div
|
||||||
|
class="modal-box w-narrowModal relative text-center dark:border-slate-600 dark:border"
|
||||||
|
>
|
||||||
|
<div class="grid grid-flow-row auto-rows-max gap-7">
|
||||||
|
<h3 class="text-base">Connect to {appName}</h3>
|
||||||
|
<div class="grid grid-flow-row auto-rows-max gap-4 justify-items-center">
|
||||||
|
{#if pin}
|
||||||
|
<span
|
||||||
|
on:click={copyCode}
|
||||||
|
class="btn text-base-100 hover:text-base-100 bg-base-content hover:bg-base-content border-0 btn-lg rounded-full text-deviceCode tracking-[.18em] w-3/4 cursor-pointer font-mono font-light"
|
||||||
|
>
|
||||||
|
{pin}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-sm text-left">
|
||||||
|
Enter this code on your connected device.
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="grid grid-flow-col auto-cols-max gap-4 justify-center items-center text-slate-500"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="rounded-lg border-t-2 border-l-2 border-slate-600 dark:border-slate-50 w-4 h-4 block animate-spin"
|
||||||
|
/>
|
||||||
|
Waiting for a response...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline text-base mt-4"
|
||||||
|
on:click={cancelConnection}
|
||||||
|
>
|
||||||
|
Cancel Request
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
import {
|
||||||
|
isUsernameValid,
|
||||||
|
isUsernameAvailable,
|
||||||
|
register
|
||||||
|
} from '$lib/auth/account'
|
||||||
|
import CheckIcon from '$components/icons/CheckIcon.svelte'
|
||||||
|
import XIcon from '$components/icons/XIcon.svelte'
|
||||||
|
import FilesystemActivity from '$components/common/FilesystemActivity.svelte'
|
||||||
|
|
||||||
|
let username: string = ''
|
||||||
|
let usernameValid = true
|
||||||
|
let usernameAvailable = true
|
||||||
|
let registrationSuccess = true
|
||||||
|
let checkingUsername = false
|
||||||
|
|
||||||
|
let initializingFilesystem = false
|
||||||
|
|
||||||
|
const checkUsername = async (event: Event) => {
|
||||||
|
const { value } = event.target as HTMLInputElement
|
||||||
|
|
||||||
|
username = value
|
||||||
|
checkingUsername = true
|
||||||
|
|
||||||
|
usernameValid = await isUsernameValid(username)
|
||||||
|
|
||||||
|
if (usernameValid) {
|
||||||
|
usernameAvailable = await isUsernameAvailable(username)
|
||||||
|
}
|
||||||
|
checkingUsername = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const registerUser = async () => {
|
||||||
|
if (checkingUsername) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
initializingFilesystem = true
|
||||||
|
|
||||||
|
registrationSuccess = await register(username)
|
||||||
|
|
||||||
|
if (!registrationSuccess) initializingFilesystem = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if initializingFilesystem}
|
||||||
|
<FilesystemActivity activity="Initializing" />
|
||||||
|
{:else}
|
||||||
|
<input type="checkbox" id="register-modal" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<a href="/" class="btn btn-xs btn-circle absolute right-2 top-2">✕</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-7 text-base">Choose a username</h3>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
id="registration"
|
||||||
|
type="text"
|
||||||
|
placeholder="Type here"
|
||||||
|
class="input input-bordered focus:outline-none w-full px-3 block"
|
||||||
|
class:input-error={username.length !== 0 &&
|
||||||
|
(!usernameValid || !usernameAvailable)}
|
||||||
|
on:input={checkUsername}
|
||||||
|
/>
|
||||||
|
{#if checkingUsername}
|
||||||
|
<span
|
||||||
|
class="rounded-lg border-t-2 border-l-2 border-base-content w-4 h-4 block absolute top-4 right-4 animate-spin"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{#if !(username.length === 0) && usernameAvailable && usernameValid && !checkingUsername}
|
||||||
|
<span class="w-4 h-4 block absolute top-[17px] right-4">
|
||||||
|
<CheckIcon />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if !(username.length === 0) && !checkingUsername && !(usernameAvailable && usernameValid)}
|
||||||
|
<span class="w-4 h-4 block absolute top-[17px] right-4">
|
||||||
|
<XIcon />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !(username.length === 0)}
|
||||||
|
<!-- Status of username: valid, available, etc -->
|
||||||
|
<label for="registration" class="label mt-1">
|
||||||
|
{#if usernameValid && usernameAvailable}
|
||||||
|
<span class="label-text-alt text-green-700 dark:text-green-500">
|
||||||
|
This username is available.
|
||||||
|
</span>
|
||||||
|
{:else if !usernameValid}
|
||||||
|
<span class="label-text-alt text-error">
|
||||||
|
This username is invalid.
|
||||||
|
</span>
|
||||||
|
{:else if !usernameAvailable}
|
||||||
|
<span class="label-text-alt text-error">
|
||||||
|
This username is unavailable.
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
{#if !registrationSuccess}
|
||||||
|
<!-- Error when registration fails -->
|
||||||
|
<label for="registration" class="label mt-1">
|
||||||
|
<span class="label-text-alt text-error text-left">
|
||||||
|
There was an issue registering your account. Please try again.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="text-left mt-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="shared-computer"
|
||||||
|
class="peer checkbox checkbox-primary border-2 border-base-content hover:border-orange-300 transition-colors duration-250 ease-in-out inline-grid align-bottom"
|
||||||
|
/>
|
||||||
|
<!-- Warning when "This is a shared computer" is checked -->
|
||||||
|
<label
|
||||||
|
for="shared-computer"
|
||||||
|
class="cursor-pointer ml-1 text-sm grid-inline"
|
||||||
|
>
|
||||||
|
This is a shared computer
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
for="registration"
|
||||||
|
class="label mt-1 hidden peer-checked:block"
|
||||||
|
>
|
||||||
|
<span class="label-text-alt text-error text-left">
|
||||||
|
For security reasons, {appName} doesn't support shared computers at
|
||||||
|
this time.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<a class="btn btn-outline" href="/connect">Back</a>
|
||||||
|
<button
|
||||||
|
class="ml-2 btn btn-primary disabled:opacity-50 disabled:border-neutral disabled:text-neutral"
|
||||||
|
disabled={username.length === 0 ||
|
||||||
|
!usernameValid ||
|
||||||
|
!usernameAvailable ||
|
||||||
|
checkingUsername}
|
||||||
|
on:click={registerUser}
|
||||||
|
>
|
||||||
|
Register
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
import { sessionStore } from '../../../stores'
|
||||||
|
import WelcomeCheckIcon from '$components/icons/WelcomeCheckIcon.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="link-device-modal" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-box w-narrowModal relative text-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-14 text-base">
|
||||||
|
Welcome, {$sessionStore.username}!
|
||||||
|
</h3>
|
||||||
|
<div class="flex justify-center mb-11 text-base-content">
|
||||||
|
<WelcomeCheckIcon />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="mb-4 text-left">Your account has been created.</p>
|
||||||
|
|
||||||
|
<div class="mb-8 text-left">
|
||||||
|
<input type="checkbox" id="password-message" class="peer hidden" />
|
||||||
|
<label
|
||||||
|
class="text-primary underline mb-8 hover:cursor-pointer peer-checked:hidden"
|
||||||
|
for="password-message"
|
||||||
|
>
|
||||||
|
Wait—what's my password?
|
||||||
|
</label>
|
||||||
|
<p class="hidden peer-checked:block">
|
||||||
|
You don't need a password! <br />
|
||||||
|
{appName} uses public key cryptography to authenticate you with this
|
||||||
|
device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a class="btn btn-primary" href="/backup">Continue</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let activity: 'Initializing' | 'Loading'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||||
|
<div class="modal">
|
||||||
|
<div
|
||||||
|
class="modal-box rounded-lg shadow-sm w-narrowModal relative text-center"
|
||||||
|
>
|
||||||
|
<p class="flex items-center justify-center text-base-content">
|
||||||
|
<span
|
||||||
|
class="rounded-lg border-t-2 border-l-2 border-base-content w-4 h-4 inline-block animate-spin mr-2"
|
||||||
|
/>
|
||||||
|
{activity} file system...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { sessionStore } from '../../stores'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-128px)] md:min-h-[calc(100vh-160px)] pt-8 md:pt-16 flex flex-col items-start max-w-[690px] m-auto gap-10 pb-5 text-sm"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl">Welcome, {$sessionStore.username}!</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start justify-center gap-5">
|
||||||
|
<h2 class="text-lg">Photo Gallery Demo</h2>
|
||||||
|
<p>
|
||||||
|
Webnative makes it easy to implement private, encrypted, user-owned
|
||||||
|
storage in your app. See it in action with our photo gallery demo.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-primary" href="/gallery">Try the Photo Gallery Demo</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start justify-center gap-5">
|
||||||
|
<h2 class="text-lg">Device Connection Demo</h2>
|
||||||
|
<p>
|
||||||
|
With Webnative SDK, a user’s account lives only on their connected devices
|
||||||
|
— entirely under their control. It’s easy for them to connect as many
|
||||||
|
devices as they’d like. For recoverability, we recommend they always
|
||||||
|
connect at least two.
|
||||||
|
</p>
|
||||||
|
<a class="btn btn-primary" href="/delegate-account">
|
||||||
|
Connect an additional device
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-96px)] flex flex-col items-start justify-center max-w-[700px] m-auto gap-6 pb-5 text-sm"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl">Welcome to the {appName}</h1>
|
||||||
|
|
||||||
|
<div class="max-w-[590px]">
|
||||||
|
<p class="mb-5">
|
||||||
|
Webnative SDK is a true local-first edge computing stack. Effortlessly
|
||||||
|
give your users:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="mb-6 pl-6 list-disc">
|
||||||
|
<li>
|
||||||
|
<span class="font-bold">modern, passwordless accounts</span>
|
||||||
|
, without a complex and costly cloud-native back-end
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-bold">user-controlled data</span>
|
||||||
|
, secured by default with our encrypted-at-rest file storage protocol
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="font-bold">local-first functionality</span>
|
||||||
|
, including the ability to work offline and collaborate across multiple devices
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<a class="btn btn-primary btn-sm !h-10" href="/connect">Connect</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M11 14h-1v-4H9m1-4h.01M19 10a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 266 B |
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="41" height="40" fill="none">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.8 0C2.111 0 .5 1.599.5 5.333v29.334C.5 38.255 2.156 40 5.8 40h29.15c3.674 0 5.301-1.678 5.301-5.333V5.333c0-3.67-1.658-5.333-5.3-5.333H5.8Zm3.82 26.397L11.407 8.2c.165-1.681-1.147-3.138-2.825-3.138-2.04 0-3.579 2.19-3.014 4.276.755 2.793 1.486 6.496 1.533 10.49.027 2.326-.207 4.576-.56 6.615-.354 2.05.202 4.187 1.37 5.81 1.167 1.62 2.912 2.687 4.924 2.687h1.037c2.546 0 4.61-2.077 4.61-4.638v-1.935c0-1.164-.84-2.155-1.982-2.339l-1.412-.228a1.018 1.018 0 0 0-1.174.917l-.008.092a2.158 2.158 0 0 1-2.144 1.969 2.161 2.161 0 0 1-2.142-2.38Zm8.862-5.777v-7.53c0-1.03.83-1.867 1.855-1.867s1.856.836 1.856 1.868v16.246c0 3.094 2.493 5.602 5.567 5.602h.237c3.995 0 6.975-4.28 6.242-8.41-.366-2.06-.62-4.339-.62-6.695 0-4.102.771-7.898 1.563-10.716.554-1.97-.9-4.057-2.825-4.057-1.55 0-2.772 1.33-2.65 2.885l1.446 18.476a2.178 2.178 0 0 1-2.163 2.355 2.175 2.175 0 0 1-2.16-1.984l-1.576-17.26c-.232-2.534-2.343-4.472-4.871-4.472-2.539 0-4.656 1.954-4.873 4.5l-1.046 10.886a2.022 2.022 0 0 0 1.715 2.177 2.017 2.017 0 0 0 2.303-2.004Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 15 10"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M14.2071 0.292893C14.5976 0.683417 14.5976 1.31658 14.2071 1.70711L6.20711 9.70711C5.81658 10.0976 5.18342 10.0976 4.79289 9.70711L0.792893 5.70711C0.402369 5.31658 0.402369 4.68342 0.792893 4.29289C1.18342 3.90237 1.81658 3.90237 2.20711 4.29289L5.5 7.58579L12.7929 0.292893C13.1834 -0.0976311 13.8166 -0.0976311 14.2071 0.292893Z"
|
||||||
|
fill="#16A34A"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 534 B |
|
|
@ -0,0 +1,21 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let color: string
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="28px"
|
||||||
|
height="28px"
|
||||||
|
viewBox="0 0 28 28"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
|
>
|
||||||
|
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g fill="none" fill-rule="nonzero">
|
||||||
|
<path
|
||||||
|
d="M4.03033009,13.4696699 C3.73743687,13.1767767 3.26256313,13.1767767 2.96966991,13.4696699 C2.6767767,13.7625631 2.6767767,14.2374369 2.96966991,14.5303301 L9.96966991,21.5303301 C10.2625631,21.8232233 10.7374369,21.8232233 11.0303301,21.5303301 L25.0303301,7.53033009 C25.3232233,7.23743687 25.3232233,6.76256313 25.0303301,6.46966991 C24.7374369,6.1767767 24.2625631,6.1767767 23.9696699,6.46966991 L10.5,19.9393398 L4.03033009,13.4696699 Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
<svg
|
||||||
|
width="19"
|
||||||
|
height="21"
|
||||||
|
viewBox="0 0 19 21"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.5 3.5H3.5C2.39543 3.5 1.5 4.39543 1.5 5.5V17.5C1.5 18.6046 2.39543 19.5 3.5 19.5H13.5C14.6046 19.5 15.5 18.6046 15.5 17.5V16.5M5.5 3.5C5.5 4.60457 6.39543 5.5 7.5 5.5H9.5C10.6046 5.5 11.5 4.60457 11.5 3.5M5.5 3.5C5.5 2.39543 6.39543 1.5 7.5 1.5H9.5C10.6046 1.5 11.5 2.39543 11.5 3.5M11.5 3.5H13.5C14.6046 3.5 15.5 4.39543 15.5 5.5V8.5M17.5 12.5H7.5M7.5 12.5L10.5 9.5M7.5 12.5L10.5 15.5"
|
||||||
|
stroke="#3B82F6"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 621 B |
|
|
@ -0,0 +1,19 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="none">
|
||||||
|
<rect
|
||||||
|
width="38"
|
||||||
|
height="38"
|
||||||
|
x="1"
|
||||||
|
y="1"
|
||||||
|
fill="#171717"
|
||||||
|
stroke="#FAFAFA"
|
||||||
|
stroke-width="2"
|
||||||
|
rx="19"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="#FAFAFA"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M28.354 23.354a9 9 0 0 1-11.708-11.708A9.003 9.003 0 0 0 20 29a9.003 9.003 0 0 0 8.354-5.646Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 432 B |
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="20" fill="none">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M20.317 2.37A19.791 19.791 0 0 0 15.432.855a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.271 18.271 0 0 0-5.487 0 12.643 12.643 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.737 19.737 0 0 0 3.677 2.37a.07.07 0 0 0-.032.027C.533 7.046-.32 11.58.099 16.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.296 1.226-1.995.021-.04.001-.09-.041-.105a13.109 13.109 0 0 1-1.872-.893.077.077 0 0 1-.008-.127c.126-.095.252-.193.372-.292a.074.074 0 0 1 .078-.01c3.927 1.793 8.18 1.793 12.061 0a.074.074 0 0 1 .079.01c.12.098.245.197.372.292.044.032.04.1-.006.127-.598.35-1.22.645-1.873.892a.077.077 0 0 0-.041.106c.36.699.772 1.363 1.225 1.994a.076.076 0 0 0 .084.028 19.834 19.834 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.06.06 0 0 0-.031-.03ZM8.02 13.33c-1.182 0-2.157-1.086-2.157-2.419S6.82 8.493 8.02 8.493c1.21 0 2.176 1.095 2.157 2.42 0 1.332-.956 2.418-2.157 2.418Zm7.975 0c-1.183 0-2.157-1.086-2.157-2.419s.955-2.419 2.157-2.419c1.21 0 2.176 1.095 2.157 2.42 0 1.332-.946 2.418-2.157 2.418Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2.5"
|
||||||
|
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6M15 3h6v6M10 14 21 3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 285 B |
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let classes: string = 'mb-3 w-10 h-10'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
aria-hidden="true"
|
||||||
|
class={classes}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M7 16a4 4 0 0 1-.88-7.903A5 5 0 1 1 15.9 6h.1a5 5 0 0 1 1 9.9M15 13l-3-3m0 0-3 3m3-3v12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="23" height="24" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2.5"
|
||||||
|
d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 500 B |
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="40" fill="none">
|
||||||
|
<path fill="currentColor" d="M8 8h32v2H8zM8 19h32v2H8zM8 30h32v2H8z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 157 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m1 10 2-2m0 0 7-7 7 7M3 8v10a1 1 0 0 0 1 1h3M17 8l2 2m-2-2v10a1 1 0 0 1-1 1h-3m-6 0a1 1 0 0 0 1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 0 1 1m-6 0h6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 360 B |
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let color: string
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="36"
|
||||||
|
height="36"
|
||||||
|
viewBox="0 0 36 36"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M19.8333 25.3333H18V18H16.1667M18 10.6667H18.0183M34.5 18C34.5 27.1127 27.1127 34.5 18 34.5C8.8873 34.5 1.5 27.1127 1.5 18C1.5 8.8873 8.8873 1.5 18 1.5C27.1127 1.5 34.5 8.8873 34.5 18Z"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" fill="none">
|
||||||
|
<rect
|
||||||
|
width="38"
|
||||||
|
height="38"
|
||||||
|
x="1"
|
||||||
|
y="1"
|
||||||
|
fill="#FAFAFA"
|
||||||
|
stroke="#171717"
|
||||||
|
stroke-width="2"
|
||||||
|
rx="19"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
stroke="#111827"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M20 11v1m0 16v1m9-9h-1m-16 0h-1m15.364 6.364-.707-.707M14.343 14.343l-.707-.707m12.728 0-.707.707M14.343 25.657l-.707.707M24 20a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 495 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="m1 13 4.586-4.586a2 2 0 0 1 2.828 0L13 13m-2-2 1.586-1.586a2 2 0 0 1 2.828 0L17 11m-6-6h.01M3 17h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2H3a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 370 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 3V1m0 2a2 2 0 1 0 0 4m0-4a2 2 0 1 1 0 4m-6 8a2 2 0 1 0 0-4m0 4a2 2 0 1 1 0-4m0 4v2m0-6V1m6 6v10m6-2a2 2 0 1 0 0-4m0 4a2 2 0 1 1 0-4m0 4v2m0-6V1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 353 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M6.684 11.342C6.886 10.938 7 10.482 7 10c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 1 1 0-2.684m0 2.684 6.632 3.316m-6.632-6 6.632-3.316m0 0a3 3 0 1 0 5.367-2.684 3 3 0 0 0-5.367 2.684Zm0 9.316a3 3 0 1 0 5.368 2.684 3 3 0 0 0-5.368-2.684Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 444 B |
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="21" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="#171717"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M10 8v2m0 4h.01m8.608-9.016A11.955 11.955 0 0 1 10 1.944a11.955 11.955 0 0 1-8.618 3.04A12.02 12.02 0 0 0 1 8c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
|
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let color: string
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="36"
|
||||||
|
height="32"
|
||||||
|
viewBox="0 0 36 32"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.9998 12.5V16.1667M17.9998 23.5H18.0182M5.29811 30.8333H30.7015C33.5241 30.8333 35.2883 27.7778 33.877 25.3333L21.1752 3.33333C19.7639 0.888889 16.2357 0.888889 14.8244 3.33333L2.12269 25.3333C0.711386 27.7778 2.47551 30.8333 5.29811 30.8333Z"
|
||||||
|
stroke={color}
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="79" height="56" fill="none">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M77.06 1.64a5.6 5.6 0 0 1 0 7.92l-44.8 44.8a5.6 5.6 0 0 1-7.92 0l-22.4-22.4a5.6 5.6 0 0 1 7.92-7.92L28.3 42.48 69.14 1.64a5.6 5.6 0 0 1 7.92 0Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 320 B |
|
|
@ -0,0 +1,14 @@
|
||||||
|
<svg
|
||||||
|
width="13"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 13 12"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M0.792893 0.292893C1.18342 -0.0976311 1.81658 -0.0976311 2.20711 0.292893L6.5 4.58579L10.7929 0.292893C11.1834 -0.0976311 11.8166 -0.0976311 12.2071 0.292893C12.5976 0.683417 12.5976 1.31658 12.2071 1.70711L7.91421 6L12.2071 10.2929C12.5976 10.6834 12.5976 11.3166 12.2071 11.7071C11.8166 12.0976 11.1834 12.0976 10.7929 11.7071L6.5 7.41421L2.20711 11.7071C1.81658 12.0976 1.18342 12.0976 0.792893 11.7071C0.402369 11.3166 0.402369 10.6834 0.792893 10.2929L5.08579 6L0.792893 1.70711C0.402369 1.31658 0.402369 0.683417 0.792893 0.292893Z"
|
||||||
|
fill="#DC2626"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 740 B |
|
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
export let color: string
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="15px"
|
||||||
|
height="15px"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M6.7929 7.49998L1.14645 1.85353L1.85356 1.14642L7.50001 6.79287L13.1465 1.14642L13.8536 1.85353L8.20711 7.49998L13.8536 13.1464L13.1465 13.8535L7.50001 8.20708L1.85356 13.8535L1.14645 13.1464L6.7929 7.49998Z"
|
||||||
|
fill={color}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<span
|
||||||
|
class="inline-block px-2 py-[2px] !no-underline font-medium text-center text-xs text-neutral bg-gradient-to-r from-orange-600 to-orange-300"
|
||||||
|
>
|
||||||
|
ALPHA
|
||||||
|
</span>
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
import { sessionStore } from '$src/stores'
|
||||||
|
import About from '$components/icons/About.svelte'
|
||||||
|
import AlphaTag from '$components/nav/AlphaTag.svelte'
|
||||||
|
import BrandLogo from '$components/icons/BrandLogo.svelte'
|
||||||
|
import BrandWordmark from '$components/icons/BrandWordmark.svelte'
|
||||||
|
import Home from '$components/icons/Home.svelte'
|
||||||
|
import PhotoGallery from '$components/icons/PhotoGallery.svelte'
|
||||||
|
import Settings from '$components/icons/Settings.svelte'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
label: 'Home',
|
||||||
|
href: '/',
|
||||||
|
icon: Home
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Photo Gallery Demo',
|
||||||
|
href: '/gallery/',
|
||||||
|
icon: PhotoGallery
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'About This Template',
|
||||||
|
href: '/about/',
|
||||||
|
icon: About
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Account Settings',
|
||||||
|
href: '/settings/',
|
||||||
|
icon: Settings
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
let checked = false
|
||||||
|
const handleCloseDrawer = (): void => {
|
||||||
|
checked = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Only render the nav if the user is authed and not in the connection flow -->
|
||||||
|
{#if $sessionStore.authed && !$page.url.pathname.match(/register|backup|delegate/)}
|
||||||
|
<div class="drawer drawer-mobile h-screen">
|
||||||
|
<input
|
||||||
|
id="sidebar-nav"
|
||||||
|
class="drawer-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked
|
||||||
|
/>
|
||||||
|
<div class="drawer-content flex flex-col">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div class="drawer-side">
|
||||||
|
<label
|
||||||
|
for="sidebar-nav"
|
||||||
|
class="drawer-overlay !bg-[#262626] !opacity-[.85]"
|
||||||
|
/>
|
||||||
|
<div class="menu p-4 overflow-y-auto w-80 bg-base-100 text-base-content">
|
||||||
|
<!-- Brand -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-3 cursor-pointer mb-8"
|
||||||
|
on:click={() => {
|
||||||
|
handleCloseDrawer()
|
||||||
|
goto('/')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<BrandLogo />
|
||||||
|
<BrandWordmark />
|
||||||
|
<AlphaTag />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Menu -->
|
||||||
|
<ul>
|
||||||
|
{#each navItems as item}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="flex items-center justify-start gap-2 font-bold text-sm text-base-content hover:text-base-100 bg-base-100 hover:bg-base-content ease-in-out duration-[250ms] {$page
|
||||||
|
.url.pathname === item.href
|
||||||
|
? '!text-base-100 !bg-base-content'
|
||||||
|
: ''}"
|
||||||
|
href={item.href}
|
||||||
|
on:click={handleCloseDrawer}
|
||||||
|
>
|
||||||
|
<svelte:component this={item.icon} />{item.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<slot />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { fade, fly } from 'svelte/transition'
|
||||||
|
|
||||||
|
import { themeStore } from '../../stores'
|
||||||
|
import type { Notification } from '$lib/notifications'
|
||||||
|
import CheckThinIcon from '$components/icons/CheckThinIcon.svelte'
|
||||||
|
import InfoThinIcon from '$components/icons/InfoThinIcon.svelte'
|
||||||
|
import WarningThinIcon from '$components/icons/WarningThinIcon.svelte'
|
||||||
|
import XThinIcon from '$components/icons/XThinIcon.svelte'
|
||||||
|
|
||||||
|
export let notification: Notification
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
info: {
|
||||||
|
component: InfoThinIcon,
|
||||||
|
props: {
|
||||||
|
color: '#1e3a8a'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
component: XThinIcon,
|
||||||
|
props: {
|
||||||
|
color: $themeStore === 'light' ? '#ffd6d7' : '#fec3c3'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
component: CheckThinIcon,
|
||||||
|
props: {
|
||||||
|
color: '#14532D'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
component: WarningThinIcon,
|
||||||
|
props: {
|
||||||
|
color: '#7c2d12'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
in:fly={{ y: 20, duration: 400 }}
|
||||||
|
out:fade
|
||||||
|
role="alert"
|
||||||
|
aria-live="assertive"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<div class="alert alert-{notification.type} text-sm mb-3 peer-last:mb-0">
|
||||||
|
<div>
|
||||||
|
<svelte:component
|
||||||
|
this={iconMap[notification.type].component}
|
||||||
|
{...iconMap[notification.type].props}
|
||||||
|
/>
|
||||||
|
<span class="pl-1">{@html notification.msg}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { flip } from 'svelte/animate'
|
||||||
|
|
||||||
|
import { notificationStore } from '../../stores'
|
||||||
|
import Notification from '$components/notifications/Notification.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $notificationStore.length}
|
||||||
|
<div class="fixed z-max right-4 bottom-8 flex flex-col justify-center">
|
||||||
|
{#each $notificationStore as notification (notification.id)}
|
||||||
|
<div animate:flip>
|
||||||
|
<Notification {notification} />
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { accountSettingsStore, sessionStore } from '$src/stores'
|
||||||
|
import { getAvatarFromWNFS } from '$lib/account-settings'
|
||||||
|
|
||||||
|
export let size = 'large'
|
||||||
|
|
||||||
|
const sizeClasses =
|
||||||
|
size === 'large'
|
||||||
|
? 'w-[88px] h-[88px] text-[40px]'
|
||||||
|
: 'w-[40px] h-[40px] text-sm'
|
||||||
|
|
||||||
|
const loaderSizeClasses =
|
||||||
|
size === 'large' ? 'w-[28px] h-[28px]' : 'w-[16px] h-[16px]'
|
||||||
|
|
||||||
|
getAvatarFromWNFS()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $accountSettingsStore.avatar}
|
||||||
|
{#if $accountSettingsStore.loading}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center object-cover rounded-full border-2 border-base-content {sizeClasses}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="animate-spin ease-linear rounded-full border-2 border-t-2 border-t-orange-300 border-base-content {loaderSizeClasses}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
class="object-cover rounded-full border-2 border-base-content {sizeClasses}"
|
||||||
|
src={$accountSettingsStore.avatar.src}
|
||||||
|
alt="User Avatar"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center bg-base-content text-base-100 uppercase font-bold rounded-full {sizeClasses}"
|
||||||
|
>
|
||||||
|
{$sessionStore.username[0]}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { getAvatarFromWNFS, uploadAvatarToWNFS } from '$lib/account-settings'
|
||||||
|
import Avatar from '$components/settings/Avatar.svelte'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle uploads made by interacting with the file input
|
||||||
|
*/
|
||||||
|
const handleFileInput: (file: File) => Promise<void> = async file => {
|
||||||
|
await uploadAvatarToWNFS(file)
|
||||||
|
|
||||||
|
// Refetch avatar and update accountSettingsStore
|
||||||
|
await getAvatarFromWNFS()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle a file uploaded directly through the file input
|
||||||
|
let files: FileList
|
||||||
|
$: if (files) {
|
||||||
|
const file = Array.from(files)[0]
|
||||||
|
handleFileInput(file)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3 class="text-lg mb-4">Avatar</h3>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Avatar />
|
||||||
|
|
||||||
|
<label for="upload-avatar" class="btn btn-outline">Upload a new avatar</label>
|
||||||
|
<input
|
||||||
|
bind:files
|
||||||
|
id="upload-avatar"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { themeStore } from '$src/stores'
|
||||||
|
import { storeTheme, type Theme } from '$lib/theme'
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{
|
||||||
|
label: 'Dark',
|
||||||
|
value: 'dark'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Light',
|
||||||
|
value: 'light'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
let selected = $themeStore
|
||||||
|
|
||||||
|
themeStore.subscribe((updatedTheme) => {
|
||||||
|
selected = updatedTheme
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
themeStore.set(newTheme)
|
||||||
|
storeTheme(newTheme)
|
||||||
|
}
|
||||||
|
|
||||||
|
$: if (selected) {
|
||||||
|
setTheme(selected)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h3 class="text-lg mb-4">Theme preference</h3>
|
||||||
|
|
||||||
|
{#each options as option}
|
||||||
|
<div class="form-control items-start">
|
||||||
|
<label class="label cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="theme-preference"
|
||||||
|
class="radio checked:bg-base-content"
|
||||||
|
value={option.value}
|
||||||
|
checked={selected === option.value}
|
||||||
|
bind:group={selected}
|
||||||
|
/>
|
||||||
|
<span class="label-text text-sm ml-2">{option.label}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'UncutSans';
|
||||||
|
src: url('/fonts/uncut-sans-regular-webfont.woff2') format('woff2'),
|
||||||
|
url('/fonts/uncut-sans-regular-webfont.woff') format('woff');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'UncutSans';
|
||||||
|
src: url('/fonts/uncut-sans-medium-webfont.woff2') format('woff2'),
|
||||||
|
url('/fonts/uncut-sans-medium-webfont.woff') format('woff');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'UncutSans';
|
||||||
|
src: url('/fonts/uncut-sans-bold-webfont.woff2') format('woff2'),
|
||||||
|
url('/fonts/uncut-sans-bold-webfont.woff') format('woff');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4, h5, h6 {
|
||||||
|
@apply font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, input {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button default styles */
|
||||||
|
.btn {
|
||||||
|
@apply font-medium;
|
||||||
|
@apply border-2;
|
||||||
|
@apply min-h-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-circle {
|
||||||
|
@apply text-base-100;
|
||||||
|
@apply bg-base-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply text-sm;
|
||||||
|
@apply text-base-content;
|
||||||
|
@apply border-base-content;
|
||||||
|
@apply bg-base-100;
|
||||||
|
@apply shadow-orange;
|
||||||
|
@apply h-11;
|
||||||
|
@apply px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply text-sm;
|
||||||
|
@apply text-neutral;
|
||||||
|
@apply border-neutral;
|
||||||
|
@apply shadow-orange;
|
||||||
|
@apply bg-gradient-to-r;
|
||||||
|
@apply from-orange-600;
|
||||||
|
@apply to-orange-300;
|
||||||
|
@apply h-11;
|
||||||
|
@apply px-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover, .btn-warning:hover {
|
||||||
|
@apply border-orange-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input default styles */
|
||||||
|
.input-bordered {
|
||||||
|
@apply text-base-content;
|
||||||
|
@apply border-2;
|
||||||
|
@apply border-base-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal default styles */
|
||||||
|
.modal-box {
|
||||||
|
@apply p-8;
|
||||||
|
@apply border-2;
|
||||||
|
@apply border-base-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Label default styles */
|
||||||
|
.label {
|
||||||
|
@apply px-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-text-alt {
|
||||||
|
@apply text-sm;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="@sveltejs/kit" />
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
import { get as getStore } from 'svelte/store'
|
||||||
|
import * as wn from 'webnative'
|
||||||
|
import * as uint8arrays from 'uint8arrays'
|
||||||
|
import type { CID } from 'multiformats/cid'
|
||||||
|
import type { PuttableUnixTree, File as WNFile } from 'webnative/fs/types'
|
||||||
|
import type { Metadata } from 'webnative/fs/metadata'
|
||||||
|
|
||||||
|
import { accountSettingsStore, filesystemStore } from '$src/stores'
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
|
||||||
|
export type Avatar = {
|
||||||
|
cid: string
|
||||||
|
ctime: number
|
||||||
|
name: string
|
||||||
|
size?: number
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountSettings = {
|
||||||
|
avatar: Avatar
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
interface AvatarFile extends PuttableUnixTree, WNFile {
|
||||||
|
cid: CID
|
||||||
|
content: Uint8Array
|
||||||
|
header: {
|
||||||
|
content: Uint8Array
|
||||||
|
metadata: Metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ACCOUNT_SETTINGS_DIR = ['private', 'settings']
|
||||||
|
const AVATAR_DIR = [...ACCOUNT_SETTINGS_DIR, 'avatars']
|
||||||
|
const AVATAR_ARCHIVE_DIR = [...AVATAR_DIR, 'archive']
|
||||||
|
const AVATAR_FILE_NAME = 'avatar'
|
||||||
|
const FILE_SIZE_LIMIT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move old avatar to the archive directory
|
||||||
|
*/
|
||||||
|
const archiveOldAvatar = async (): Promise<void> => {
|
||||||
|
const fs = getStore(filesystemStore)
|
||||||
|
|
||||||
|
// Return if user has not uploaded an avatar yet
|
||||||
|
const avatarDirExists = await fs.exists(wn.path.file(...AVATAR_DIR))
|
||||||
|
if (!avatarDirExists) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the filename of the old avatar
|
||||||
|
const path = wn.path.directory(...AVATAR_DIR)
|
||||||
|
const links = await fs.ls(path)
|
||||||
|
const oldAvatarFileName = Object.keys(links).find(key =>
|
||||||
|
key.includes(AVATAR_FILE_NAME)
|
||||||
|
)
|
||||||
|
const oldFileNameArray = oldAvatarFileName.split('.')[0]
|
||||||
|
const archiveFileName = `${oldFileNameArray[0]}-${Date.now()}.${
|
||||||
|
oldFileNameArray[1]
|
||||||
|
}`
|
||||||
|
|
||||||
|
// Move old avatar to archive dir
|
||||||
|
const fromPath = wn.path.file(...AVATAR_DIR, oldAvatarFileName)
|
||||||
|
const toPath = wn.path.file(...AVATAR_ARCHIVE_DIR, archiveFileName)
|
||||||
|
await fs.mv(fromPath, toPath)
|
||||||
|
|
||||||
|
// Announce the changes to the server
|
||||||
|
await fs.publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Avatar from the user's WNFS and construct its `src`
|
||||||
|
*/
|
||||||
|
export const getAvatarFromWNFS = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Set loading: true on the accountSettingsStore
|
||||||
|
accountSettingsStore.update(store => ({ ...store, loading: true }))
|
||||||
|
|
||||||
|
const fs = getStore(filesystemStore)
|
||||||
|
|
||||||
|
// If the avatar dir doesn't exist, silently fail and let the UI handle it
|
||||||
|
const avatarDirExists = await fs.exists(wn.path.file(...AVATAR_DIR))
|
||||||
|
if (!avatarDirExists) {
|
||||||
|
accountSettingsStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
loading: false
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the file that matches the AVATAR_FILE_NAME
|
||||||
|
const path = wn.path.directory(...AVATAR_DIR)
|
||||||
|
const links = await fs.ls(path)
|
||||||
|
const avatarName = Object.keys(links).find(key =>
|
||||||
|
key.includes(AVATAR_FILE_NAME)
|
||||||
|
)
|
||||||
|
|
||||||
|
// If user has not uploaded an avatar, silently fail and let the UI handle it
|
||||||
|
if (!avatarName) {
|
||||||
|
accountSettingsStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
loading: false
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await fs.get(wn.path.file(...AVATAR_DIR, `${avatarName}`))
|
||||||
|
|
||||||
|
// The CID for private files is currently located in `file.header.content`
|
||||||
|
const cid = (file as AvatarFile).header.content.toString()
|
||||||
|
|
||||||
|
// Create a base64 string to use as the image `src`
|
||||||
|
const src = `data:image/jpeg;base64, ${uint8arrays.toString(
|
||||||
|
(file as AvatarFile).content,
|
||||||
|
'base64'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
const avatar = {
|
||||||
|
cid,
|
||||||
|
ctime: (file as AvatarFile).header.metadata.unixMeta.ctime,
|
||||||
|
name: avatarName,
|
||||||
|
src
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push images to the accountSettingsStore
|
||||||
|
accountSettingsStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
avatar,
|
||||||
|
loading: false
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
accountSettingsStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
avatar: null,
|
||||||
|
loading: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload an avatar image to the user's private WNFS
|
||||||
|
* @param image
|
||||||
|
*/
|
||||||
|
export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Set loading: true on the accountSettingsStore
|
||||||
|
accountSettingsStore.update(store => ({ ...store, loading: true }))
|
||||||
|
|
||||||
|
const fs = getStore(filesystemStore)
|
||||||
|
|
||||||
|
// Reject files over 5MB
|
||||||
|
const imageSizeInMB = image.size / (1024 * 1024)
|
||||||
|
if (imageSizeInMB > FILE_SIZE_LIMIT) {
|
||||||
|
throw new Error('Image can be no larger than 5MB')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive old avatar
|
||||||
|
await archiveOldAvatar()
|
||||||
|
|
||||||
|
// Rename the file to `avatar.[extension]`
|
||||||
|
const updatedImage = new File(
|
||||||
|
[image],
|
||||||
|
`${AVATAR_FILE_NAME}.${image.name.split('.')[1]}`,
|
||||||
|
{
|
||||||
|
type: image.type
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create a sub directory and add the avatar
|
||||||
|
await fs.write(wn.path.file(...AVATAR_DIR, updatedImage.name), updatedImage)
|
||||||
|
|
||||||
|
// Announce the changes to the server
|
||||||
|
await fs.publish()
|
||||||
|
|
||||||
|
addNotification(`Your avatar has been updated!`, 'success')
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(error.message, 'error')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
export const appName = 'Webnative SDK Demo'
|
||||||
|
export const appDescription = 'This is another awesome Webnative app.'
|
||||||
|
export const appURL = 'https://webnative.netlify.app'
|
||||||
|
export const appImageURL = `${appURL}/preview.png`
|
||||||
|
export const ipfsGatewayUrl = 'runfission.com'
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import * as webnative from 'webnative'
|
||||||
|
import type FileSystem from 'webnative/fs/index'
|
||||||
|
|
||||||
|
import { asyncDebounce } from '$lib/utils'
|
||||||
|
import { filesystemStore, sessionStore } from '../../stores'
|
||||||
|
import { getBackupStatus } from '$lib/auth/backup'
|
||||||
|
import { ACCOUNT_SETTINGS_DIR } from '$lib/account-settings'
|
||||||
|
import { AREAS } from '$routes/gallery/stores'
|
||||||
|
import { GALLERY_DIRS } from '$routes/gallery/lib/gallery'
|
||||||
|
|
||||||
|
export const isUsernameValid = async (username: string): Promise<boolean> => {
|
||||||
|
return webnative.account.isUsernameValid(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedIsUsernameAvailable = asyncDebounce(
|
||||||
|
webnative.account.isUsernameAvailable,
|
||||||
|
300
|
||||||
|
)
|
||||||
|
|
||||||
|
export const isUsernameAvailable = async (
|
||||||
|
username: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
return debouncedIsUsernameAvailable(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const register = async (username: string): Promise<boolean> => {
|
||||||
|
const { success } = await webnative.account.register({ username })
|
||||||
|
|
||||||
|
if (!success) return success
|
||||||
|
|
||||||
|
const fs = await webnative.bootstrapRootFileSystem()
|
||||||
|
filesystemStore.set(fs)
|
||||||
|
|
||||||
|
// TODO Remove if only public and private directories are needed
|
||||||
|
await initializeFilesystem(fs)
|
||||||
|
|
||||||
|
sessionStore.update(session => ({
|
||||||
|
...session,
|
||||||
|
username,
|
||||||
|
authed: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
return success
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create additional directories and files needed by the app
|
||||||
|
*
|
||||||
|
* @param fs FileSystem
|
||||||
|
*/
|
||||||
|
const initializeFilesystem = async (fs: FileSystem): Promise<void> => {
|
||||||
|
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PUBLIC]))
|
||||||
|
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PRIVATE]))
|
||||||
|
await fs.mkdir(webnative.path.directory(...ACCOUNT_SETTINGS_DIR))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadAccount = async (username: string): Promise<void> => {
|
||||||
|
await checkDataRoot(username)
|
||||||
|
|
||||||
|
const fs = await webnative.loadRootFileSystem()
|
||||||
|
filesystemStore.set(fs)
|
||||||
|
|
||||||
|
const backupStatus = await getBackupStatus(fs)
|
||||||
|
|
||||||
|
sessionStore.update(session => ({
|
||||||
|
...session,
|
||||||
|
username,
|
||||||
|
authed: true,
|
||||||
|
backupCreated: backupStatus.created
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkDataRoot = async (username: string): Promise<void> => {
|
||||||
|
let dataRoot = await webnative.dataRoot.lookup(username)
|
||||||
|
|
||||||
|
if (dataRoot) return
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const maxRetries = 20
|
||||||
|
let attempt = 0
|
||||||
|
|
||||||
|
const dataRootInterval = setInterval(async () => {
|
||||||
|
console.warn('Could not fetch filesystem data root. Retrying.')
|
||||||
|
|
||||||
|
dataRoot = await webnative.dataRoot.lookup(username)
|
||||||
|
|
||||||
|
if (!dataRoot && attempt < maxRetries) {
|
||||||
|
attempt++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(dataRootInterval)
|
||||||
|
resolve()
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import * as webnative from 'webnative'
|
||||||
|
import type FileSystem from 'webnative/fs/index'
|
||||||
|
|
||||||
|
export type BackupStatus = { created: boolean } | null
|
||||||
|
|
||||||
|
export const setBackupStatus = async (fs: FileSystem, status: BackupStatus): Promise<void> => {
|
||||||
|
const backupStatusPath = webnative.path.file('private', 'backup-status.json')
|
||||||
|
await fs.write(backupStatusPath, JSON.stringify(status))
|
||||||
|
await fs.publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getBackupStatus = async (fs: FileSystem): Promise<BackupStatus> => {
|
||||||
|
const backupStatusPath = webnative.path.file('private', 'backup-status.json')
|
||||||
|
|
||||||
|
if (await fs.exists(backupStatusPath)) {
|
||||||
|
const fileContent = await fs.read(backupStatusPath)
|
||||||
|
|
||||||
|
if (typeof fileContent === 'string') {
|
||||||
|
return JSON.parse(fileContent) as BackupStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Unable to load backup status')
|
||||||
|
|
||||||
|
return { created: false }
|
||||||
|
} else {
|
||||||
|
return { created: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import * as webnative from 'webnative'
|
||||||
|
import type { account } from 'webnative'
|
||||||
|
|
||||||
|
export const createAccountLinkingConsumer = async (
|
||||||
|
username: string
|
||||||
|
): Promise<account.AccountLinkingConsumer> => {
|
||||||
|
return await webnative.account.createConsumer({ username })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createAccountLinkingProducer = async (
|
||||||
|
username: string
|
||||||
|
): Promise<account.AccountLinkingProducer> => {
|
||||||
|
return await webnative.account.createProducer({ username })
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import * as webnative from 'webnative'
|
||||||
|
import { setup } from 'webnative'
|
||||||
|
|
||||||
|
import { filesystemStore, sessionStore } from '../stores'
|
||||||
|
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
|
||||||
|
|
||||||
|
// TODO: Add a flag or script to turn debugging on/off
|
||||||
|
setup.debug({ enabled: false })
|
||||||
|
|
||||||
|
export const initialize = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
let backupStatus: BackupStatus = null
|
||||||
|
|
||||||
|
const state: webnative.AppState = await webnative.app({ useWnfs: true })
|
||||||
|
|
||||||
|
switch (state.scenario) {
|
||||||
|
case webnative.AppScenario.NotAuthed:
|
||||||
|
sessionStore.set({
|
||||||
|
username: '',
|
||||||
|
authed: false,
|
||||||
|
loading: false,
|
||||||
|
backupCreated: null
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
case webnative.AppScenario.Authed:
|
||||||
|
backupStatus = await getBackupStatus(state.fs)
|
||||||
|
|
||||||
|
sessionStore.set({
|
||||||
|
username: state.username,
|
||||||
|
authed: state.authenticated,
|
||||||
|
loading: false,
|
||||||
|
backupCreated: backupStatus.created
|
||||||
|
})
|
||||||
|
|
||||||
|
filesystemStore.set(state.fs)
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
switch (error) {
|
||||||
|
case webnative.InitialisationError.InsecureContext:
|
||||||
|
sessionStore.update(session => ({
|
||||||
|
...session,
|
||||||
|
loading: false,
|
||||||
|
error: 'Insecure Context'
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
|
||||||
|
case webnative.InitialisationError.UnsupportedBrowser:
|
||||||
|
sessionStore.update(session => ({
|
||||||
|
...session,
|
||||||
|
loading: false,
|
||||||
|
error: 'Unsupported Browser'
|
||||||
|
}))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { notificationStore } from '../stores'
|
||||||
|
|
||||||
|
export type Notification = {
|
||||||
|
id?: string
|
||||||
|
msg?: string
|
||||||
|
type?: NotificationType
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationType = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
|
export const removeNotification: (id: string) => void = id => {
|
||||||
|
notificationStore.update(all =>
|
||||||
|
all.filter(notification => notification.id !== id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addNotification: (
|
||||||
|
msg: string,
|
||||||
|
type?: NotificationType,
|
||||||
|
timeout?: number
|
||||||
|
) => void = (msg, type = 'info', timeout = 5000) => {
|
||||||
|
// uuid for each notification
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
|
||||||
|
// adding new notifications to the bottom of the list so they stack from bottom to top
|
||||||
|
notificationStore.update(rest => [
|
||||||
|
...rest,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
msg,
|
||||||
|
type,
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// removing the notification after a specified timeout
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
removeNotification(id)
|
||||||
|
clearTimeout(timer)
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
// return the id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { appName } from '$lib/app-info'
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
username: string
|
||||||
|
authed: boolean
|
||||||
|
loading: boolean
|
||||||
|
backupCreated: boolean
|
||||||
|
error?: SessionError
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionError = 'Insecure Context' | 'Unsupported Browser'
|
||||||
|
|
||||||
|
export const errorToMessage = (error: SessionError): string => {
|
||||||
|
switch (error) {
|
||||||
|
case 'Insecure Context':
|
||||||
|
return `${appName} requires a secure context (HTTPS)`
|
||||||
|
|
||||||
|
case 'Unsupported Browser':
|
||||||
|
return `Your browser does not support ${appName}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import { browser } from '$app/environment'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'default'
|
||||||
|
|
||||||
|
export const getSystemDefaultTheme = (): Theme =>
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
|
||||||
|
export const loadTheme = (): Theme => {
|
||||||
|
if (browser) {
|
||||||
|
const browserTheme = localStorage.getItem('theme') as Theme
|
||||||
|
const osTheme = getSystemDefaultTheme()
|
||||||
|
|
||||||
|
return browserTheme ?? (osTheme as Theme) ?? 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storeTheme = (theme: Theme): void => {
|
||||||
|
if (browser) {
|
||||||
|
localStorage.setItem('theme', theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
export function asyncDebounce<A extends unknown[], R>(
|
||||||
|
fn: (...args: A) => Promise<R>,
|
||||||
|
wait: number
|
||||||
|
): (...args: A) => Promise<R> {
|
||||||
|
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||||
|
|
||||||
|
return (...args: A): Promise<R> => {
|
||||||
|
clearTimeout(lastTimeoutId)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const currentTimeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (currentTimeoutId === lastTimeoutId) {
|
||||||
|
const result = await fn(...args)
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
}, wait)
|
||||||
|
|
||||||
|
lastTimeoutId = currentTimeoutId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const extractSearchParam = (url: URL, param: string): string | null => {
|
||||||
|
const val = url.searchParams.get(param)
|
||||||
|
|
||||||
|
// clear the param from the URL
|
||||||
|
url.searchParams.delete(param)
|
||||||
|
history.replaceState(null, document.title, url.toString())
|
||||||
|
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export type BackupView = 'backup' | 'are-you-sure'
|
||||||
|
|
||||||
|
export type ConnectView = 'connect' | 'open-connected-device'
|
||||||
|
|
||||||
|
export type DelegateAccountView = 'connect-backup-device' | 'delegate-account'
|
||||||
|
|
||||||
|
export type LinkDeviceView = 'link-device' | 'load-filesystem'
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Discord from '$components/icons/Discord.svelte'
|
||||||
|
import ExternalLink from '$components/icons/ExternalLink.svelte'
|
||||||
|
import Github from '$components/icons/Github.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-96px)] flex flex-col items-center justify-center max-w-[700px] m-auto gap-6 pb-5 text-sm"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl">404 - Page not found</h1>
|
||||||
|
|
||||||
|
<p>The page you have requested does not exist.</p>
|
||||||
|
|
||||||
|
<img
|
||||||
|
class="relative z-0 w-[227px] h-[227px] rounded-full border-[16px] border-base-content"
|
||||||
|
src={`${window.location.origin}/wn-404.gif`}
|
||||||
|
alt={`Circle animation`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-6">
|
||||||
|
<a
|
||||||
|
class="flex items-center justify-center gap-2 font-bold text-sm text-base-content"
|
||||||
|
href="https://guide.fission.codes/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Docs <ExternalLink />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="flex items-center justify-center gap-2 font-bold text-sm text-base-content"
|
||||||
|
href="https://github.com/webnative-examples/"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Github <Github />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="flex items-center justify-center gap-2 font-bold text-sm text-base-content"
|
||||||
|
href="https://fission.codes/discord"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Discord <Discord />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const csr = true
|
||||||
|
export const ssr = false
|
||||||
|
export const prerender = true
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import '../global.css'
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
import { appDescription, appImageURL, appName, appURL } from '$lib/app-info'
|
||||||
|
import { sessionStore, themeStore } from '../stores'
|
||||||
|
import { errorToMessage } from '$lib/session'
|
||||||
|
import { initialize } from '$lib/init'
|
||||||
|
import Footer from '$components/Footer.svelte'
|
||||||
|
import Header from '$components/Header.svelte'
|
||||||
|
import Notifications from '$components/notifications/Notifications.svelte'
|
||||||
|
import SidebarNav from '$components/nav/SidebarNav.svelte'
|
||||||
|
|
||||||
|
sessionStore.subscribe(session => {
|
||||||
|
if (session.error) {
|
||||||
|
const message = errorToMessage(session.error)
|
||||||
|
addNotification(message, 'error')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
await initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{appName}</title>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="robots" content="index,follow" />
|
||||||
|
<meta name="googlebot" content="index,follow" />
|
||||||
|
<meta name="description" content={appDescription} />
|
||||||
|
<meta property="og:title" content={appName} />
|
||||||
|
<meta property="og:description" content={appDescription} />
|
||||||
|
<meta property="og:url" content={appURL} />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:image" content={appImageURL} />
|
||||||
|
<meta property="og:image:alt" content="WebNative Template" />
|
||||||
|
<meta property="og:image:width" content="1250" />
|
||||||
|
<meta property="og:image:height" content="358" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={appName} />
|
||||||
|
<meta name="twitter:description" content={appDescription} />
|
||||||
|
<meta name="twitter:image" content={appImageURL} />
|
||||||
|
<meta name="twitter:image:alt" content={appName} />
|
||||||
|
|
||||||
|
<!-- See https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs for description. -->
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div data-theme={$themeStore} class="min-h-screen">
|
||||||
|
<Notifications />
|
||||||
|
<SidebarNav>
|
||||||
|
<Header />
|
||||||
|
<div class="px-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</SidebarNav>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { sessionStore } from '../stores'
|
||||||
|
import Authed from '$components/home/Authed.svelte'
|
||||||
|
import Public from '$components/home/Public.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $sessionStore?.authed}
|
||||||
|
<Authed />
|
||||||
|
{:else}
|
||||||
|
<Public />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import AboutThisTemplate from '$components/about/AboutThisTemplate.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AboutThisTemplate />
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
|
import { extractSearchParam } from '$lib/utils'
|
||||||
|
import type { BackupView } from '$lib/views'
|
||||||
|
import AreYouSure from '$components/auth/backup/AreYouSure.svelte'
|
||||||
|
import Backup from '$components/auth/backup/Backup.svelte'
|
||||||
|
|
||||||
|
let view = extractSearchParam($page.url, 'view') ?? 'backup'
|
||||||
|
|
||||||
|
const navigate = (event: CustomEvent<{ view: BackupView }>) => {
|
||||||
|
view = event.detail.view
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view === 'backup'}
|
||||||
|
<Backup on:navigate={navigate} />
|
||||||
|
{:else if view === 'are-you-sure'}
|
||||||
|
<AreYouSure />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ConnectView } from '$lib/views'
|
||||||
|
import Connect from '$components/auth/connect/Connect.svelte'
|
||||||
|
import OpenConnectedDevice from '$components/auth/connect/OpenConnectedDevice.svelte'
|
||||||
|
|
||||||
|
let view: ConnectView = 'connect'
|
||||||
|
|
||||||
|
const navigate = (event: CustomEvent<{ view: ConnectView }>) => {
|
||||||
|
view = event.detail.view
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view === 'connect'}
|
||||||
|
<Connect on:navigate={navigate} />
|
||||||
|
{:else if view === 'open-connected-device'}
|
||||||
|
<OpenConnectedDevice />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { onDestroy, onMount } from 'svelte'
|
||||||
|
import QRCode from 'qrcode-svg'
|
||||||
|
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
import { createAccountLinkingProducer } from '$lib/auth/linking'
|
||||||
|
import { filesystemStore, sessionStore, themeStore } from '../../stores'
|
||||||
|
import { getBackupStatus, setBackupStatus } from '$lib/auth/backup'
|
||||||
|
import ConnectBackupDevice from '$components/auth/delegate-account/ConnectBackupDevice.svelte'
|
||||||
|
import DelegateAccount from '$components/auth/delegate-account/DelegateAccount.svelte'
|
||||||
|
|
||||||
|
import type { DelegateAccountView } from '$lib/views'
|
||||||
|
|
||||||
|
let view: DelegateAccountView = 'connect-backup-device'
|
||||||
|
|
||||||
|
let connectionLink = null
|
||||||
|
let qrcode = null
|
||||||
|
|
||||||
|
let fs = null
|
||||||
|
let backupCreated = true
|
||||||
|
|
||||||
|
let pin: number[]
|
||||||
|
let pinInput = ''
|
||||||
|
let pinError = false
|
||||||
|
let confirmPin = () => {}
|
||||||
|
let rejectPin = () => {}
|
||||||
|
|
||||||
|
let unsubscribeFilesystemStore = () => {}
|
||||||
|
let unsubscribeSessionStore = () => {}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
unsubscribeFilesystemStore = filesystemStore.subscribe(async val => {
|
||||||
|
fs = val
|
||||||
|
|
||||||
|
if (fs) {
|
||||||
|
const backupStatus = await getBackupStatus(fs)
|
||||||
|
backupCreated = backupStatus.created
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
unsubscribeSessionStore = sessionStore.subscribe(async val => {
|
||||||
|
const username = val.username
|
||||||
|
|
||||||
|
if (username) {
|
||||||
|
const origin = window.location.origin
|
||||||
|
|
||||||
|
connectionLink = `${origin}/link-device?username=${username}`
|
||||||
|
qrcode = new QRCode({
|
||||||
|
content: connectionLink,
|
||||||
|
color: $themeStore === 'light' ? '#171717' : '#FAFAFA',
|
||||||
|
background: $themeStore === 'light' ? '#FAFAFA' : '#171717',
|
||||||
|
padding: 0,
|
||||||
|
width: 216,
|
||||||
|
height: 216
|
||||||
|
}).svg()
|
||||||
|
|
||||||
|
initAccountLinkingProducer(username)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const initAccountLinkingProducer = async (username: string) => {
|
||||||
|
const accountLinkingProducer = await createAccountLinkingProducer(username)
|
||||||
|
|
||||||
|
accountLinkingProducer.on('challenge', detail => {
|
||||||
|
pin = detail.pin
|
||||||
|
confirmPin = detail.confirmPin
|
||||||
|
rejectPin = detail.rejectPin
|
||||||
|
|
||||||
|
view = 'delegate-account'
|
||||||
|
})
|
||||||
|
|
||||||
|
accountLinkingProducer.on('link', async ({ approved }) => {
|
||||||
|
if (approved) {
|
||||||
|
sessionStore.update(session => ({
|
||||||
|
...session,
|
||||||
|
backupCreated: true
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (fs) {
|
||||||
|
await setBackupStatus(fs, { created: true })
|
||||||
|
|
||||||
|
addNotification("You've connected a backup device!", 'success')
|
||||||
|
goto('/')
|
||||||
|
} else {
|
||||||
|
addNotification(
|
||||||
|
'Missing filesystem. Unable to create a backup device.',
|
||||||
|
'error'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkPin = () => {
|
||||||
|
if (pin.join('') === pinInput) {
|
||||||
|
confirmPin()
|
||||||
|
} else {
|
||||||
|
pinError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelConnection = () => {
|
||||||
|
rejectPin()
|
||||||
|
|
||||||
|
addNotification('The connection attempt was cancelled', 'info')
|
||||||
|
goto('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeFilesystemStore()
|
||||||
|
unsubscribeSessionStore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if view === 'connect-backup-device'}
|
||||||
|
<ConnectBackupDevice {qrcode} {connectionLink} {backupCreated} />
|
||||||
|
{:else if view === 'delegate-account'}
|
||||||
|
<DelegateAccount
|
||||||
|
bind:pinInput
|
||||||
|
bind:pinError
|
||||||
|
on:cancel={cancelConnection}
|
||||||
|
on:checkpin={checkPin}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
|
||||||
|
import { sessionStore } from '$src/stores'
|
||||||
|
import { AREAS, galleryStore } from '$routes/gallery/stores'
|
||||||
|
import Dropzone from '$routes/gallery/components/upload/Dropzone.svelte'
|
||||||
|
import ImageGallery from '$routes/gallery/components/imageGallery/ImageGallery.svelte'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tab between the public/private areas and load associated images
|
||||||
|
* @param area
|
||||||
|
*/
|
||||||
|
const handleChangeTab: (area: AREAS) => void = area =>
|
||||||
|
galleryStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
selectedArea: area
|
||||||
|
}))
|
||||||
|
|
||||||
|
// If the user is not authed redirect them to the home page
|
||||||
|
const unsubscribe = sessionStore.subscribe(newState => {
|
||||||
|
if (!newState.loading && !newState.authed) {
|
||||||
|
goto('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(unsubscribe)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="p-2 mb-14 text-center">
|
||||||
|
{#if $sessionStore.authed}
|
||||||
|
<div class="flex items-center justify-center translate-y-1/2 w-fit m-auto">
|
||||||
|
<div class="tabs border-2 overflow-hidden border-base-content rounded-lg">
|
||||||
|
{#each Object.keys(AREAS) as area}
|
||||||
|
<button
|
||||||
|
on:click={() => handleChangeTab(AREAS[area])}
|
||||||
|
class="tab h-10 font-bold text-sm ease-in {$galleryStore.selectedArea ===
|
||||||
|
AREAS[area]
|
||||||
|
? 'tab-active bg-base-content text-base-100'
|
||||||
|
: 'bg-base-100 text-base-content'}"
|
||||||
|
>
|
||||||
|
{AREAS[area]} Photos
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dropzone>
|
||||||
|
<ImageGallery />
|
||||||
|
</Dropzone>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="66" fill="none">
|
||||||
|
<path
|
||||||
|
stroke="#171717"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M14.167 45.667C7.17 45.667 1.5 39.996 1.5 33c0-6.039 4.226-11.09 9.882-12.36A15.89 15.89 0 0 1 11 17.167c0-8.745 7.089-15.834 15.833-15.834 7.662 0 14.052 5.441 15.518 12.67.105-.002.21-.003.316-.003C51.41 14 58.5 21.089 58.5 29.833c0 7.66-5.44 14.05-12.667 15.517M39.5 36.167l-9.5-9.5m0 0-9.5 9.5m9.5-9.5v38"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 510 B |
|
|
@ -0,0 +1,24 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Image } from '$routes/gallery/lib/gallery'
|
||||||
|
|
||||||
|
export let image: Image
|
||||||
|
export let openModal: (image: Image) => void
|
||||||
|
|
||||||
|
const handleOpenModal = () => openModal(image)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative group w-full aspect-[22/23] rounded-lg border-2 border-transparent hover:border-base-content box-border overflow-hidden transition-colors ease-in"
|
||||||
|
on:click={handleOpenModal}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center absolute z-10 top-0 right-0 bottom-0 left-0 bg-[#00000035] opacity-0 group-hover:opacity-100 transition-opacity ease-in"
|
||||||
|
/>
|
||||||
|
<div class="relative pb-[105%]">
|
||||||
|
<img
|
||||||
|
class="absolute block object-cover object-center w-full h-full"
|
||||||
|
alt={`Gallery Image: ${image.name}`}
|
||||||
|
src={image.src}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
|
||||||
|
import { filesystemStore, sessionStore } from '$src/stores'
|
||||||
|
import { AREAS, galleryStore } from '$routes/gallery/stores'
|
||||||
|
import { getImagesFromWNFS, type Image } from '$routes/gallery/lib/gallery'
|
||||||
|
import FileUploadCard from '$routes/gallery/components/upload/FileUploadCard.svelte'
|
||||||
|
import ImageCard from '$routes/gallery/components/imageGallery/ImageCard.svelte'
|
||||||
|
import ImageModal from '$routes/gallery/components/imageGallery/ImageModal.svelte'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the ImageModal and pass it the selected `image` from the gallery
|
||||||
|
* @param image
|
||||||
|
*/
|
||||||
|
let selectedImage: Image
|
||||||
|
const setSelectedImage: (image: Image) => void = image =>
|
||||||
|
(selectedImage = image)
|
||||||
|
|
||||||
|
const clearSelectedImage = () => (selectedImage = null)
|
||||||
|
|
||||||
|
// If galleryStore.selectedArea changes from private to public, re-run getImagesFromWNFS
|
||||||
|
let selectedArea = null
|
||||||
|
const unsubscribeGalleryStore = galleryStore.subscribe(async updatedStore => {
|
||||||
|
// Get initial selectedArea
|
||||||
|
if (!selectedArea) {
|
||||||
|
selectedArea = updatedStore.selectedArea
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedArea !== updatedStore.selectedArea) {
|
||||||
|
selectedArea = updatedStore.selectedArea
|
||||||
|
await getImagesFromWNFS()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Once the user has been authed, fetch the images from their file system
|
||||||
|
let imagesFetched = false
|
||||||
|
const unsubscribeSessionStore = sessionStore.subscribe((newState) => {
|
||||||
|
if (newState.authed && $filesystemStore && !imagesFetched) {
|
||||||
|
imagesFetched = true
|
||||||
|
// Get images from the user's public WNFS
|
||||||
|
getImagesFromWNFS()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeGalleryStore()
|
||||||
|
unsubscribeSessionStore()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="overflow-hidden text-gray-700">
|
||||||
|
<div class="pt-8 p-6 md:p-8 mx-auto">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:lg:grid-cols-6 gap-4"
|
||||||
|
>
|
||||||
|
<FileUploadCard />
|
||||||
|
{#each $galleryStore.selectedArea === AREAS.PRIVATE ? $galleryStore.privateImages : $galleryStore.publicImages as image}
|
||||||
|
<ImageCard {image} openModal={setSelectedImage} />
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedImage}
|
||||||
|
<ImageModal
|
||||||
|
image={selectedImage}
|
||||||
|
isModalOpen={!!selectedImage}
|
||||||
|
on:close={clearSelectedImage}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||||
|
|
||||||
|
import { ipfsGatewayUrl } from '$lib/app-info';
|
||||||
|
import { galleryStore } from '$routes/gallery/stores'
|
||||||
|
import { deleteImageFromWNFS, type Gallery, type Image } from '$routes/gallery/lib/gallery'
|
||||||
|
|
||||||
|
export let image: Image
|
||||||
|
export let isModalOpen: boolean = false
|
||||||
|
let previousImage: Image | undefined
|
||||||
|
let nextImage: Image | undefined
|
||||||
|
let showPreviousArrow: boolean
|
||||||
|
let showNextArrow: boolean
|
||||||
|
let gallery: Gallery
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
const unsubcribe = galleryStore.subscribe(newState => (gallery = newState))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the modal, clear the image state vars, set `isModalOpen` to false
|
||||||
|
* and dispatch the close event to clear the image from the parent's state
|
||||||
|
*/
|
||||||
|
const handleCloseModal: () => void = () => {
|
||||||
|
image = null
|
||||||
|
previousImage = null
|
||||||
|
nextImage = null
|
||||||
|
isModalOpen = false
|
||||||
|
dispatch('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an image from the user's WNFS
|
||||||
|
*/
|
||||||
|
const handleDeleteImage: () => Promise<void> = async () => {
|
||||||
|
await deleteImageFromWNFS(image.name)
|
||||||
|
handleCloseModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the previous and next images to be toggled to when the arrows are clicked
|
||||||
|
*/
|
||||||
|
const setCarouselState = () => {
|
||||||
|
const imageList = image.private
|
||||||
|
? gallery.privateImages
|
||||||
|
: gallery.publicImages
|
||||||
|
const currentIndex = imageList.findIndex(val => val.cid === image.cid)
|
||||||
|
previousImage =
|
||||||
|
imageList[currentIndex - 1] ?? imageList[imageList.length - 1]
|
||||||
|
nextImage = imageList[currentIndex + 1] ?? imageList[0]
|
||||||
|
|
||||||
|
showPreviousArrow = imageList.length > 1 && !!previousImage
|
||||||
|
showNextArrow = imageList.length > 1 && !!nextImage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the correct image when a user clicks the Next or Previous arrows
|
||||||
|
* @param direction
|
||||||
|
*/
|
||||||
|
const handleNextOrPrevImage: (
|
||||||
|
direction: 'next' | 'prev'
|
||||||
|
) => void = direction => {
|
||||||
|
image = direction === 'prev' ? previousImage : nextImage
|
||||||
|
setCarouselState()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect `Escape` key presses to close the modal or `ArrowRight`/`ArrowLeft`
|
||||||
|
* presses to navigate the carousel
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
const handleKeyDown: (event: KeyboardEvent) => void = event => {
|
||||||
|
if (event.key === 'Escape') handleCloseModal()
|
||||||
|
|
||||||
|
if (showNextArrow && event.key === 'ArrowRight')
|
||||||
|
handleNextOrPrevImage('next')
|
||||||
|
|
||||||
|
if (showPreviousArrow && event.key === 'ArrowLeft')
|
||||||
|
handleNextOrPrevImage('prev')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
setCarouselState()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unsubscribe from galleryStore updates
|
||||||
|
onDestroy(unsubcribe)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeyDown} />
|
||||||
|
|
||||||
|
{#if !!image}
|
||||||
|
<!-- bind:checked can't be set to !!image, so we need to set it to a boolean(casting image as a boolean throws a svelte error, so we're using isModalOpen) -->
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`image-modal-${image.cid}`}
|
||||||
|
class="modal-toggle"
|
||||||
|
bind:checked={isModalOpen}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for={`image-modal-${image.cid}`}
|
||||||
|
class="modal cursor-pointer z-50"
|
||||||
|
on:click|self={handleCloseModal}
|
||||||
|
>
|
||||||
|
<div class="modal-box relative text-center text-base-content">
|
||||||
|
<label
|
||||||
|
for={`image-modal-${image.cid}`}
|
||||||
|
class="btn btn-xs btn-circle absolute right-2 top-2"
|
||||||
|
on:click={handleCloseModal}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<h3 class="mb-7 text-lg break-all">{image.name}</h3>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
{#if showPreviousArrow}
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 -left-[25px] -translate-y-1/2 inline-block text-center text-[40px]"
|
||||||
|
on:click={() => handleNextOrPrevImage('prev')}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<img
|
||||||
|
class="block object-cover object-center border-2 border-base-content w-full h-full mb-4 rounded-[1rem]"
|
||||||
|
alt={`Image: ${image.name}`}
|
||||||
|
src={image.src}
|
||||||
|
/>
|
||||||
|
{#if showNextArrow}
|
||||||
|
<button
|
||||||
|
class="absolute top-1/2 -right-[25px] -translate-y-1/2 inline-block text-center text-[40px]"
|
||||||
|
on:click={() => handleNextOrPrevImage('next')}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center justify-center">
|
||||||
|
<a
|
||||||
|
href={`https://ipfs.${ipfsGatewayUrl}/ipfs/${image.cid}/userland`}
|
||||||
|
target="_blank"
|
||||||
|
class="underline mb-4 hover:text-slate-500"
|
||||||
|
>
|
||||||
|
View on IPFS
|
||||||
|
</a>
|
||||||
|
<p class="mb-4">
|
||||||
|
Created at {new Date(image.ctime).toDateString()}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<a href={image.src} download={image.name} class="btn btn-primary">
|
||||||
|
Download Image
|
||||||
|
</a>
|
||||||
|
<button class="btn btn-outline" on:click={handleDeleteImage}>
|
||||||
|
Delete Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
getImagesFromWNFS,
|
||||||
|
uploadImageToWNFS
|
||||||
|
} from '$routes/gallery/lib/gallery'
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect when a user drags a file in or out of the dropzone to change the styles
|
||||||
|
*/
|
||||||
|
let isDragging = false
|
||||||
|
const handleDragEnter: () => void = () => (isDragging = true)
|
||||||
|
const handleDragLeave: () => void = () => (isDragging = false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process files being dropped in the drop zone and ensure they are images
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
const handleDrop: (event: DragEvent) => Promise<void> = async event => {
|
||||||
|
// Prevent default behavior (Prevent file from being opened)
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer.items)
|
||||||
|
|
||||||
|
// Iterate over the dropped files and upload them to WNFS
|
||||||
|
await Promise.all(
|
||||||
|
files.map(async item => {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file: File = item.getAsFile()
|
||||||
|
|
||||||
|
// If the dropped files aren't images, we don't want them!
|
||||||
|
if (!file.type.match('image/*')) {
|
||||||
|
addNotification('Please upload images only', 'error')
|
||||||
|
console.error('Please upload images only')
|
||||||
|
} else {
|
||||||
|
await uploadImageToWNFS(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Refetch images and update galleryStore
|
||||||
|
await getImagesFromWNFS()
|
||||||
|
|
||||||
|
// Disable isDragging state
|
||||||
|
isDragging = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is needed to prevent the default behaviour of the file opening in browser
|
||||||
|
* when it is dropped
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
const handleDragOver: (event: DragEvent) => void = event =>
|
||||||
|
event.preventDefault()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
on:drop={handleDrop}
|
||||||
|
on:dragover={handleDragOver}
|
||||||
|
on:dragenter={handleDragEnter}
|
||||||
|
on:dragleave={handleDragLeave}
|
||||||
|
for="dropzone-file"
|
||||||
|
class="block w-full min-h-[calc(100vh-190px)] rounded-lg border-2 border-solid border-base-content transition ease-in cursor-pointer {isDragging
|
||||||
|
? 'border-dashed !border-orange-700 bg-orange-50'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { galleryStore } from '$routes/gallery/stores'
|
||||||
|
import { handleFileInput } from '$routes/gallery/lib/gallery'
|
||||||
|
import FileUploadIcon from '$routes/gallery/components/icons/FileUploadIcon.svelte'
|
||||||
|
|
||||||
|
// Handle files uploaded directly through the file input
|
||||||
|
let files: FileList
|
||||||
|
$: if (files) {
|
||||||
|
handleFileInput(files)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<label
|
||||||
|
for="upload-file"
|
||||||
|
class="group btn !p-0 !h-auto flex flex-col justify-center items-center aspect-[22/23] object-cover rounded-lg shadow-orange hover:border-neutral-50 overflow-hidden transition-colors ease-in bg-base-100 border-2 box-content border-neutral cursor-pointer text-neutral bg-gradient-to-r from-orange-600 to-orange-300"
|
||||||
|
>
|
||||||
|
{#if $galleryStore.loading}
|
||||||
|
<div class="flex justify-center items-center p-12">
|
||||||
|
<div
|
||||||
|
class="loader ease-linear rounded-full border-4 border-t-4 border-t-orange-300 border-neutral h-16 w-16 animate-spin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col justify-center items-center pt-5 pb-6">
|
||||||
|
<FileUploadIcon />
|
||||||
|
<p class="mt-4 mb-2 text-sm">
|
||||||
|
<span class="font-bold text-sm">Upload a photo</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xxs">SVG, PNG, JPG or GIF</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:files
|
||||||
|
id="upload-file"
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { get as getStore } from 'svelte/store'
|
||||||
|
import * as wn from 'webnative'
|
||||||
|
import * as uint8arrays from 'uint8arrays'
|
||||||
|
import type { CID } from 'multiformats/cid'
|
||||||
|
import type { PuttableUnixTree, File as WNFile } from 'webnative/fs/types'
|
||||||
|
import type { Metadata } from 'webnative/fs/metadata'
|
||||||
|
|
||||||
|
import { filesystemStore } from '$src/stores'
|
||||||
|
import { AREAS, galleryStore } from '$routes/gallery/stores'
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
|
||||||
|
export type Image = {
|
||||||
|
cid: string
|
||||||
|
ctime: number
|
||||||
|
name: string
|
||||||
|
private: boolean
|
||||||
|
size: number
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Gallery = {
|
||||||
|
publicImages: Image[] | null
|
||||||
|
privateImages: Image[] | null
|
||||||
|
selectedArea: AREAS
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GalleryFile extends PuttableUnixTree, WNFile {
|
||||||
|
cid: CID
|
||||||
|
content: Uint8Array
|
||||||
|
header: {
|
||||||
|
content: Uint8Array
|
||||||
|
metadata: Metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link = {
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GALLERY_DIRS = {
|
||||||
|
[AREAS.PUBLIC]: ['public', 'gallery'],
|
||||||
|
[AREAS.PRIVATE]: ['private', 'gallery']
|
||||||
|
}
|
||||||
|
const FILE_SIZE_LIMIT = 5
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get images from the user's WNFS and construct the `src` value for the images
|
||||||
|
*/
|
||||||
|
export const getImagesFromWNFS: () => Promise<void> = async () => {
|
||||||
|
try {
|
||||||
|
// Set loading: true on the galleryStore
|
||||||
|
galleryStore.update(store => ({ ...store, loading: true }))
|
||||||
|
|
||||||
|
const { selectedArea } = getStore(galleryStore)
|
||||||
|
const isPrivate = selectedArea === AREAS.PRIVATE
|
||||||
|
const fs = getStore(filesystemStore)
|
||||||
|
|
||||||
|
// Set path to either private or public gallery dir
|
||||||
|
const path = wn.path.directory(...GALLERY_DIRS[selectedArea])
|
||||||
|
|
||||||
|
// Get list of links for files in the gallery dir
|
||||||
|
const links = await fs.ls(path)
|
||||||
|
|
||||||
|
const images = await Promise.all(
|
||||||
|
Object.entries(links).map(async ([name]) => {
|
||||||
|
const file = await fs.get(
|
||||||
|
wn.path.file(...GALLERY_DIRS[selectedArea], `${name}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// The CID for private files is currently located in `file.header.content`,
|
||||||
|
// whereas the CID for public files is located in `file.cid`
|
||||||
|
const cid = isPrivate
|
||||||
|
? (file as GalleryFile).header.content.toString()
|
||||||
|
: (file as GalleryFile).cid.toString()
|
||||||
|
|
||||||
|
// Create a base64 string to use as the image `src`
|
||||||
|
const src = `data:image/jpeg;base64, ${uint8arrays.toString(
|
||||||
|
(file as GalleryFile).content,
|
||||||
|
'base64'
|
||||||
|
)}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
cid,
|
||||||
|
ctime: (file as GalleryFile).header.metadata.unixMeta.ctime,
|
||||||
|
name,
|
||||||
|
private: isPrivate,
|
||||||
|
size: (links[name] as Link).size,
|
||||||
|
src
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sort images by ctime(created at date)
|
||||||
|
// NOTE: this will eventually be controlled via the UI
|
||||||
|
images.sort((a, b) => b.ctime - a.ctime)
|
||||||
|
|
||||||
|
// Push images to the galleryStore
|
||||||
|
galleryStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
...(isPrivate
|
||||||
|
? {
|
||||||
|
privateImages: images
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
publicImages: images
|
||||||
|
}),
|
||||||
|
loading: false
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
galleryStore.update(store => ({
|
||||||
|
...store,
|
||||||
|
loading: false
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload an image to the user's private or public WNFS
|
||||||
|
* @param image
|
||||||
|
*/
|
||||||
|
export const uploadImageToWNFS: (
|
||||||
|
image: File
|
||||||
|
) => Promise<void> = async image => {
|
||||||
|
try {
|
||||||
|
const { selectedArea } = getStore(galleryStore)
|
||||||
|
const fs = getStore(filesystemStore)
|
||||||
|
|
||||||
|
// Reject files over 5MB
|
||||||
|
const imageSizeInMB = image.size / (1024 * 1024)
|
||||||
|
if (imageSizeInMB > FILE_SIZE_LIMIT) {
|
||||||
|
throw new Error('Image can be no larger than 5MB')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject the upload if the image already exists in the directory
|
||||||
|
const imageExists = await fs.exists(
|
||||||
|
wn.path.file(...GALLERY_DIRS[selectedArea], image.name)
|
||||||
|
)
|
||||||
|
if (imageExists) {
|
||||||
|
throw new Error(`${image.name} image already exists`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a sub directory and add some content
|
||||||
|
await fs.write(
|
||||||
|
wn.path.file(...GALLERY_DIRS[selectedArea], image.name),
|
||||||
|
image
|
||||||
|
)
|
||||||
|
|
||||||
|
// Announce the changes to the server
|
||||||
|
await fs.publish()
|
||||||
|
|
||||||
|
addNotification(`${image.name} image has been published`, 'success')
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(error.message, 'error')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an image from the user's private or public WNFS
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
export const deleteImageFromWNFS: (
|
||||||
|
name: string
|
||||||
|
) => Promise<void> = async name => {
|
||||||
|
try {
|
||||||
|
const { selectedArea } = getStore(galleryStore)
|
||||||
|
const fs = getStore(filesystemStore)
|
||||||
|
|
||||||
|
const imageExists = await fs.exists(
|
||||||
|
wn.path.file(...GALLERY_DIRS[selectedArea], name)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (imageExists) {
|
||||||
|
// Remove images from server
|
||||||
|
await fs.rm(wn.path.file(...GALLERY_DIRS[selectedArea], name))
|
||||||
|
|
||||||
|
// Announce the changes to the server
|
||||||
|
await fs.publish()
|
||||||
|
|
||||||
|
addNotification(`${name} image has been deleted`, 'success')
|
||||||
|
|
||||||
|
// Refetch images and update galleryStore
|
||||||
|
await getImagesFromWNFS()
|
||||||
|
} else {
|
||||||
|
throw new Error(`${name} image has already been deleted`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
addNotification(error.message, 'error')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle uploads made by interacting with the file input directly
|
||||||
|
*/
|
||||||
|
export const handleFileInput: (
|
||||||
|
files: FileList
|
||||||
|
) => Promise<void> = async files => {
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(files).map(async file => {
|
||||||
|
await uploadImageToWNFS(file)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
// Refetch images and update galleryStore
|
||||||
|
await getImagesFromWNFS()
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
import type { Writable } from 'svelte/store'
|
||||||
|
|
||||||
|
import type { Gallery } from '$routes/gallery/lib/gallery'
|
||||||
|
|
||||||
|
export enum AREAS {
|
||||||
|
PUBLIC = 'Public',
|
||||||
|
PRIVATE = 'Private'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const galleryStore: Writable<Gallery> = writable({
|
||||||
|
loading: true,
|
||||||
|
publicImages: [],
|
||||||
|
privateImages: [],
|
||||||
|
selectedArea: AREAS.PUBLIC,
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { account } from 'webnative'
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { page } from '$app/stores'
|
||||||
|
|
||||||
|
import { addNotification } from '$lib/notifications'
|
||||||
|
import { createAccountLinkingConsumer } from '$lib/auth/linking'
|
||||||
|
import { loadAccount } from '$lib/auth/account'
|
||||||
|
import type { LinkDeviceView } from '$lib/views'
|
||||||
|
import FilesystemActivity from '$components/common/FilesystemActivity.svelte'
|
||||||
|
import LinkDevice from '$components/auth/link-device/LinkDevice.svelte'
|
||||||
|
|
||||||
|
import { extractSearchParam } from '$lib/utils'
|
||||||
|
|
||||||
|
let view: LinkDeviceView = 'link-device'
|
||||||
|
|
||||||
|
let accountLinkingConsumer: account.AccountLinkingConsumer
|
||||||
|
let displayPin: string = ''
|
||||||
|
|
||||||
|
const username = extractSearchParam($page.url, 'username')
|
||||||
|
|
||||||
|
const initAccountLinkingConsumer = async () => {
|
||||||
|
accountLinkingConsumer = await createAccountLinkingConsumer(username)
|
||||||
|
|
||||||
|
accountLinkingConsumer.on('challenge', ({ pin }) => {
|
||||||
|
displayPin = pin.join('')
|
||||||
|
})
|
||||||
|
|
||||||
|
accountLinkingConsumer.on('link', async ({ approved, username }) => {
|
||||||
|
if (approved) {
|
||||||
|
view = 'load-filesystem'
|
||||||
|
|
||||||
|
await loadAccount(username)
|
||||||
|
|
||||||
|
addNotification("You're now connected!", 'success')
|
||||||
|
goto('/')
|
||||||
|
} else {
|
||||||
|
addNotification('The connection attempt was cancelled', 'info')
|
||||||
|
goto('/')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelConnection = async () => {
|
||||||
|
addNotification('The connection attempt was cancelled', 'info')
|
||||||
|
|
||||||
|
await accountLinkingConsumer?.cancel()
|
||||||
|
goto('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
initAccountLinkingConsumer()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||||
|
|
||||||
|
{#if view === 'link-device'}
|
||||||
|
<LinkDevice pin={displayPin} on:cancel={cancelConnection} />
|
||||||
|
{:else if view === 'load-filesystem'}
|
||||||
|
<FilesystemActivity activity="Loading" />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { sessionStore } from '../../stores'
|
||||||
|
|
||||||
|
import Register from '$components/auth/register/Register.svelte'
|
||||||
|
import Welcome from '$components/auth/register/Welcome.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $sessionStore.authed}
|
||||||
|
<Welcome />
|
||||||
|
{:else}
|
||||||
|
<Register />
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation'
|
||||||
|
import { sessionStore } from '$src/stores'
|
||||||
|
import AvatarUpload from '$components/settings/AvatarUpload.svelte'
|
||||||
|
import ThemePreferences from '$components/settings/ThemePreferences.svelte'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $sessionStore.authed}
|
||||||
|
<div
|
||||||
|
class="min-h-[calc(100vh-128px)] md:min-h-[calc(100vh-160px)] pt-8 md:pt-16 flex flex-col items-start max-w-[690px] m-auto gap-10 pb-5 text-sm"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl">Account Settings</h1>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-start justify-center gap-6">
|
||||||
|
<div>
|
||||||
|
<AvatarUpload />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg mb-4">Username</h3>
|
||||||
|
<p>{$sessionStore.username}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ThemePreferences />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg mb-4">Connected devices</h3>
|
||||||
|
{#if $sessionStore.backupCreated}
|
||||||
|
<p class="mb-4">
|
||||||
|
You've already connected an additional device, but you can connect
|
||||||
|
more.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4">You have no other connected devices.</p>
|
||||||
|
{/if}
|
||||||
|
<a class="btn btn-primary" href="/delegate-account">
|
||||||
|
Connect an additional device
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{goto('/')}
|
||||||
|
{/if}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { writable } from 'svelte/store'
|
||||||
|
import type { Writable } from 'svelte/store'
|
||||||
|
import type FileSystem from 'webnative/fs/index'
|
||||||
|
|
||||||
|
import { loadTheme } from '$lib/theme'
|
||||||
|
import type { AccountSettings } from '$lib/account-settings'
|
||||||
|
import type { Notification } from '$lib/notifications'
|
||||||
|
import type { Session } from '$lib/session'
|
||||||
|
import type { Theme } from '$lib/theme'
|
||||||
|
|
||||||
|
export const themeStore: Writable<Theme> = writable(loadTheme())
|
||||||
|
|
||||||
|
export const sessionStore: Writable<Session> = writable({
|
||||||
|
username: null,
|
||||||
|
authed: false,
|
||||||
|
loading: true,
|
||||||
|
backupCreated: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export const filesystemStore: Writable<FileSystem | null> = writable(null)
|
||||||
|
|
||||||
|
export const notificationStore: Writable<Notification[]> = writable([])
|
||||||
|
|
||||||
|
export const accountSettingsStore: Writable<AccountSettings> = writable({
|
||||||
|
avatar: null,
|
||||||
|
loading: true,
|
||||||
|
})
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |