Upgrade to Webnative 0.35 (#89)

* Upgrade to webnative 0.35

* Linking

* Adjust loadAccount

* Chore: update avatar size limit to be 20mb - also update gallery size limit error message

* Chore: move program.auth.webCrypto to sessionStore.authStrategy (#93)

* program.id → program.tag

* Use AuthenticationStrategy type

* Single auth strategy

* Fix: always pass program.auth to sessionStore

* Avivash/add loading UI (#94)

* tag -> namespace

* Avivash/51 private file info (#95)

* Fix: delegate route being mounted twice

* Fix: orange and gradient directions

* Fix: footer gradient direction

* Bump webnative

* Use alpha version

* Upgrade alpha version

* Bump alpha release

* Bump

* Avivash/UI updates (#102)

* Bump

Co-authored-by: avivash <av@andrewvivash.com>
Co-authored-by: Andrew Vivash <andy@fission.codes>
Co-authored-by: Brian Ginsburg <gins@brianginsburg.com>
This commit is contained in:
Steven Vandevelde 2023-01-05 19:13:15 +01:00 committed by GitHub
parent c4c94777fa
commit b0b9a2020e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
66 changed files with 1958 additions and 1846 deletions

View File

@ -81,6 +81,7 @@ The app template is designed to be easy for you to _make it your own._ Here's ho
- Change `appName` to the name of your app.
- Change `appDescription` to a simple, 1-sentence description of your app.
- Update `webnativeNamespace` with your project details.
- Once you [deploy](#deploy) your app, change `appURL` to the production URL.
In `package.json`, change `name` to your application's name.

1996
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -55,8 +55,8 @@
"dependencies": {
"clipboard-copy": "^4.0.1",
"qrcode-svg": "^1.1.0",
"uint8arrays": "^3.1.0",
"webnative": "^0.34.1"
"uint8arrays": "^4.0.2",
"webnative": "^0.35.1"
},
"engines": {
"node": ">=16.14"

View File

@ -8,13 +8,13 @@
</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"
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-neutral-700 dark:bg-neutral-200 overflow-x-hidden"
>
{#if $themeStore === 'light'}
{#if $themeStore.selectedTheme === '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'}"
: 'text-transparent bg-clip-text bg-gradient-to-r from-orange-300 to-orange-600'}"
>
*** Experimental *** - You are currently previewing Webnative SDK Alpha
0.2

View File

@ -2,7 +2,7 @@
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { sessionStore, themeStore } from '../stores'
import { storeTheme, type Theme } from '$lib/theme'
import { DEFAULT_THEME_KEY, storeTheme, type ThemeOptions } from '$lib/theme'
import AlphaTag from '$components/nav/AlphaTag.svelte'
import Avatar from '$components/settings/Avatar.svelte'
import BrandLogo from '$components/icons/BrandLogo.svelte'
@ -12,16 +12,20 @@
import LightMode from '$components/icons/LightMode.svelte'
import Shield from '$components/icons/Shield.svelte'
const setTheme = (newTheme: Theme) => {
themeStore.set(newTheme)
const setTheme = (newTheme: ThemeOptions) => {
localStorage.setItem(DEFAULT_THEME_KEY, 'false')
themeStore.set({
...$themeStore,
selectedTheme: newTheme,
useDefault: false,
})
storeTheme(newTheme)
}
</script>
<header class="navbar flex bg-base-100 pt-4">
<div class="lg:hidden">
{#if $sessionStore.authed}
{#if $sessionStore.session}
<label
for="sidebar-nav"
class="drawer-button cursor-pointer -translate-x-2"
@ -40,7 +44,7 @@
</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/)}
{#if !$sessionStore.session || $page.url.pathname.match(/register|backup|delegate/)}
<div
class="hidden lg:flex flex-1 items-center cursor-pointer gap-3"
on:click={() => goto('/')}
@ -56,30 +60,24 @@
{/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"
class="btn btn-sm h-10 btn-warning rounded-full bg-orange-200 border-2 border-neutral-900 font-medium text-neutral-900 transition-colors ease-in hover:bg-orange-300"
>
<span class="mr-2">Backup recommended</span>
<Shield />
</span>
{/if}
{#if $sessionStore.authed}
{#if $sessionStore.session}
<a href="/settings" class="ml-2 cursor-pointer">
<Avatar size="small" />
</a>
{/if}
<span class="ml-2 cursor-pointer">
{#if $themeStore === 'light'}
{#if $themeStore.selectedTheme === 'light'}
<span on:click={() => setTheme('dark')}>
<LightMode />
</span>

View File

@ -6,7 +6,7 @@
<div class="max-w-[573px]">
<p class="mb-5">
<a
class="link link-primary whitespace-nowrap"
class="link text-blue-600 whitespace-nowrap"
href="https://github.com/fission-codes/webnative"
target="_blank"
>
@ -18,7 +18,7 @@
<p>
You can fork this
<a
class="link link-primary whitespace-nowrap"
class="link text-blue-600 whitespace-nowrap"
href="https://github.com/webnative-examples/webnative-app-template"
target="_blank"
>
@ -27,7 +27,7 @@
</a>
to start writing your own Webnative app. Learn more in the
<a
class="link link-primary whitespace-nowrap"
class="link text-blue-600 whitespace-nowrap"
href="https://guide.fission.codes/"
target="_blank"
>

View File

@ -1,34 +0,0 @@
<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

@ -1,24 +0,0 @@
<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,152 @@
<script lang="ts">
import * as RootKey from 'webnative/common/root-key'
import * as UCAN from 'webnative/ucan/index'
import * as DID from 'webnative/did/index'
import * as uint8arrays from 'uint8arrays'
import { sessionStore } from '$src/stores'
import {
RECOVERY_STATES,
USERNAME_STORAGE_KEY,
loadAccount,
prepareUsername
} from '$lib/auth/account'
import Check from '$components/icons/CheckIcon.svelte'
import RecoveryKitButton from './RecoveryKitButton.svelte'
let state = $sessionStore.session
? RECOVERY_STATES.Done
: RECOVERY_STATES.Ready
/**
* Parse the user's `username` and `readKey` from the uploaded recovery kit and pass them into
* webnative to recover the user's account and populate the `session` and `filesystem` stores
* @param files
*/
export const handleFileInput: (
files: FileList
) => Promise<void> = async files => {
const reader = new FileReader()
reader.onload = async event => {
state = RECOVERY_STATES.Processing
try {
const {
authStrategy,
program: {
components: { crypto, reference, storage }
}
} = $sessionStore
const parts = (event.target.result as string)
.split('username: ')[1]
.split('key: ')
const readKey = uint8arrays.fromString(
// Trim any whitespace from the parsed readKey
parts[1].replace(/(\r\n|\n|\r)/gm, ''),
'base64pad'
)
// Trim any whitespace from the parsed username
const oldUsername = parts[0].replace(/(\r\n|\n|\r)/gm, '')
const hashedOldUsername = await prepareUsername(oldUsername)
const newRootDID = await $sessionStore.program.agentDID()
// Construct a new username using the old `trimmed` name and `newRootDID`
const newUsername = `${oldUsername.split('#')[0]}#${newRootDID}`
const hashedNewUsername = await prepareUsername(newUsername)
storage.setItem(USERNAME_STORAGE_KEY, newUsername)
// Register the user with the `hashedNewUsername`
const { success } = await authStrategy.register({
username: hashedNewUsername
})
if (!success) {
throw new Error('Failed to register new user')
}
// Build an ephemeral UCAN to authorize the dataRoot.update call
const proof: string | null = await storage.getItem(
storage.KEYS.ACCOUNT_UCAN
)
const ucan = await UCAN.build({
dependencies: $sessionStore.program.components,
potency: 'APPEND',
resource: '*',
proof: proof ? proof : undefined,
lifetimeInSeconds: 60 * 3, // Three minutes
audience: newRootDID,
issuer: newRootDID
})
const oldRootCID = await reference.dataRoot.lookup(hashedOldUsername)
// Update the dataRoot of the new user
await reference.dataRoot.update(oldRootCID, ucan)
// Store the accountDID, which is used to namespace the readKey
await RootKey.store({
accountDID: newRootDID,
readKey,
crypto: crypto
})
// Load account data into sessionStore
await loadAccount(hashedNewUsername, newUsername)
state = RECOVERY_STATES.Done
} catch (error) {
console.error(error)
state = RECOVERY_STATES.Error
}
}
reader.onerror = error => {
console.error(error)
state = RECOVERY_STATES.Error
}
reader.readAsText(files[0])
}
</script>
<div
class="min-h-[calc(100vh-96px)] flex flex-col items-start justify-center max-w-[590px] m-auto gap-6 pb-5 text-sm"
>
<h1 class="text-xl">Recover your account</h1>
{#if state === RECOVERY_STATES.Done}
<h3 class="flex items-center gap-2 font-normal text-base text-green-600">
<Check /> Account recovered!
</h3>
<p>
Welcome back <strong>{$sessionStore.username.trimmed}.</strong>
We were able to successfully recover all of your private data.
</p>
{:else}
<p>
If youve lost access to all of your connected devices, you can use your
recovery kit to restore access to your private data.
</p>
{/if}
{#if state === RECOVERY_STATES.Error}
<p class="text-red-600">
We were unable to recover your account. Please double check that you
uploaded the correct file.
</p>
{/if}
<div class="flex flex-col gap-2">
<RecoveryKitButton {handleFileInput} {state} />
{#if state !== RECOVERY_STATES.Done}
<p class="text-xxs">
{`It should be a file named Webnative-RecoveryKit-{yourUsername}.txt`}
</p>
{/if}
</div>
</div>

View File

@ -0,0 +1,65 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { RECOVERY_STATES } from '$lib/auth/account'
import RightArrow from '$components/icons/RightArrow.svelte'
import Upload from '$components/icons/Upload.svelte'
export let handleFileInput: (files: FileList) => Promise<void>
export let state: RECOVERY_STATES
// Handle files uploaded directly through the file input
let files: FileList
$: if (files) {
handleFileInput(files)
}
$: buttonData = {
[RECOVERY_STATES.Processing]: {
text: 'Processing recovery kit...',
props: {
disabled: state === RECOVERY_STATES.Processing,
$$on_click: () => {}
}
},
[RECOVERY_STATES.Done]: {
text: 'Continue to the app',
props: {
$$on_click: () => goto('/')
}
}
}
</script>
{#if state === RECOVERY_STATES.Ready || state === RECOVERY_STATES.Error}
<label
for="upload-recovery-kit"
class="btn btn-primary !btn-lg !h-[56px] !min-h-0 w-fit gap-2"
>
<Upload /> Upload your recovery kit
</label>
<input
bind:files
id="upload-recovery-kit"
type="file"
accept=".txt"
class="hidden"
/>
{:else}
{@const { $$on_click, ...props } = buttonData[state].props}
<button
class="btn btn-primary !btn-lg !h-[56px] !min-h-0 w-fit gap-2"
{...props}
on:click={$$on_click}
>
{#if state === RECOVERY_STATES.Processing}
<span
class="animate-spin ease-linear rounded-full border-2 border-t-2 border-t-orange-500 border-neutral-900 w-[16px] h-[16px] text-sm"
/>
{/if}
{buttonData[state].text}
{#if state === RECOVERY_STATES.Done}
<RightArrow />
{/if}
</button>
{/if}

View File

@ -1,15 +1,21 @@
<script lang="ts">
import { appName } from '$lib/app-info'
import { get as getStore } from 'svelte/store'
import { sessionStore } from '$src/stores'
import {
createDID,
isUsernameValid,
isUsernameAvailable,
register
prepareUsername,
register,
USERNAME_STORAGE_KEY
} 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 encodedUsername: string = ''
let usernameValid = true
let usernameAvailable = true
let registrationSuccess = true
@ -19,133 +25,204 @@
const checkUsername = async (event: Event) => {
const { value } = event.target as HTMLInputElement
const {
program: {
components: { crypto, storage }
}
} = getStore(sessionStore)
username = value
checkingUsername = true
usernameValid = await isUsernameValid(username)
/**
* Create a new DID for the user, which will be appended to their username, concatenated
* via a `#`, hashed and encoded to ensure uniqueness
*/
const did = await createDID(crypto)
const fullUsername = `${value}#${did}`
await storage.setItem(USERNAME_STORAGE_KEY, fullUsername)
encodedUsername = await prepareUsername(fullUsername)
usernameValid = await isUsernameValid(encodedUsername)
if (usernameValid) {
usernameAvailable = await isUsernameAvailable(username)
usernameAvailable = await isUsernameAvailable(encodedUsername)
}
checkingUsername = false
}
const registerUser = async () => {
const registerUser = async (event: Event) => {
event.preventDefault()
if (checkingUsername) {
return
}
initializingFilesystem = true
registrationSuccess = await register(username)
registrationSuccess = await register(encodedUsername)
if (!registrationSuccess) initializingFilesystem = false
}
$: usernameApproved =
username.length > 0 &&
!checkingUsername &&
usernameValid &&
usernameAvailable
$: usernameError =
username.length > 0 &&
!checkingUsername &&
(!usernameValid || !usernameAvailable)
let existingAccount = false
const toggleExistingAccount = () => (existingAccount = !existingAccount)
</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
class="flex flex-col items-center justify-center gap-8 min-h-[calc(100vh-128px)] md:min-h-[calc(100vh-160px)] max-w-[352px] m-auto"
>
<h1 class="text-base">Connect this device</h1>
<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}
<!-- Registration Form -->
<form
on:submit={registerUser}
class="w-full p-6 rounded bg-base-content text-base-100"
>
<h2 class="mb-2 text-sm font-semibold">Choose a username</h2>
<div class="relative">
<input
id="registration"
type="text"
class="input input-bordered bg-neutral-50 !text-neutral-900 dark:border-neutral-900 rounded-lg focus:outline-none w-full px-3 block {usernameApproved
? '!border-green-300'
: ''} {usernameError ? '!border-red-400' : ''}"
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 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 usernameApproved}
<span
class="w-4 h-4 block absolute top-[17px] right-4 text-green-300"
>
<CheckIcon />
</span>
{/if}
{#if usernameError}
<span class="w-4 h-4 block absolute top-[17px] right-4 text-red-400">
<XIcon />
</span>
{/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>
{#if !(username.length === 0)}
<!-- Status of username: valid, available, etc -->
<label for="registration">
{#if usernameApproved}
<span class="text-xxs !p-0 text-green-300 dark:text-green-500">
This username is available.
</span>
{:else if !checkingUsername && !usernameValid}
<span class="text-xxs !p-0 text-error">
This username is not valid.
</span>
{:else if !checkingUsername && !usernameAvailable}
<span class="text-xxs !p-0 text-error">
This username is not available.
</span>
{/if}
</label>
{/if}
{#if !registrationSuccess}
<!-- Error when registration fails -->
<label for="registration" class="label">
<span class="text-xxs !p-0 text-error text-left">
There was an issue registering your account. Please try again.
</span>
</label>
{/if}
<div class="text-left mt-4">
<input
type="checkbox"
id="shared-computer"
class="peer checkbox checkbox-primary hover:border-base-100 rounded-lg border-2 border-base-100 transition-colors duration-250 ease-in-out inline-grid align-bottom checked:bg-base-100"
/>
<!-- 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 public or shared computer
</label>
<label
for="registration"
class="label mt-4 !p-0 hidden peer-checked:block"
>
<span class="text-red-400 text-left">
In order to ensure the security of your private data, Webnative does
not recommend creating an account from a public or shared computer.
</span>
</label>
</div>
<div class="flex items-center mt-4">
<a
class="!h-[52px] btn btn-outline !text-neutral-900 !border-neutral-900 !bg-neutral-50"
href="/"
>
Cancel
</a>
<button
class="ml-2 !h-[52px] flex-1 btn btn-primary disabled:opacity-50 disabled:border-neutral-900 disabled:text-neutral-900"
disabled={username.length === 0 ||
!usernameValid ||
!usernameAvailable ||
checkingUsername}
type="submit"
>
Create your account
</button>
</div>
</form>
<!-- Existing Account -->
<div class="flex flex-col gap-5 w-full">
<button
class="btn btn-outline !h-[52px] w-full {existingAccount
? '!bg-base-content !text-base-100 !border-base-content'
: ''}"
on:click={toggleExistingAccount}
>
I have an existing account
</button>
{#if existingAccount}
<div
class="flex flex-col gap-4 p-6 rounded bg-neutral-200 text-neutral-900"
>
<h3 class="text-sm text-center">
Which device are you connected on?
</h3>
<p>To connect your existing account, you'll need to:</p>
<ol class="pl-6 list-decimal">
<li>Find a device the account is already connected on</li>
<li>Navigate to your Account Settings</li>
<li>Click "Connect a new device"</li>
</ol>
</div>
{/if}
</div>
<!-- Recovery Link -->
<a href="/recover" class="underline">Recover an account</a>
</div>
{/if}

View File

@ -9,7 +9,7 @@
<div class="modal-box w-narrowModal relative text-center">
<div>
<h3 class="mb-14 text-base">
Welcome, {$sessionStore.username}!
Welcome, {$sessionStore.username.trimmed}!
</h3>
<div class="flex justify-center mb-11 text-base-content">
<WelcomeCheckIcon />
@ -20,7 +20,7 @@
<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"
class="text-blue-500 underline mb-8 hover:cursor-pointer peer-checked:hidden"
for="password-message"
>
Wait&mdash;what's my password?

View File

@ -0,0 +1,7 @@
<script lang="ts">
import LoadingSpinner from '$components/common/LoadingSpinner.svelte'
</script>
<div class="w-full h-[calc(100vh-72px)] flex items-center justify-center">
<LoadingSpinner />
</div>

View File

@ -0,0 +1,3 @@
<div
class="loader animate-spin ease-linear rounded-full border-4 border-t-4 border-t-orange-500 border-base-content h-16 w-16"
/>

View File

@ -5,7 +5,7 @@
<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>
<h1 class="text-xl">Welcome, {$sessionStore.username.trimmed}!</h1>
<div class="flex flex-col items-start justify-center gap-5">
<h2 class="text-lg">Photo Gallery Demo</h2>

View File

@ -1,5 +1,8 @@
<script lang="ts">
import { sessionStore } from '$src/stores'
import { appName } from '$lib/app-info'
import Alert from '$components/icons/Alert.svelte'
import Connect from '$components/icons/Connect.svelte'
</script>
<div
@ -28,6 +31,27 @@
</li>
</ul>
<a class="btn btn-primary btn-sm !h-10" href="/connect">Connect</a>
{#if $sessionStore.error === 'Unsupported Browser'}
<div class="p-4 rounded-lg bg-base-content text-neutral-50">
<h3 class="flex items-center gap-2 text-base">
<span class="-translate-y-[2px]"><Alert /></span>
Unsupported device
</h3>
<p>
It appears this device isnt supported. Webnative requires IndexedDB
in order to function. This browser doesnt appear to implement this
API. Are you in a Firefox private window?
</p>
</div>
{:else}
<div class="flex flex-col items-start gap-4">
<a class="btn btn-primary !btn-lg !h-10 gap-2" href="/register">
<Connect /> Connect this device
</a>
<a class="btn btn-outline" href="/recover">
Recover an existing account
</a>
</div>
{/if}
</div>
</div>

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="14" fill="none">
<path
fill="currentColor"
fill-rule="evenodd"
d="M6.257 1.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.333-.213 2.98-1.742 2.98H2.42C.89 14-.073 12.353.677 11.02l5.58-9.92ZM9 10.999a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM8 3a1 1 0 0 0-1 1v3a1 1 0 0 0 2 0V4a1 1 0 0 0-1-1Z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 393 B

View File

@ -9,6 +9,6 @@
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"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 539 B

View File

@ -7,7 +7,7 @@
>
<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="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 626 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="18" fill="none">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 13 5 9m0 0 4-4M5 9h14m-5 4v1a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V4a3 3 0 0 1 3-3h7a3 3 0 0 1 3 3v1"
/>
</svg>

After

Width:  |  Height:  |  Size: 302 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="18" fill="none">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m15 13 4-4m0 0-4-4m4 4H5m6 4v1a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V4a3 3 0 0 1 3-3h4a3 3 0 0 1 3 3v1"
/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="17" height="20" fill="none">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.5 8v6m0 0-3-3m3 3 3-3m2 8h-10a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V17a2 2 0 0 1-2 2Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" fill="none">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 11 1 6m0 0 5-5M1 6h12"
/>
</svg>

After

Width:  |  Height:  |  Size: 231 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none">
<path
fill="#fff"
fill-rule="evenodd"
d="M12 0c6.628 0 12 5.372 12 12s-5.372 12-12 12S0 18.628 0 12 5.372 0 12 0Zm-1.714 7.287c0-.237.184-.43.42-.43h2.588c.232 0 .42.188.42.425v4.807c0 .235-.141.553-.319.713l-.219.197a.4.4 0 0 0-.02.586l.258.258c.166.166.3.497.3.73v2.14c0 .237-.184.43-.42.43h-2.588a.42.42 0 0 1-.42-.428v-4.716c0-.236.134-.562.3-.727l.257-.258a.424.424 0 0 0 0-.6l-.258-.257a1.193 1.193 0 0 1-.3-.73v-2.14ZM21.429 12A9.428 9.428 0 0 0 12 2.571 9.428 9.428 0 0 0 2.571 12 9.428 9.428 0 0 0 12 21.429 9.428 9.428 0 0 0 21.429 12ZM5.143 12a6.857 6.857 0 1 1 13.714 0A6.857 6.857 0 0 1 12 18.857 6.857 6.857 0 0 1 5.143 12Z"
clip-rule="evenodd"
/>
</svg>

After

Width:  |  Height:  |  Size: 765 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="12" fill="none">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m8 1 5 5m0 0-5 5m5-5H1"
/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none">
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="m16.5 5-.867 12.142A2 2 0 0 1 13.638 19H5.362a2 2 0 0 1-1.995-1.858L2.5 5m5 4v6m4-6v6m1-10V2a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v3m-5 0h16"
/>
</svg>

After

Width:  |  Height:  |  Size: 340 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 13v1a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-1m-4-8L9 1m0 0L5 5m4-4v12"
/>
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -9,6 +9,6 @@
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"
fill="currentColor"
/>
</svg>

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 745 B

View File

@ -1,5 +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"
class="inline-block px-2 py-[2px] !no-underline font-medium text-center text-xs text-neutral-900 bg-gradient-to-r from-orange-300 to-orange-600"
>
ALPHA
</span>

View File

@ -0,0 +1,34 @@
<script lang="ts">
import { page } from '$app/stores'
export let handleCloseDrawer
export let item
</script>
<li>
{#if item.callback}
<button
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'
: ''}"
on:click={() => {
handleCloseDrawer()
item.callback()
}}
>
<svelte:component this={item.icon} />{item.label}
</button>
{:else}
<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>
{/if}
</li>

View File

@ -6,11 +6,13 @@
import AlphaTag from '$components/nav/AlphaTag.svelte'
import BrandLogo from '$components/icons/BrandLogo.svelte'
import BrandWordmark from '$components/icons/BrandWordmark.svelte'
import Disconnect from '$components/icons/Disconnect.svelte'
import Home from '$components/icons/Home.svelte'
import PhotoGallery from '$components/icons/PhotoGallery.svelte'
import Settings from '$components/icons/Settings.svelte'
import NavItem from '$components/nav/NavItem.svelte'
const navItems = [
const navItemsUpper = [
{
label: 'Home',
href: '/',
@ -21,11 +23,6 @@
href: '/gallery/',
icon: PhotoGallery
},
{
label: 'About This Template',
href: '/about/',
icon: About
},
{
label: 'Account Settings',
href: '/settings/',
@ -33,6 +30,25 @@
}
]
const navItemsLower = [
{
label: 'About This Template',
href: '/about/',
icon: About,
placement: 'bottom'
},
{
label: 'Disconnect',
callback: async () => {
await $sessionStore.session.destroy()
// Force a hard refresh to ensure everything is disconnected properly
window.location.href = window.location.origin
},
icon: Disconnect,
placement: 'bottom'
}
]
let checked = false
const handleCloseDrawer = (): void => {
checked = false
@ -40,7 +56,7 @@
</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/)}
{#if $sessionStore.session}
<div class="drawer drawer-mobile h-screen">
<input
id="sidebar-nav"
@ -51,7 +67,13 @@
<div class="drawer-content flex flex-col">
<slot />
</div>
<div class="drawer-side">
<div
class="drawer-side {$page.url.pathname.match(
/register|backup|delegate|recover/
)
? '!hidden'
: ''}"
>
<label
for="sidebar-nav"
class="drawer-overlay !bg-[#262626] !opacity-[.85]"
@ -70,21 +92,17 @@
<AlphaTag />
</div>
<!-- Menu -->
<!-- Upper 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 navItemsUpper as item}
<NavItem {item} {handleCloseDrawer} />
{/each}
</ul>
<!-- Lower Menu -->
<ul class="mt-auto pb-8">
{#each navItemsLower as item}
<NavItem {item} {handleCloseDrawer} />
{/each}
</ul>
</div>

View File

@ -20,7 +20,7 @@
error: {
component: XThinIcon,
props: {
color: $themeStore === 'light' ? '#ffd6d7' : '#fec3c3'
color: $themeStore.selectedTheme === 'light' ? '#ffd6d7' : '#fec3c3'
}
},
success: {

View File

@ -21,7 +21,7 @@
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}"
class="animate-spin ease-linear rounded-full border-2 border-t-2 border-t-orange-500 border-base-content {loaderSizeClasses}"
/>
</div>
{:else}
@ -35,6 +35,6 @@
<div
class="flex items-center justify-center bg-base-content text-base-100 uppercase font-bold rounded-full {sizeClasses}"
>
{$sessionStore.username[0]}
{$sessionStore.username.trimmed[0]}
</div>
{/if}

View File

@ -20,16 +20,20 @@
}
</script>
<h3 class="text-lg mb-4">Avatar</h3>
<div class="flex items-center gap-4">
<Avatar />
<div>
<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"
/>
<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>
</div>

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { sessionStore } from '$src/stores'
</script>
<div class="flex flex-col gap-4">
<h3 class="text-lg">Connected devices</h3>
{#if $sessionStore.backupCreated}
<p>You have connected at least one other device.</p>
{:else}
<p>You have no other connected devices.</p>
{/if}
<a class="btn btn-outline" href="/delegate-account">
Connect an additional device
</a>
</div>

View File

@ -0,0 +1,24 @@
<script lang="ts">
import RecoveryKitModal from '$components/settings/RecoveryKitModal.svelte'
let modalOpen = false
const handleToggleModal = async () => (modalOpen = !modalOpen)
</script>
<div class="flex flex-col gap-4">
<h3 class="text-lg">Recovery Kit</h3>
<p>
Your recovery kit will restore access to your data in the event that you
lose access to all of your connected devices. We recommend you store your
kit in a safe place, separate from those devices.
</p>
<button class="btn btn-primary w-fit" on:click={handleToggleModal}>
Create your recovery kit
</button>
</div>
{#if modalOpen}
<RecoveryKitModal {handleToggleModal} />
{/if}

View File

@ -0,0 +1,87 @@
<script lang="ts">
import { sessionStore } from '$src/stores'
import { generateRecoveryKit } from '$lib/account-settings'
import Download from '$components/icons/Download.svelte'
export let handleToggleModal: () => void
$: recoveryKit = null
$: downloadLinkRef = null
$: fileURL = null
const prepareRecoveryKitDownload = async () => {
recoveryKit = await generateRecoveryKit()
const data = new Blob([recoveryKit], { type: 'text/plain' })
// If we are replacing a previously generated file we need to
// manually revoke the object URL to avoid memory leaks.
if (fileURL !== null) {
window.URL.revokeObjectURL(fileURL)
}
fileURL = window.URL.createObjectURL(data)
}
const recoveryKitPromise = prepareRecoveryKitDownload()
$: if (downloadLinkRef && fileURL) {
downloadLinkRef.setAttribute(
'download',
`Webnative-RecoveryKit-${$sessionStore.username.trimmed}.txt`
)
downloadLinkRef.href = fileURL
}
</script>
<input type="checkbox" id="recovery-kit-modal" checked class="modal-toggle" />
<div class="modal !z-max">
<div
class="modal-box w-narrowModal sm:w-wideModal relative text-center sm:!pr-11 sm:!pb-11 sm:!pl-11"
>
<button
class="btn btn-xs btn-circle absolute right-2 top-2"
on:click={handleToggleModal}
>
</button>
<div>
{#await recoveryKitPromise}
<h3 class="mb-7 text-base">Creating your recovery kit...</h3>
<div 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"
/>
</div>
{:then}
<h3 class="mb-7 text-base">Your recovery kit has been created!</h3>
<div class="text-left mb-6">
<p class="mb-2">Please store it somewhere safe for two reasons:</p>
<ol class="list-decimal mb-2 pl-6">
<li>
<strong>It is powerful:</strong>
Anyone with this recovery kit will have access to all of your private
data.
</li>
<li>
<strong>It's your backup plan:</strong>
If you lose access to your connected devices, this kit will help you
recover your private data.
</li>
</ol>
<p>
So, keep it somewhere you keep things you don't want to lose or have
stolen.
</p>
</div>
<!-- svelte-ignore a11y-missing-attribute -->
<a class="btn btn-primary w-[227px] gap-2" bind:this={downloadLinkRef}>
<Download /> Download recovery kit
</a>
{/await}
</div>
</div>
</div>

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { themeStore } from '$src/stores'
import { storeTheme, type Theme } from '$lib/theme'
import { getSystemDefaultTheme, storeTheme, DEFAULT_THEME_KEY, type Theme, type ThemeOptions } from '$lib/theme'
const options = [
{
@ -13,36 +13,74 @@
}
]
let selected = $themeStore
const defaultTheme = getSystemDefaultTheme()
let selected = $themeStore.selectedTheme
let useDefault = $themeStore.useDefault
themeStore.subscribe((updatedTheme) => {
selected = updatedTheme
selected = updatedTheme.selectedTheme
useDefault = updatedTheme.useDefault
})
const setTheme = (newTheme: Theme) => {
themeStore.set(newTheme)
storeTheme(newTheme)
const setTheme = ({ selectedTheme, useDefault }: Theme) => {
themeStore.set({
...$themeStore,
selectedTheme,
useDefault,
})
storeTheme(selectedTheme)
}
$: if (selected) {
setTheme(selected)
setTheme({ selectedTheme: selected, useDefault })
}
const setDefaultThemePreference = (): void => {
localStorage.setItem(DEFAULT_THEME_KEY, `${useDefault}`)
if (useDefault) {
setTheme({ selectedTheme: defaultTheme, useDefault })
}
}
$: useDefault !== null && setDefaultThemePreference()
</script>
<h3 class="text-lg mb-4">Theme preference</h3>
<div class="flex flex-col gap-4">
<h3 class="text-lg">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>
<p>
Your theme preference is saved per device. Any newly connected device will
adopt the preference from the device it was connected by.
</p>
<div>
<div class="form-control items-start">
<label class="label cursor-pointer">
<input
type="checkbox"
name="use-default-theme"
class="checkbox checked:bg-base-content"
bind:checked={useDefault}
/>
<span class="label-text text-sm ml-2">Use system default</span>
</label>
</div>
{#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}
disabled={useDefault}
/>
<span class="label-text text-sm ml-2">{option.label}</span>
</label>
</div>
{/each}
</div>
{/each}
</div>

View File

@ -0,0 +1,12 @@
<script lang="ts">
import { sessionStore } from '$src/stores'
$: usernameParts = $sessionStore?.username?.full?.split('#')
</script>
{usernameParts[0]}
<span class="text-neutral-500 -ml-[3px]">
#{usernameParts[1].substring(0, 12)}...{usernameParts[1].substring(
usernameParts[1].length - 5
)}
</span>

View File

@ -0,0 +1,28 @@
<script lang="ts">
import clipboardCopy from 'clipboard-copy'
import { sessionStore } from '$src/stores'
import { addNotification } from '$lib/notifications'
import ClipboardIcon from '$components/icons/ClipboardIcon.svelte'
import TruncatedUsername from '$components/settings/TruncatedUsername.svelte'
const handleCopyUsername = async (): Promise<void> => {
await clipboardCopy($sessionStore.username.full)
addNotification('Copied to clipboard', 'success')
}
</script>
<div>
<h3 class="text-lg mb-4">Username</h3>
<div class="flex items-center">
<p>
<TruncatedUsername />
</p>
<button
class="pl-2 hover:text-neutral-500 transition-colors"
on:click={handleCopyUsername}
>
<ClipboardIcon />
</button>
</div>
</div>

View File

@ -18,6 +18,14 @@
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: 600;
font-style: normal;
}
@font-face {
font-family: 'UncutSans';
src: url('/fonts/uncut-sans-bold-webfont.woff2') format('woff2'),
@ -50,30 +58,44 @@ body, input {
@apply bg-base-content;
}
.btn-circle:hover {
@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 h-10;
@apply px-4;
}
.btn-primary {
@apply text-sm;
@apply text-neutral;
@apply border-neutral;
@apply text-neutral-900;
@apply border-neutral-900;
@apply shadow-orange;
@apply bg-gradient-to-r;
@apply from-orange-600;
@apply to-orange-300;
@apply h-11;
@apply from-orange-300;
@apply to-orange-600;
@apply h-10;
@apply px-4;
}
.btn-primary:disabled {
@apply opacity-50;
@apply text-neutral-900;
@apply border-neutral-900;
}
.btn-primary:hover, .btn-warning:hover {
@apply border-orange-300;
@apply border-neutral-900;
}
.btn-link {
@apply text-blue-500;
}
/* Input default styles */

View File

@ -1,12 +1,14 @@
import { get as getStore } from 'svelte/store'
import * as wn from 'webnative'
import { retrieve } from 'webnative/common/root-key'
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 { accountSettingsStore, filesystemStore, sessionStore } from '$src/stores'
import { addNotification } from '$lib/notifications'
import { fileToUint8Array } from './utils'
export type Avatar = {
cid: string
@ -29,11 +31,11 @@ interface AvatarFile extends PuttableUnixTree, WNFile {
}
}
export const ACCOUNT_SETTINGS_DIR = ['private', 'settings']
const AVATAR_DIR = [...ACCOUNT_SETTINGS_DIR, 'avatars']
const AVATAR_ARCHIVE_DIR = [...AVATAR_DIR, 'archive']
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
const FILE_SIZE_LIMIT = 20
/**
* Move old avatar to the archive directory
@ -53,10 +55,9 @@ const archiveOldAvatar = async (): Promise<void> => {
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]
}`
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)
@ -148,10 +149,10 @@ export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
const fs = getStore(filesystemStore)
// Reject files over 5MB
// Reject files over 20MB
const imageSizeInMB = image.size / (1024 * 1024)
if (imageSizeInMB > FILE_SIZE_LIMIT) {
throw new Error('Image can be no larger than 5MB')
throw new Error('Image can be no larger than 20MB')
}
// Archive old avatar
@ -159,15 +160,18 @@ export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
// Rename the file to `avatar.[extension]`
const updatedImage = new File(
[image],
`${AVATAR_FILE_NAME}.${image.name.split('.')[1]}`,
[ 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)
await fs.write(
wn.path.file(...AVATAR_DIR, updatedImage.name),
await fileToUint8Array(updatedImage)
)
// Announce the changes to the server
await fs.publish()
@ -178,3 +182,71 @@ export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
console.error(error)
}
}
export const generateRecoveryKit = async (): Promise<string> => {
const {
program: {
components: { crypto, reference }
},
username: {
full,
hashed,
trimmed,
}
} = getStore(sessionStore)
// Get the user's read-key and base64 encode it
const accountDID = await reference.didRoot.lookup(hashed)
const readKey = await retrieve({ crypto, accountDID })
const encodedReadKey = uint8arrays.toString(readKey, 'base64pad')
// Get today's date to display in the kit
const options: Intl.DateTimeFormatOptions = {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
}
const date = new Date()
const content = `# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
# @@@@@% %@@@@@@% %@@@@@@@% %@@@@@
# @@@@@ @@@@@% @@@@@@ @@@@@
# @@@@@% @@@@@ %@@@@@ %@@@@@
# @@@@@@% @@@@@ %@@% @@@@@ %@@@@@@
# @@@@@@@ @@@@@ %@@@@% @@@@@ @@@@@@@
# @@@@@@@ @@@@% @@@@@@ @@@@@ @@@@@@@
# @@@@@@@ %@@@@ @@@@@@ @@@@@% @@@@@@@
# @@@@@@@ @@@@@ @@@@@@ %@@@@@ @@@@@@@
# @@@@@@@ @@@@@@@@@@@@@@@@ @@@@@ @@@@@@@
# @@@@@@@ %@@@@@@@@@@@@@@@ @@@@% @@@@@@@
# @@@@@@@ %@@% @@@@@@ %@@% @@@@@@@
# @@@@@@@ @@@@@@ @@@@@@@
# @@@@@@@% %@@@@@@% %@@@@@@@
# @@@@@@@@@% %@@@@@@@@@@% %@@@@@@@@@
# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
# @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
# %@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%
#
# This is your recovery kit. (Its a yaml text file)
#
# Created for ${trimmed} on ${date.toLocaleDateString('en-US', options)}
#
# Store this somewhere safe.
#
# Anyone with this file will have read access to your private files.
# Losing it means you wont be able to recover your account
# in case you lose access to all your linked devices.
#
# Our team will never ask you to share this file.
#
# To use this file, go to ${window.location.origin}/recover/
# Learn how to customize this kit for your users: https://guide.fission.codes/
username: ${full}
key: ${encodedReadKey}`
return content
}

View File

@ -3,3 +3,4 @@ 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'
export const webnativeNamespace = { creator: 'Fission', name: 'WAT' }

View File

@ -1,5 +1,10 @@
import * as uint8arrays from 'uint8arrays'
import * as webnative from 'webnative'
import { sha256 } from 'webnative/components/crypto/implementation/browser'
import { publicKeyToDid } from 'webnative/did/transformers'
import type { Crypto } from 'webnative'
import type FileSystem from 'webnative/fs/index'
import { get as getStore } from 'svelte/store'
import { asyncDebounce } from '$lib/utils'
import { filesystemStore, sessionStore } from '../../stores'
@ -8,12 +13,29 @@ import { ACCOUNT_SETTINGS_DIR } from '$lib/account-settings'
import { AREAS } from '$routes/gallery/stores'
import { GALLERY_DIRS } from '$routes/gallery/lib/gallery'
export const USERNAME_STORAGE_KEY = 'fullUsername'
export enum RECOVERY_STATES {
Ready,
Processing,
Error,
Done
}
export const isUsernameValid = async (username: string): Promise<boolean> => {
return webnative.account.isUsernameValid(username)
const session = getStore(sessionStore)
return session.authStrategy.isUsernameValid(username)
}
const _isUsernameAvailable = async (
username: string
) => {
const session = getStore(sessionStore)
return session.authStrategy.isUsernameAvailable(username)
}
const debouncedIsUsernameAvailable = asyncDebounce(
webnative.account.isUsernameAvailable,
_isUsernameAvailable,
300
)
@ -23,21 +45,47 @@ export const isUsernameAvailable = async (
return debouncedIsUsernameAvailable(username)
}
export const register = async (username: string): Promise<boolean> => {
const { success } = await webnative.account.register({ username })
export const createDID = async (crypto: Crypto.Implementation): Promise<string> => {
const pubKey = await crypto.keystore.publicExchangeKey()
const ksAlg = await crypto.keystore.getAlgorithm()
return publicKeyToDid(crypto, pubKey, ksAlg)
}
export const prepareUsername = async (username: string): Promise<string> => {
const normalizedUsername = username.normalize('NFD')
const hashedUsername = await sha256(
new TextEncoder().encode(normalizedUsername)
)
return uint8arrays
.toString(hashedUsername, 'base32')
.slice(0, 32)
}
export const register = async (hashedUsername: string): Promise<boolean> => {
const { authStrategy, program: { components: { storage } } } = getStore(sessionStore)
const { success } = await authStrategy.register({ username: hashedUsername })
if (!success) return success
const fs = await webnative.bootstrapRootFileSystem()
filesystemStore.set(fs)
const session = await authStrategy.session()
filesystemStore.set(session.fs)
// TODO Remove if only public and private directories are needed
await initializeFilesystem(fs)
await initializeFilesystem(session.fs)
sessionStore.update(session => ({
...session,
username,
authed: true
const fullUsername = await storage.getItem(USERNAME_STORAGE_KEY) as string
sessionStore.update(state => ({
...state,
username: {
full: fullUsername,
hashed: hashedUsername,
trimmed: fullUsername.split('#')[0]
},
session
}))
return success
@ -49,48 +97,29 @@ export const register = async (username: string): Promise<boolean> => {
* @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(...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)
export const loadAccount = async (hashedUsername: string, fullUsername: string): Promise<void> => {
const { authStrategy, program: { components: { storage } } } = getStore(sessionStore)
const session = await authStrategy.session()
const fs = await webnative.loadRootFileSystem()
filesystemStore.set(fs)
filesystemStore.set(session.fs)
const backupStatus = await getBackupStatus(fs)
const backupStatus = await getBackupStatus(session.fs)
sessionStore.update(session => ({
...session,
username,
authed: true,
await storage.setItem(USERNAME_STORAGE_KEY, fullUsername)
sessionStore.update(state => ({
...state,
username: {
full: fullUsername,
hashed: hashedUsername,
trimmed: fullUsername.split('#')[0],
},
session,
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)
})
}

View File

@ -5,7 +5,7 @@ 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.write(backupStatusPath, new TextEncoder().encode(JSON.stringify(status)))
await fs.publish()
}
@ -15,15 +15,16 @@ export const getBackupStatus = async (fs: FileSystem): Promise<BackupStatus> =>
if (await fs.exists(backupStatusPath)) {
const fileContent = await fs.read(backupStatusPath)
if (typeof fileContent === 'string') {
return JSON.parse(fileContent) as BackupStatus
try {
return JSON.parse(
new TextDecoder().decode(fileContent)
) as BackupStatus
} catch (err) {
console.warn(`Unable to load backup status: ${err.message || err}`)
}
console.warn('Unable to load backup status')
return { created: false }
} else {
return { created: false }
}
}

View File

@ -1,14 +1,28 @@
import * as webnative from 'webnative'
import type { account } from 'webnative'
import type * as webnative from 'webnative'
import { get as getStore } from 'svelte/store'
import { sessionStore } from '$src/stores'
export const createAccountLinkingConsumer = async (
username: string
): Promise<account.AccountLinkingConsumer> => {
return await webnative.account.createConsumer({ username })
): Promise<webnative.AccountLinkingConsumer> => {
const session = getStore(sessionStore)
if (session.authStrategy) return session.authStrategy.accountConsumer(username)
// Wait for program to be initialised
return new Promise((resolve) => {
sessionStore.subscribe(updatedState => {
if (!updatedState.authStrategy) return
const consumer = updatedState.authStrategy.accountConsumer(username)
resolve(consumer)
})
})
}
export const createAccountLinkingProducer = async (
username: string
): Promise<account.AccountLinkingProducer> => {
return await webnative.account.createProducer({ username })
}
): Promise<webnative.AccountLinkingProducer> => {
const session = getStore(sessionStore)
return session.authStrategy.accountProducer(username)
}

View File

@ -1,47 +1,59 @@
import * as webnative from 'webnative'
import { setup } from 'webnative'
import { dev } from '$app/environment'
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 })
import { USERNAME_STORAGE_KEY } from '$lib/auth/account'
import { webnativeNamespace } from '$lib/app-info'
export const initialize = async (): Promise<void> => {
try {
let backupStatus: BackupStatus = null
const state: webnative.AppState = await webnative.app({ useWnfs: true })
const program: webnative.Program = await webnative.program({
namespace: webnativeNamespace,
debug: dev
})
switch (state.scenario) {
case webnative.AppScenario.NotAuthed:
sessionStore.set({
username: '',
authed: false,
loading: false,
backupCreated: null
})
break
if (program.session) {
// Authed
backupStatus = await getBackupStatus(program.session.fs)
case webnative.AppScenario.Authed:
backupStatus = await getBackupStatus(state.fs)
const fullUsername = await program.components.storage.getItem(USERNAME_STORAGE_KEY) as string
sessionStore.set({
username: state.username,
authed: state.authenticated,
loading: false,
backupCreated: backupStatus.created
})
sessionStore.set({
username: {
full: fullUsername,
hashed: program.session.username,
trimmed: fullUsername.split('#')[0],
},
session: program.session,
authStrategy: program.auth,
program,
loading: false,
backupCreated: backupStatus.created
})
filesystemStore.set(state.fs)
break
filesystemStore.set(program.session.fs)
} else {
// Not authed
sessionStore.set({
username: null,
session: null,
authStrategy: program.auth,
program,
loading: false,
backupCreated: null
})
default:
break
}
} catch (error) {
console.error(error)
switch (error) {
case webnative.InitialisationError.InsecureContext:
case webnative.ProgramError.InsecureContext:
sessionStore.update(session => ({
...session,
loading: false,
@ -49,7 +61,7 @@ export const initialize = async (): Promise<void> => {
}))
break
case webnative.InitialisationError.UnsupportedBrowser:
case webnative.ProgramError.UnsupportedBrowser:
sessionStore.update(session => ({
...session,
loading: false,
@ -57,5 +69,6 @@ export const initialize = async (): Promise<void> => {
}))
break
}
}
}
}

View File

@ -1,8 +1,18 @@
import type * as webnative from 'webnative'
import { appName } from '$lib/app-info'
type Username = {
full: string
hashed: string
trimmed: string
}
export type Session = {
username: string
authed: boolean
username: Username
session: webnative.Session | null
authStrategy: webnative.AuthenticationStrategy | null
program: webnative.Program
loading: boolean
backupCreated: boolean
error?: SessionError

View File

@ -1,20 +1,39 @@
import { browser } from '$app/environment'
export type Theme = 'light' | 'dark' | 'default'
export type ThemeOptions = 'light' | 'dark'
export const getSystemDefaultTheme = (): Theme =>
export type Theme = {
selectedTheme: ThemeOptions
useDefault: boolean
}
export const DEFAULT_THEME_KEY = 'useDefaultTheme'
export const THEME_KEY = 'theme'
export const getSystemDefaultTheme = (): ThemeOptions =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
export const loadTheme = (): Theme => {
if (browser) {
const browserTheme = localStorage.getItem('theme') as Theme
const useDefault = localStorage.getItem(DEFAULT_THEME_KEY) !== 'undefined' && JSON.parse(localStorage.getItem(DEFAULT_THEME_KEY))
const browserTheme = localStorage.getItem(THEME_KEY) as ThemeOptions
const osTheme = getSystemDefaultTheme()
return browserTheme ?? (osTheme as Theme) ?? 'light'
if (useDefault) {
return {
selectedTheme: getSystemDefaultTheme(),
useDefault,
}
}
return {
selectedTheme: browserTheme ?? (osTheme as ThemeOptions) ?? 'light',
useDefault,
}
}
}
export const storeTheme = (theme: Theme): void => {
export const storeTheme = (theme: ThemeOptions): void => {
if (browser) {
localStorage.setItem('theme', theme)
}

View File

@ -32,4 +32,13 @@ export const extractSearchParam = (url: URL, param: string): string | null => {
history.replaceState(null, document.title, url.toString())
return val
}
/**
* File to Uint8Array
*/
export async function fileToUint8Array(file: File): Promise<Uint8Array> {
return new Uint8Array(
await new Blob([ file ]).arrayBuffer()
)
}

View File

@ -4,4 +4,4 @@ export type ConnectView = 'connect' | 'open-connected-device'
export type DelegateAccountView = 'connect-backup-device' | 'delegate-account'
export type LinkDeviceView = 'link-device' | 'load-filesystem'
export type LinkDeviceView = 'link-device' | 'load-filesystem'

View File

@ -6,6 +6,7 @@
import { errorToMessage } from '$lib/session'
import { initialize } from '$lib/init'
import Footer from '$components/Footer.svelte'
import FullScreenLoadingSpinner from '$components/common/FullScreenLoadingSpinner.svelte'
import Header from '$components/Header.svelte'
import Notifications from '$components/notifications/Notifications.svelte'
import SidebarNav from '$components/nav/SidebarNav.svelte'
@ -52,13 +53,18 @@
<link rel="manifest" href="/manifest.webmanifest" />
</svelte:head>
<div data-theme={$themeStore} class="min-h-screen">
<div data-theme={$themeStore.selectedTheme} class="min-h-screen">
<Notifications />
<SidebarNav>
<Header />
<div class="px-4">
<slot />
</div>
</SidebarNav>
{#if $sessionStore.loading}
<FullScreenLoadingSpinner />
{:else}
<SidebarNav>
<Header />
<div class="px-4">
<slot />
</div>
</SidebarNav>
{/if}
<Footer />
</div>

View File

@ -1,10 +1,10 @@
<script lang="ts">
import { sessionStore } from '../stores'
import { sessionStore } from '$src/stores'
import Authed from '$components/home/Authed.svelte'
import Public from '$components/home/Public.svelte'
</script>
{#if $sessionStore?.authed}
{#if $sessionStore?.session}
<Authed />
{:else}
<Public />

View File

@ -1,17 +0,0 @@
<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

@ -40,22 +40,26 @@
})
unsubscribeSessionStore = sessionStore.subscribe(async val => {
const username = val.username
const hashedUsername = val.username.hashed
const fullUsername = val.username.full
if (username) {
if (hashedUsername && fullUsername) {
const origin = window.location.origin
connectionLink = `${origin}/link-device?username=${username}`
connectionLink = `${origin}/link-device?hashedUsername=${hashedUsername}&username=${encodeURIComponent(
fullUsername
)}`
qrcode = new QRCode({
content: connectionLink,
color: $themeStore === 'light' ? '#171717' : '#FAFAFA',
background: $themeStore === 'light' ? '#FAFAFA' : '#171717',
color: $themeStore.selectedTheme === 'light' ? '#171717' : '#FAFAFA',
background:
$themeStore.selectedTheme === 'light' ? '#FAFAFA' : '#171717',
padding: 0,
width: 216,
height: 216
width: 250,
height: 250
}).svg()
initAccountLinkingProducer(username)
initAccountLinkingProducer(hashedUsername)
}
})
})

View File

@ -19,7 +19,7 @@
// If the user is not authed redirect them to the home page
const unsubscribe = sessionStore.subscribe(newState => {
if (!newState.loading && !newState.authed) {
if (!newState.loading && !newState.session) {
goto('/')
}
})
@ -28,7 +28,7 @@
</script>
<div class="p-2 mb-14 text-center">
{#if $sessionStore.authed}
{#if $sessionStore.session}
<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}

View File

@ -35,7 +35,7 @@
// 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) {
if (newState.session && $filesystemStore && !imagesFetched) {
imagesFetched = true
// Get images from the user's public WNFS
getImagesFromWNFS()

View File

@ -4,6 +4,8 @@
import { ipfsGatewayUrl } from '$lib/app-info';
import { galleryStore } from '$routes/gallery/stores'
import { deleteImageFromWNFS, type Gallery, type Image } from '$routes/gallery/lib/gallery'
import Download from '$components/icons/Download.svelte'
import Trash from '$components/icons/Trash.svelte'
export let image: Image
export let isModalOpen: boolean = false
@ -137,22 +139,47 @@
{/if}
</div>
<div class="flex flex-col items-center justify-center">
<p class="mb-2 text-neutral-500">
Created {new Date(image.ctime).toDateString()}
</p>
<a
href={`https://ipfs.${ipfsGatewayUrl}/ipfs/${image.cid}/userland`}
target="_blank"
class="underline mb-4 hover:text-slate-500"
class="underline mb-2 hover:text-neutral-500"
>
View on IPFS
View on IPFS{#if image.private}*{/if}
</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
{#if image.private}
<p class="mb-2 text-neutral-700 dark:text-neutral-500">
* Your private files can only be viewed on devices that have
permission. When viewed directly on IPFS, you will see the
encrypted state of this file. This is because the raw IPFS gateway
view does not have permission to decrypt this file.
</p>
<p class="mb-2 text-neutral-700 dark:text-neutral-500">
Interested in private file sharing as a feature? Follow the <a
class="underline"
href="https://github.com/webnative-examples/webnative-app-template/issues/4"
target="_blank"
>
github issue.
</a>
</p>
{/if}
<div
class="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4"
>
<a
href={image.src}
download={image.name}
class="btn btn-primary gap-2"
>
<Download /> Download Image
</a>
<button class="btn btn-outline" on:click={handleDeleteImage}>
Delete Image
<button class="btn btn-outline gap-2" on:click={handleDeleteImage}>
<Trash /> Delete Image
</button>
</div>
</div>

View File

@ -12,12 +12,12 @@
<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"
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-500 overflow-hidden transition-colors ease-in bg-base-100 border-2 box-content border-neutral cursor-pointer text-neutral-900 bg-gradient-to-r from-orange-300 to-orange-600"
>
{#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"
class="loader ease-linear rounded-full border-4 border-t-4 border-t-orange-500 border-neutral-900 h-16 w-16 animate-spin"
/>
</div>
{:else}

View File

@ -1,13 +1,13 @@
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 type PublicFile from 'webnative/fs/v1/PublicFile'
import type PrivateFile from 'webnative/fs/v1/PrivateFile'
import { isFile } from 'webnative/fs/types/check'
import { filesystemStore } from '$src/stores'
import { AREAS, galleryStore } from '$routes/gallery/stores'
import { addNotification } from '$lib/notifications'
import { fileToUint8Array } from '$lib/utils'
export type Image = {
cid: string
@ -25,24 +25,15 @@ export type Gallery = {
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']
[ AREAS.PUBLIC ]: [ 'public', 'gallery' ],
[ AREAS.PRIVATE ]: [ 'private', 'gallery' ]
}
const FILE_SIZE_LIMIT = 5
const FILE_SIZE_LIMIT = 20
/**
* Get images from the user's WNFS and construct the `src` value for the images
@ -57,35 +48,39 @@ export const getImagesFromWNFS: () => Promise<void> = async () => {
const fs = getStore(filesystemStore)
// Set path to either private or public gallery dir
const path = wn.path.directory(...GALLERY_DIRS[selectedArea])
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]) => {
let images = await Promise.all(
Object.entries(links).map(async ([ name ]) => {
const file = await fs.get(
wn.path.file(...GALLERY_DIRS[selectedArea], `${name}`)
wn.path.file(...GALLERY_DIRS[ selectedArea ], `${name}`)
)
if (!isFile(file)) return null
// 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()
? (file as PrivateFile).header.content.toString()
: (file as PublicFile).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'
)}`
// Create a blob to use as the image `src`
const blob = new Blob([ file.content ])
const src = URL.createObjectURL(blob)
const ctime = isPrivate
? (file as PrivateFile).header.metadata.unixMeta.ctime
: (file as PublicFile).header.metadata.unixMeta.ctime
return {
cid,
ctime: (file as GalleryFile).header.metadata.unixMeta.ctime,
ctime,
name,
private: isPrivate,
size: (links[name] as Link).size,
size: (links[ name ] as Link).size,
src
}
})
@ -93,6 +88,7 @@ export const getImagesFromWNFS: () => Promise<void> = async () => {
// Sort images by ctime(created at date)
// NOTE: this will eventually be controlled via the UI
images = images.filter(a => !!a)
images.sort((a, b) => b.ctime - a.ctime)
// Push images to the galleryStore
@ -100,11 +96,11 @@ export const getImagesFromWNFS: () => Promise<void> = async () => {
...store,
...(isPrivate
? {
privateImages: images
}
privateImages: images
}
: {
publicImages: images
}),
publicImages: images
}),
loading: false
}))
} catch (error) {
@ -127,15 +123,15 @@ export const uploadImageToWNFS: (
const { selectedArea } = getStore(galleryStore)
const fs = getStore(filesystemStore)
// Reject files over 5MB
// Reject files over 20MB
const imageSizeInMB = image.size / (1024 * 1024)
if (imageSizeInMB > FILE_SIZE_LIMIT) {
throw new Error('Image can be no larger than 5MB')
throw new Error('Image can be no larger than 20MB')
}
// Reject the upload if the image already exists in the directory
const imageExists = await fs.exists(
wn.path.file(...GALLERY_DIRS[selectedArea], image.name)
wn.path.file(...GALLERY_DIRS[ selectedArea ], image.name)
)
if (imageExists) {
throw new Error(`${image.name} image already exists`)
@ -143,8 +139,8 @@ export const uploadImageToWNFS: (
// Create a sub directory and add some content
await fs.write(
wn.path.file(...GALLERY_DIRS[selectedArea], image.name),
image
wn.path.file(...GALLERY_DIRS[ selectedArea ], image.name),
await fileToUint8Array(image)
)
// Announce the changes to the server
@ -169,12 +165,12 @@ export const deleteImageFromWNFS: (
const fs = getStore(filesystemStore)
const imageExists = await fs.exists(
wn.path.file(...GALLERY_DIRS[selectedArea], name)
wn.path.file(...GALLERY_DIRS[ selectedArea ], name)
)
if (imageExists) {
// Remove images from server
await fs.rm(wn.path.file(...GALLERY_DIRS[selectedArea], name))
await fs.rm(wn.path.file(...GALLERY_DIRS[ selectedArea ], name))
// Announce the changes to the server
await fs.publish()

View File

@ -1,5 +1,5 @@
<script lang="ts">
import type { account } from 'webnative'
import type * as webnative from 'webnative'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
@ -14,23 +14,26 @@
let view: LinkDeviceView = 'link-device'
let accountLinkingConsumer: account.AccountLinkingConsumer
let accountLinkingConsumer: webnative.AccountLinkingConsumer
let displayPin: string = ''
const username = extractSearchParam($page.url, 'username')
const hashedUsername = extractSearchParam($page.url, 'hashedUsername')
const fullUsername = decodeURIComponent(
extractSearchParam($page.url, 'username')
)
const initAccountLinkingConsumer = async () => {
accountLinkingConsumer = await createAccountLinkingConsumer(username)
accountLinkingConsumer = await createAccountLinkingConsumer(hashedUsername)
accountLinkingConsumer.on('challenge', ({ pin }) => {
displayPin = pin.join('')
})
accountLinkingConsumer.on('link', async ({ approved, username }) => {
accountLinkingConsumer.on('link', async ({ approved }) => {
if (approved) {
view = 'load-filesystem'
await loadAccount(username)
await loadAccount(hashedUsername, fullUsername)
addNotification("You're now connected!", 'success')
goto('/')

View File

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

View File

@ -1,11 +1,11 @@
<script lang="ts">
import { sessionStore } from '../../stores'
import { sessionStore } from '$src/stores'
import Register from '$components/auth/register/Register.svelte'
import Welcome from '$components/auth/register/Welcome.svelte'
</script>
{#if $sessionStore.authed}
{#if $sessionStore.session}
<Welcome />
{:else}
<Register />

View File

@ -2,43 +2,28 @@
import { goto } from '$app/navigation'
import { sessionStore } from '$src/stores'
import AvatarUpload from '$components/settings/AvatarUpload.svelte'
import ConnectedDevices from '$components/settings/ConnectedDevices.svelte'
import RecoveryKit from '$components/settings/RecoveryKit.svelte'
import ThemePreferences from '$components/settings/ThemePreferences.svelte'
import Username from '$components/settings/Username.svelte'
</script>
{#if $sessionStore.authed}
{#if $sessionStore.session}
<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"
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-28 text-sm"
>
<h1 class="text-xl">Account Settings</h1>
<div class="flex flex-col items-start justify-center gap-6">
<div>
<AvatarUpload />
</div>
<AvatarUpload />
<div>
<h3 class="text-lg mb-4">Username</h3>
<p>{$sessionStore.username}</p>
</div>
<Username />
<div>
<ThemePreferences />
</div>
<ThemePreferences />
<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>
<ConnectedDevices />
<RecoveryKit />
</div>
</div>
{:else}

View File

@ -12,7 +12,9 @@ export const themeStore: Writable<Theme> = writable(loadTheme())
export const sessionStore: Writable<Session> = writable({
username: null,
authed: false,
session: null,
authStrategy: null,
program: null,
loading: true,
backupCreated: null
})

View File

@ -8,7 +8,7 @@ module.exports = {
themes: [
{
dark: {
primary: '#3b82f6',
primary: '#171717',
secondary: '#30aadd',
accent: '#00ffc6',
neutral: '#171717',
@ -19,14 +19,14 @@ module.exports = {
'base-content': '#FAFAFA', // Base text content color
'base-100': '#171717', // Base background color
'--rounded-box': '16px',
'--rounded-btn': '8px',
'--rounded-btn': '4px',
'--rounded-badge': '2px',
'--tab-radius': '2px',
'--btn-text-case': 'normal-case',
'--navbar-padding': '16px'
},
light: {
primary: '#3b82f6',
primary: '#FAFAFA',
secondary: '#30aadd',
accent: '#00ffc6',
neutral: '#171717',
@ -37,7 +37,7 @@ module.exports = {
'base-content': '#171717', // Base text content color
'base-100': '#FAFAFA', // Base background color
'--rounded-box': '16px',
'--rounded-btn': '8px',
'--rounded-btn': '4px',
'--rounded-badge': '2px',
'--tab-radius': '2px',
'--btn-text-case': 'normal-case',
@ -60,30 +60,37 @@ module.exports = {
'22/23': '22 / 23'
},
boxShadow: {
orange: '0px 8px 0px rgba(243, 110, 101, 0.2)'
orange: '0px 4px 0px rgba(243, 110, 101, 0.35)'
},
color: {
colors: {
blue: {
100: '#DBEAFE',
600: '#2563EB',
900: '#1E3A8A'
},
green: {
300: '#86EFAC',
500: '#22C55E',
700: '#15803D'
},
neutral: {
50: '#FAFAFA',
200: '#E5E5E5',
500: '#737373',
800: '#262626'
700: '#404040',
800: '#262626',
900: '#171717'
},
orange: {
50: '#FFF7ED',
200: '#FDBA74',
300: '#F26D64',
500: '#F36E65',
600: '#EED082',
700: '#C2410C'
},
red: {
400: '#F87171',
600: '#DC2626'
}
},