Initial commit

This commit is contained in:
Darren Zal 2022-12-27 21:21:22 -08:00 committed by GitHub
commit 082a1406d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
119 changed files with 17957 additions and 0 deletions

4
.eslintignore Normal file
View File

@ -0,0 +1,4 @@
.eslintrc.cjs
svelte.config.js
tsnode-loader.js
src/hooks.ts

46
.eslintrc.cjs Normal file
View File

@ -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']
}
}

22
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -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

26
.github/workflows/publish.yml vendored Normal file
View File

@ -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

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
# node
node_modules/
# svelte
.svelte-kit/
build/
# macOS
.DS_Store

1
.npmrc Normal file
View File

@ -0,0 +1 @@
engine-strict=true

20
.prettierrc.cjs Normal file
View File

@ -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,
}
}
]
}

21
LICENSE Normal file
View File

@ -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.

191
README.md Normal file
View File

@ -0,0 +1,191 @@
# Webnative App Template
[![Built by FISSION](https://img.shields.io/badge/⌘-Built_by_FISSION-purple.svg)](https://fission.codes) [![Built by FISSION](https://img.shields.io/badge/webnative-v0.34.1-purple.svg)](https://github.com/fission-suite/webnative) [![Discord](https://img.shields.io/discord/478735028319158273.svg)](https://discord.gg/zAQBDEq) [![Discourse](https://img.shields.io/discourse/https/talk.fission.codes/topics)](https://talk.fission.codes)
![Webnative UI Screenshot](static/webnative-ui.png)
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
![WNFS Image Gallery Screenshot](static/wnfs-gallery-screenshot.png)
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
[![Netlify Status](https://api.netlify.com/api/v1/badges/7b7418ef-86eb-43c4-a668-0118568c7f46/deploy-status)](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.

3
fission.yaml Normal file
View File

@ -0,0 +1,3 @@
ignore: []
url: webnative-template.fission.app
build: ./build

10487
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

64
package.json Normal file
View File

@ -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"
}
}

3
postcss.config.cjs Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
plugins: [require('tailwindcss'), require('autoprefixer')]
}

10
src/app.html Normal file
View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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&mdash;I'll risk just one device for now
</span>
</div>
</div>
</div>

View File

@ -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>

View File

@ -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>

View File

@ -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, youll 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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&mdash;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>

View File

@ -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>

View File

@ -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 users account lives only on their connected devices
— entirely under their control. Its easy for them to connect as many
devices as theyd 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>

View File

@ -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>

View File

@ -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

View File

@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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}

100
src/global.css Normal file
View File

@ -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;
}

1
src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="@sveltejs/kit" />

180
src/lib/account-settings.ts Normal file
View File

@ -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)
}
}

5
src/lib/app-info.ts Normal file
View File

@ -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'

96
src/lib/auth/account.ts Normal file
View File

@ -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)
})
}

29
src/lib/auth/backup.ts Normal file
View File

@ -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 }
}
}

14
src/lib/auth/linking.ts Normal file
View File

@ -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 })
}

61
src/lib/init.ts Normal file
View File

@ -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
}
}
}

45
src/lib/notifications.ts Normal file
View File

@ -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
}

21
src/lib/session.ts Normal file
View File

@ -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}`
}
}

21
src/lib/theme.ts Normal file
View File

@ -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)
}
}

35
src/lib/utils.ts Normal file
View File

@ -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
}

7
src/lib/views.ts Normal file
View File

@ -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'

43
src/routes/+error.svelte Normal file
View File

@ -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>

3
src/routes/+layout.js Normal file
View File

@ -0,0 +1,3 @@
export const csr = true
export const ssr = false
export const prerender = true

64
src/routes/+layout.svelte Normal file
View File

@ -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>

11
src/routes/+page.svelte Normal file
View File

@ -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}

View File

@ -0,0 +1,5 @@
<script lang="ts">
import AboutThisTemplate from '$components/about/AboutThisTemplate.svelte'
</script>
<AboutThisTemplate />

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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')}
>
&#8249;
</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')}
>
&#8250;
</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}

View File

@ -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>

View File

@ -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>

View File

@ -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()
}

View File

@ -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,
})

View File

@ -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}

View File

@ -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}

View File

@ -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}

27
src/stores.ts Normal file
View File

@ -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,
})

BIN
static/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
static/favicon-dark.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
static/favicon-light.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More