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 `appName` to the name of your app.
- Change `appDescription` to a simple, 1-sentence description 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. - Once you [deploy](#deploy) your app, change `appURL` to the production URL.
In `package.json`, change `name` to your application's name. 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": { "dependencies": {
"clipboard-copy": "^4.0.1", "clipboard-copy": "^4.0.1",
"qrcode-svg": "^1.1.0", "qrcode-svg": "^1.1.0",
"uint8arrays": "^3.1.0", "uint8arrays": "^4.0.2",
"webnative": "^0.34.1" "webnative": "^0.35.1"
}, },
"engines": { "engines": {
"node": ">=16.14" "node": ">=16.14"

View File

@ -8,13 +8,13 @@
</script> </script>
<div <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 <p
class="motion-safe:animate-marquee motion-safe:left-full whitespace-nowrap font-bold text-xxs {isFirefox class="motion-safe:animate-marquee motion-safe:left-full whitespace-nowrap font-bold text-xxs {isFirefox
? 'text-orange-500' ? '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 *** Experimental *** - You are currently previewing Webnative SDK Alpha
0.2 0.2

View File

@ -2,7 +2,7 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { page } from '$app/stores' import { page } from '$app/stores'
import { sessionStore, themeStore } from '../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 AlphaTag from '$components/nav/AlphaTag.svelte'
import Avatar from '$components/settings/Avatar.svelte' import Avatar from '$components/settings/Avatar.svelte'
import BrandLogo from '$components/icons/BrandLogo.svelte' import BrandLogo from '$components/icons/BrandLogo.svelte'
@ -12,16 +12,20 @@
import LightMode from '$components/icons/LightMode.svelte' import LightMode from '$components/icons/LightMode.svelte'
import Shield from '$components/icons/Shield.svelte' import Shield from '$components/icons/Shield.svelte'
const setTheme = (newTheme: Theme) => { const setTheme = (newTheme: ThemeOptions) => {
themeStore.set(newTheme) localStorage.setItem(DEFAULT_THEME_KEY, 'false')
themeStore.set({
...$themeStore,
selectedTheme: newTheme,
useDefault: false,
})
storeTheme(newTheme) storeTheme(newTheme)
} }
</script> </script>
<header class="navbar flex bg-base-100 pt-4"> <header class="navbar flex bg-base-100 pt-4">
<div class="lg:hidden"> <div class="lg:hidden">
{#if $sessionStore.authed} {#if $sessionStore.session}
<label <label
for="sidebar-nav" for="sidebar-nav"
class="drawer-button cursor-pointer -translate-x-2" class="drawer-button cursor-pointer -translate-x-2"
@ -40,7 +44,7 @@
</div> </div>
<!-- Even if the user is not authed, render this header in the connection flow --> <!-- 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 <div
class="hidden lg:flex flex-1 items-center cursor-pointer gap-3" class="hidden lg:flex flex-1 items-center cursor-pointer gap-3"
on:click={() => goto('/')} on:click={() => goto('/')}
@ -56,30 +60,24 @@
{/if} {/if}
<div class="ml-auto"> <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} {#if !$sessionStore.loading && $sessionStore.backupCreated === false}
<span <span
on:click={() => goto('/delegate-account')} 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> <span class="mr-2">Backup recommended</span>
<Shield /> <Shield />
</span> </span>
{/if} {/if}
{#if $sessionStore.authed} {#if $sessionStore.session}
<a href="/settings" class="ml-2 cursor-pointer"> <a href="/settings" class="ml-2 cursor-pointer">
<Avatar size="small" /> <Avatar size="small" />
</a> </a>
{/if} {/if}
<span class="ml-2 cursor-pointer"> <span class="ml-2 cursor-pointer">
{#if $themeStore === 'light'} {#if $themeStore.selectedTheme === 'light'}
<span on:click={() => setTheme('dark')}> <span on:click={() => setTheme('dark')}>
<LightMode /> <LightMode />
</span> </span>

View File

@ -6,7 +6,7 @@
<div class="max-w-[573px]"> <div class="max-w-[573px]">
<p class="mb-5"> <p class="mb-5">
<a <a
class="link link-primary whitespace-nowrap" class="link text-blue-600 whitespace-nowrap"
href="https://github.com/fission-codes/webnative" href="https://github.com/fission-codes/webnative"
target="_blank" target="_blank"
> >
@ -18,7 +18,7 @@
<p> <p>
You can fork this You can fork this
<a <a
class="link link-primary whitespace-nowrap" class="link text-blue-600 whitespace-nowrap"
href="https://github.com/webnative-examples/webnative-app-template" href="https://github.com/webnative-examples/webnative-app-template"
target="_blank" target="_blank"
> >
@ -27,7 +27,7 @@
</a> </a>
to start writing your own Webnative app. Learn more in the to start writing your own Webnative app. Learn more in the
<a <a
class="link link-primary whitespace-nowrap" class="link text-blue-600 whitespace-nowrap"
href="https://guide.fission.codes/" href="https://guide.fission.codes/"
target="_blank" 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"> <script lang="ts">
import { appName } from '$lib/app-info' import { get as getStore } from 'svelte/store'
import { sessionStore } from '$src/stores'
import { import {
createDID,
isUsernameValid, isUsernameValid,
isUsernameAvailable, isUsernameAvailable,
register prepareUsername,
register,
USERNAME_STORAGE_KEY
} from '$lib/auth/account' } from '$lib/auth/account'
import CheckIcon from '$components/icons/CheckIcon.svelte' import CheckIcon from '$components/icons/CheckIcon.svelte'
import XIcon from '$components/icons/XIcon.svelte' import XIcon from '$components/icons/XIcon.svelte'
import FilesystemActivity from '$components/common/FilesystemActivity.svelte' import FilesystemActivity from '$components/common/FilesystemActivity.svelte'
let username: string = '' let username: string = ''
let encodedUsername: string = ''
let usernameValid = true let usernameValid = true
let usernameAvailable = true let usernameAvailable = true
let registrationSuccess = true let registrationSuccess = true
@ -19,133 +25,204 @@
const checkUsername = async (event: Event) => { const checkUsername = async (event: Event) => {
const { value } = event.target as HTMLInputElement const { value } = event.target as HTMLInputElement
const {
program: {
components: { crypto, storage }
}
} = getStore(sessionStore)
username = value username = value
checkingUsername = true 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) { if (usernameValid) {
usernameAvailable = await isUsernameAvailable(username) usernameAvailable = await isUsernameAvailable(encodedUsername)
} }
checkingUsername = false checkingUsername = false
} }
const registerUser = async () => { const registerUser = async (event: Event) => {
event.preventDefault()
if (checkingUsername) { if (checkingUsername) {
return return
} }
initializingFilesystem = true initializingFilesystem = true
registrationSuccess = await register(username) registrationSuccess = await register(encodedUsername)
if (!registrationSuccess) initializingFilesystem = false 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> </script>
{#if initializingFilesystem} {#if initializingFilesystem}
<FilesystemActivity activity="Initializing" /> <FilesystemActivity activity="Initializing" />
{:else} {:else}
<input type="checkbox" id="register-modal" checked class="modal-toggle" /> <div
<div class="modal"> 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"
<div class="modal-box w-narrowModal relative text-center"> >
<a href="/" class="btn btn-xs btn-circle absolute right-2 top-2"></a> <h1 class="text-base">Connect this device</h1>
<div> <!-- Registration Form -->
<h3 class="mb-7 text-base">Choose a username</h3> <form
<div class="relative"> on:submit={registerUser}
<input class="w-full p-6 rounded bg-base-content text-base-100"
id="registration" >
type="text" <h2 class="mb-2 text-sm font-semibold">Choose a username</h2>
placeholder="Type here" <div class="relative">
class="input input-bordered focus:outline-none w-full px-3 block" <input
class:input-error={username.length !== 0 && id="registration"
(!usernameValid || !usernameAvailable)} type="text"
on:input={checkUsername} 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}
{#if !registrationSuccess} {#if usernameApproved}
<!-- Error when registration fails --> <span
<label for="registration" class="label mt-1"> class="w-4 h-4 block absolute top-[17px] right-4 text-green-300"
<span class="label-text-alt text-error text-left"> >
There was an issue registering your account. Please try again. <CheckIcon />
</span> </span>
</label> {/if}
{#if usernameError}
<span class="w-4 h-4 block absolute top-[17px] right-4 text-red-400">
<XIcon />
</span>
{/if} {/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>
{#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> </div>
<!-- Recovery Link -->
<a href="/recover" class="underline">Recover an account</a>
</div> </div>
{/if} {/if}

View File

@ -9,7 +9,7 @@
<div class="modal-box w-narrowModal relative text-center"> <div class="modal-box w-narrowModal relative text-center">
<div> <div>
<h3 class="mb-14 text-base"> <h3 class="mb-14 text-base">
Welcome, {$sessionStore.username}! Welcome, {$sessionStore.username.trimmed}!
</h3> </h3>
<div class="flex justify-center mb-11 text-base-content"> <div class="flex justify-center mb-11 text-base-content">
<WelcomeCheckIcon /> <WelcomeCheckIcon />
@ -20,7 +20,7 @@
<div class="mb-8 text-left"> <div class="mb-8 text-left">
<input type="checkbox" id="password-message" class="peer hidden" /> <input type="checkbox" id="password-message" class="peer hidden" />
<label <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" for="password-message"
> >
Wait&mdash;what's my password? 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 <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-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"> <div class="flex flex-col items-start justify-center gap-5">
<h2 class="text-lg">Photo Gallery Demo</h2> <h2 class="text-lg">Photo Gallery Demo</h2>

View File

@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { sessionStore } from '$src/stores'
import { appName } from '$lib/app-info' import { appName } from '$lib/app-info'
import Alert from '$components/icons/Alert.svelte'
import Connect from '$components/icons/Connect.svelte'
</script> </script>
<div <div
@ -28,6 +31,27 @@
</li> </li>
</ul> </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>
</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" fill-rule="evenodd"
clip-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" 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> </svg>

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 539 B

View File

@ -7,7 +7,7 @@
> >
<path <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" 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-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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" fill-rule="evenodd"
clip-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" 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> </svg>

Before

Width:  |  Height:  |  Size: 740 B

After

Width:  |  Height:  |  Size: 745 B

View File

@ -1,5 +1,5 @@
<span <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 ALPHA
</span> </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 AlphaTag from '$components/nav/AlphaTag.svelte'
import BrandLogo from '$components/icons/BrandLogo.svelte' import BrandLogo from '$components/icons/BrandLogo.svelte'
import BrandWordmark from '$components/icons/BrandWordmark.svelte' import BrandWordmark from '$components/icons/BrandWordmark.svelte'
import Disconnect from '$components/icons/Disconnect.svelte'
import Home from '$components/icons/Home.svelte' import Home from '$components/icons/Home.svelte'
import PhotoGallery from '$components/icons/PhotoGallery.svelte' import PhotoGallery from '$components/icons/PhotoGallery.svelte'
import Settings from '$components/icons/Settings.svelte' import Settings from '$components/icons/Settings.svelte'
import NavItem from '$components/nav/NavItem.svelte'
const navItems = [ const navItemsUpper = [
{ {
label: 'Home', label: 'Home',
href: '/', href: '/',
@ -21,11 +23,6 @@
href: '/gallery/', href: '/gallery/',
icon: PhotoGallery icon: PhotoGallery
}, },
{
label: 'About This Template',
href: '/about/',
icon: About
},
{ {
label: 'Account Settings', label: 'Account Settings',
href: '/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 let checked = false
const handleCloseDrawer = (): void => { const handleCloseDrawer = (): void => {
checked = false checked = false
@ -40,7 +56,7 @@
</script> </script>
<!-- Only render the nav if the user is authed and not in the connection flow --> <!-- 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"> <div class="drawer drawer-mobile h-screen">
<input <input
id="sidebar-nav" id="sidebar-nav"
@ -51,7 +67,13 @@
<div class="drawer-content flex flex-col"> <div class="drawer-content flex flex-col">
<slot /> <slot />
</div> </div>
<div class="drawer-side"> <div
class="drawer-side {$page.url.pathname.match(
/register|backup|delegate|recover/
)
? '!hidden'
: ''}"
>
<label <label
for="sidebar-nav" for="sidebar-nav"
class="drawer-overlay !bg-[#262626] !opacity-[.85]" class="drawer-overlay !bg-[#262626] !opacity-[.85]"
@ -70,21 +92,17 @@
<AlphaTag /> <AlphaTag />
</div> </div>
<!-- Menu --> <!-- Upper Menu -->
<ul> <ul>
{#each navItems as item} {#each navItemsUpper as item}
<li> <NavItem {item} {handleCloseDrawer} />
<a {/each}
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 </ul>
.url.pathname === item.href
? '!text-base-100 !bg-base-content' <!-- Lower Menu -->
: ''}" <ul class="mt-auto pb-8">
href={item.href} {#each navItemsLower as item}
on:click={handleCloseDrawer} <NavItem {item} {handleCloseDrawer} />
>
<svelte:component this={item.icon} />{item.label}
</a>
</li>
{/each} {/each}
</ul> </ul>
</div> </div>

View File

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

View File

@ -21,7 +21,7 @@
class="flex items-center justify-center object-cover rounded-full border-2 border-base-content {sizeClasses}" class="flex items-center justify-center object-cover rounded-full border-2 border-base-content {sizeClasses}"
> >
<span <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> </div>
{:else} {:else}
@ -35,6 +35,6 @@
<div <div
class="flex items-center justify-center bg-base-content text-base-100 uppercase font-bold rounded-full {sizeClasses}" 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> </div>
{/if} {/if}

View File

@ -20,16 +20,20 @@
} }
</script> </script>
<h3 class="text-lg mb-4">Avatar</h3> <div>
<div class="flex items-center gap-4"> <h3 class="text-lg mb-4">Avatar</h3>
<Avatar /> <div class="flex items-center gap-4">
<Avatar />
<label for="upload-avatar" class="btn btn-outline">Upload a new avatar</label> <label for="upload-avatar" class="btn btn-outline">
<input Upload a new avatar
bind:files </label>
id="upload-avatar" <input
type="file" bind:files
accept="image/*" id="upload-avatar"
class="hidden" type="file"
/> accept="image/*"
class="hidden"
/>
</div>
</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"> <script lang="ts">
import { themeStore } from '$src/stores' 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 = [ const options = [
{ {
@ -13,36 +13,74 @@
} }
] ]
let selected = $themeStore const defaultTheme = getSystemDefaultTheme()
let selected = $themeStore.selectedTheme
let useDefault = $themeStore.useDefault
themeStore.subscribe((updatedTheme) => { themeStore.subscribe((updatedTheme) => {
selected = updatedTheme selected = updatedTheme.selectedTheme
useDefault = updatedTheme.useDefault
}) })
const setTheme = (newTheme: Theme) => { const setTheme = ({ selectedTheme, useDefault }: Theme) => {
themeStore.set(newTheme) themeStore.set({
storeTheme(newTheme) ...$themeStore,
selectedTheme,
useDefault,
})
storeTheme(selectedTheme)
} }
$: if (selected) { $: 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> </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} <p>
<div class="form-control items-start"> Your theme preference is saved per device. Any newly connected device will
<label class="label cursor-pointer"> adopt the preference from the device it was connected by.
<input </p>
type="radio"
name="theme-preference" <div>
class="radio checked:bg-base-content" <div class="form-control items-start">
value={option.value} <label class="label cursor-pointer">
checked={selected === option.value} <input
bind:group={selected} type="checkbox"
/> name="use-default-theme"
<span class="label-text text-sm ml-2">{option.label}</span> class="checkbox checked:bg-base-content"
</label> 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> </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-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-face {
font-family: 'UncutSans'; font-family: 'UncutSans';
src: url('/fonts/uncut-sans-bold-webfont.woff2') format('woff2'), src: url('/fonts/uncut-sans-bold-webfont.woff2') format('woff2'),
@ -50,30 +58,44 @@ body, input {
@apply bg-base-content; @apply bg-base-content;
} }
.btn-circle:hover {
@apply bg-base-content;
}
.btn-outline { .btn-outline {
@apply text-sm; @apply text-sm;
@apply text-base-content; @apply text-base-content;
@apply border-base-content; @apply border-base-content;
@apply bg-base-100; @apply bg-base-100;
@apply shadow-orange; @apply shadow-orange;
@apply h-11; @apply h-10;
@apply px-4; @apply px-4;
} }
.btn-primary { .btn-primary {
@apply text-sm; @apply text-sm;
@apply text-neutral; @apply text-neutral-900;
@apply border-neutral; @apply border-neutral-900;
@apply shadow-orange; @apply shadow-orange;
@apply bg-gradient-to-r; @apply bg-gradient-to-r;
@apply from-orange-600; @apply from-orange-300;
@apply to-orange-300; @apply to-orange-600;
@apply h-11; @apply h-10;
@apply px-4; @apply px-4;
} }
.btn-primary:disabled {
@apply opacity-50;
@apply text-neutral-900;
@apply border-neutral-900;
}
.btn-primary:hover, .btn-warning:hover { .btn-primary:hover, .btn-warning:hover {
@apply border-orange-300; @apply border-neutral-900;
}
.btn-link {
@apply text-blue-500;
} }
/* Input default styles */ /* Input default styles */

View File

@ -1,12 +1,14 @@
import { get as getStore } from 'svelte/store' import { get as getStore } from 'svelte/store'
import * as wn from 'webnative' import * as wn from 'webnative'
import { retrieve } from 'webnative/common/root-key'
import * as uint8arrays from 'uint8arrays' import * as uint8arrays from 'uint8arrays'
import type { CID } from 'multiformats/cid' import type { CID } from 'multiformats/cid'
import type { PuttableUnixTree, File as WNFile } from 'webnative/fs/types' import type { PuttableUnixTree, File as WNFile } from 'webnative/fs/types'
import type { Metadata } from 'webnative/fs/metadata' 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 { addNotification } from '$lib/notifications'
import { fileToUint8Array } from './utils'
export type Avatar = { export type Avatar = {
cid: string cid: string
@ -29,11 +31,11 @@ interface AvatarFile extends PuttableUnixTree, WNFile {
} }
} }
export const ACCOUNT_SETTINGS_DIR = ['private', 'settings'] export const ACCOUNT_SETTINGS_DIR = [ 'private', 'settings' ]
const AVATAR_DIR = [...ACCOUNT_SETTINGS_DIR, 'avatars'] const AVATAR_DIR = [ ...ACCOUNT_SETTINGS_DIR, 'avatars' ]
const AVATAR_ARCHIVE_DIR = [...AVATAR_DIR, 'archive'] const AVATAR_ARCHIVE_DIR = [ ...AVATAR_DIR, 'archive' ]
const AVATAR_FILE_NAME = 'avatar' const AVATAR_FILE_NAME = 'avatar'
const FILE_SIZE_LIMIT = 5 const FILE_SIZE_LIMIT = 20
/** /**
* Move old avatar to the archive directory * Move old avatar to the archive directory
@ -53,10 +55,9 @@ const archiveOldAvatar = async (): Promise<void> => {
const oldAvatarFileName = Object.keys(links).find(key => const oldAvatarFileName = Object.keys(links).find(key =>
key.includes(AVATAR_FILE_NAME) key.includes(AVATAR_FILE_NAME)
) )
const oldFileNameArray = oldAvatarFileName.split('.')[0] const oldFileNameArray = oldAvatarFileName.split('.')[ 0 ]
const archiveFileName = `${oldFileNameArray[0]}-${Date.now()}.${ const archiveFileName = `${oldFileNameArray[ 0 ]}-${Date.now()}.${oldFileNameArray[ 1 ]
oldFileNameArray[1] }`
}`
// Move old avatar to archive dir // Move old avatar to archive dir
const fromPath = wn.path.file(...AVATAR_DIR, oldAvatarFileName) const fromPath = wn.path.file(...AVATAR_DIR, oldAvatarFileName)
@ -148,10 +149,10 @@ export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
const fs = getStore(filesystemStore) const fs = getStore(filesystemStore)
// Reject files over 5MB // Reject files over 20MB
const imageSizeInMB = image.size / (1024 * 1024) const imageSizeInMB = image.size / (1024 * 1024)
if (imageSizeInMB > FILE_SIZE_LIMIT) { 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 // Archive old avatar
@ -159,15 +160,18 @@ export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
// Rename the file to `avatar.[extension]` // Rename the file to `avatar.[extension]`
const updatedImage = new File( const updatedImage = new File(
[image], [ image ],
`${AVATAR_FILE_NAME}.${image.name.split('.')[1]}`, `${AVATAR_FILE_NAME}.${image.name.split('.')[ 1 ]}`,
{ {
type: image.type type: image.type
} }
) )
// Create a sub directory and add the avatar // 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 // Announce the changes to the server
await fs.publish() await fs.publish()
@ -178,3 +182,71 @@ export const uploadAvatarToWNFS = async (image: File): Promise<void> => {
console.error(error) 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 appURL = 'https://webnative.netlify.app'
export const appImageURL = `${appURL}/preview.png` export const appImageURL = `${appURL}/preview.png`
export const ipfsGatewayUrl = 'runfission.com' 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 * 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 type FileSystem from 'webnative/fs/index'
import { get as getStore } from 'svelte/store'
import { asyncDebounce } from '$lib/utils' import { asyncDebounce } from '$lib/utils'
import { filesystemStore, sessionStore } from '../../stores' import { filesystemStore, sessionStore } from '../../stores'
@ -8,12 +13,29 @@ import { ACCOUNT_SETTINGS_DIR } from '$lib/account-settings'
import { AREAS } from '$routes/gallery/stores' import { AREAS } from '$routes/gallery/stores'
import { GALLERY_DIRS } from '$routes/gallery/lib/gallery' 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> => { 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( const debouncedIsUsernameAvailable = asyncDebounce(
webnative.account.isUsernameAvailable, _isUsernameAvailable,
300 300
) )
@ -23,21 +45,47 @@ export const isUsernameAvailable = async (
return debouncedIsUsernameAvailable(username) return debouncedIsUsernameAvailable(username)
} }
export const register = async (username: string): Promise<boolean> => { export const createDID = async (crypto: Crypto.Implementation): Promise<string> => {
const { success } = await webnative.account.register({ username }) 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 if (!success) return success
const fs = await webnative.bootstrapRootFileSystem() const session = await authStrategy.session()
filesystemStore.set(fs) filesystemStore.set(session.fs)
// TODO Remove if only public and private directories are needed // TODO Remove if only public and private directories are needed
await initializeFilesystem(fs) await initializeFilesystem(session.fs)
sessionStore.update(session => ({ const fullUsername = await storage.getItem(USERNAME_STORAGE_KEY) as string
...session,
username, sessionStore.update(state => ({
authed: true ...state,
username: {
full: fullUsername,
hashed: hashedUsername,
trimmed: fullUsername.split('#')[0]
},
session
})) }))
return success return success
@ -49,48 +97,29 @@ export const register = async (username: string): Promise<boolean> => {
* @param fs FileSystem * @param fs FileSystem
*/ */
const initializeFilesystem = async (fs: FileSystem): Promise<void> => { 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.PUBLIC ]))
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PRIVATE])) await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[ AREAS.PRIVATE ]))
await fs.mkdir(webnative.path.directory(...ACCOUNT_SETTINGS_DIR)) await fs.mkdir(webnative.path.directory(...ACCOUNT_SETTINGS_DIR))
} }
export const loadAccount = async (username: string): Promise<void> => { export const loadAccount = async (hashedUsername: string, fullUsername: string): Promise<void> => {
await checkDataRoot(username) const { authStrategy, program: { components: { storage } } } = getStore(sessionStore)
const session = await authStrategy.session()
const fs = await webnative.loadRootFileSystem() filesystemStore.set(session.fs)
filesystemStore.set(fs)
const backupStatus = await getBackupStatus(fs) const backupStatus = await getBackupStatus(session.fs)
sessionStore.update(session => ({ await storage.setItem(USERNAME_STORAGE_KEY, fullUsername)
...session,
username, sessionStore.update(state => ({
authed: true, ...state,
username: {
full: fullUsername,
hashed: hashedUsername,
trimmed: fullUsername.split('#')[0],
},
session,
backupCreated: backupStatus.created 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> => { export const setBackupStatus = async (fs: FileSystem, status: BackupStatus): Promise<void> => {
const backupStatusPath = webnative.path.file('private', 'backup-status.json') 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() await fs.publish()
} }
@ -15,15 +15,16 @@ export const getBackupStatus = async (fs: FileSystem): Promise<BackupStatus> =>
if (await fs.exists(backupStatusPath)) { if (await fs.exists(backupStatusPath)) {
const fileContent = await fs.read(backupStatusPath) const fileContent = await fs.read(backupStatusPath)
if (typeof fileContent === 'string') { try {
return JSON.parse(fileContent) as BackupStatus 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 } return { created: false }
} else { } else {
return { created: false } return { created: false }
} }
} }

View File

@ -1,14 +1,28 @@
import * as webnative from 'webnative' import type * as webnative from 'webnative'
import type { account } from 'webnative' import { get as getStore } from 'svelte/store'
import { sessionStore } from '$src/stores'
export const createAccountLinkingConsumer = async ( export const createAccountLinkingConsumer = async (
username: string username: string
): Promise<account.AccountLinkingConsumer> => { ): Promise<webnative.AccountLinkingConsumer> => {
return await webnative.account.createConsumer({ username }) 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 ( export const createAccountLinkingProducer = async (
username: string username: string
): Promise<account.AccountLinkingProducer> => { ): Promise<webnative.AccountLinkingProducer> => {
return await webnative.account.createProducer({ username }) const session = getStore(sessionStore)
return session.authStrategy.accountProducer(username)
} }

View File

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

View File

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

View File

@ -1,20 +1,39 @@
import { browser } from '$app/environment' 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' window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
export const loadTheme = (): Theme => { export const loadTheme = (): Theme => {
if (browser) { 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() 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) { if (browser) {
localStorage.setItem('theme', theme) localStorage.setItem('theme', theme)
} }

View File

@ -33,3 +33,12 @@ export const extractSearchParam = (url: URL, param: string): string | null => {
return val return val
} }
/**
* File to Uint8Array
*/
export async function fileToUint8Array(file: File): Promise<Uint8Array> {
return new Uint8Array(
await new Blob([ file ]).arrayBuffer()
)
}

View File

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

View File

@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { sessionStore } from '../stores' import { sessionStore } from '$src/stores'
import Authed from '$components/home/Authed.svelte' import Authed from '$components/home/Authed.svelte'
import Public from '$components/home/Public.svelte' import Public from '$components/home/Public.svelte'
</script> </script>
{#if $sessionStore?.authed} {#if $sessionStore?.session}
<Authed /> <Authed />
{:else} {:else}
<Public /> <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 => { 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 const origin = window.location.origin
connectionLink = `${origin}/link-device?username=${username}` connectionLink = `${origin}/link-device?hashedUsername=${hashedUsername}&username=${encodeURIComponent(
fullUsername
)}`
qrcode = new QRCode({ qrcode = new QRCode({
content: connectionLink, content: connectionLink,
color: $themeStore === 'light' ? '#171717' : '#FAFAFA', color: $themeStore.selectedTheme === 'light' ? '#171717' : '#FAFAFA',
background: $themeStore === 'light' ? '#FAFAFA' : '#171717', background:
$themeStore.selectedTheme === 'light' ? '#FAFAFA' : '#171717',
padding: 0, padding: 0,
width: 216, width: 250,
height: 216 height: 250
}).svg() }).svg()
initAccountLinkingProducer(username) initAccountLinkingProducer(hashedUsername)
} }
}) })
}) })

View File

@ -19,7 +19,7 @@
// If the user is not authed redirect them to the home page // If the user is not authed redirect them to the home page
const unsubscribe = sessionStore.subscribe(newState => { const unsubscribe = sessionStore.subscribe(newState => {
if (!newState.loading && !newState.authed) { if (!newState.loading && !newState.session) {
goto('/') goto('/')
} }
}) })
@ -28,7 +28,7 @@
</script> </script>
<div class="p-2 mb-14 text-center"> <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="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"> <div class="tabs border-2 overflow-hidden border-base-content rounded-lg">
{#each Object.keys(AREAS) as area} {#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 // Once the user has been authed, fetch the images from their file system
let imagesFetched = false let imagesFetched = false
const unsubscribeSessionStore = sessionStore.subscribe((newState) => { const unsubscribeSessionStore = sessionStore.subscribe((newState) => {
if (newState.authed && $filesystemStore && !imagesFetched) { if (newState.session && $filesystemStore && !imagesFetched) {
imagesFetched = true imagesFetched = true
// Get images from the user's public WNFS // Get images from the user's public WNFS
getImagesFromWNFS() getImagesFromWNFS()

View File

@ -4,6 +4,8 @@
import { ipfsGatewayUrl } from '$lib/app-info'; import { ipfsGatewayUrl } from '$lib/app-info';
import { galleryStore } from '$routes/gallery/stores' import { galleryStore } from '$routes/gallery/stores'
import { deleteImageFromWNFS, type Gallery, type Image } from '$routes/gallery/lib/gallery' 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 image: Image
export let isModalOpen: boolean = false export let isModalOpen: boolean = false
@ -137,22 +139,47 @@
{/if} {/if}
</div> </div>
<div class="flex flex-col items-center justify-center"> <div class="flex flex-col items-center justify-center">
<p class="mb-2 text-neutral-500">
Created {new Date(image.ctime).toDateString()}
</p>
<a <a
href={`https://ipfs.${ipfsGatewayUrl}/ipfs/${image.cid}/userland`} href={`https://ipfs.${ipfsGatewayUrl}/ipfs/${image.cid}/userland`}
target="_blank" 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> </a>
<p class="mb-4">
Created at {new Date(image.ctime).toDateString()} {#if image.private}
</p> <p class="mb-2 text-neutral-700 dark:text-neutral-500">
<div class="flex items-center justify-between gap-4"> * Your private files can only be viewed on devices that have
<a href={image.src} download={image.name} class="btn btn-primary"> permission. When viewed directly on IPFS, you will see the
Download Image 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> </a>
<button class="btn btn-outline" on:click={handleDeleteImage}> <button class="btn btn-outline gap-2" on:click={handleDeleteImage}>
Delete Image <Trash /> Delete Image
</button> </button>
</div> </div>
</div> </div>

View File

@ -12,12 +12,12 @@
<label <label
for="upload-file" 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} {#if $galleryStore.loading}
<div class="flex justify-center items-center p-12"> <div class="flex justify-center items-center p-12">
<div <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> </div>
{:else} {:else}

View File

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

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { account } from 'webnative' import type * as webnative from 'webnative'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { page } from '$app/stores' import { page } from '$app/stores'
@ -14,23 +14,26 @@
let view: LinkDeviceView = 'link-device' let view: LinkDeviceView = 'link-device'
let accountLinkingConsumer: account.AccountLinkingConsumer let accountLinkingConsumer: webnative.AccountLinkingConsumer
let displayPin: string = '' 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 () => { const initAccountLinkingConsumer = async () => {
accountLinkingConsumer = await createAccountLinkingConsumer(username) accountLinkingConsumer = await createAccountLinkingConsumer(hashedUsername)
accountLinkingConsumer.on('challenge', ({ pin }) => { accountLinkingConsumer.on('challenge', ({ pin }) => {
displayPin = pin.join('') displayPin = pin.join('')
}) })
accountLinkingConsumer.on('link', async ({ approved, username }) => { accountLinkingConsumer.on('link', async ({ approved }) => {
if (approved) { if (approved) {
view = 'load-filesystem' view = 'load-filesystem'
await loadAccount(username) await loadAccount(hashedUsername, fullUsername)
addNotification("You're now connected!", 'success') addNotification("You're now connected!", 'success')
goto('/') 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"> <script lang="ts">
import { sessionStore } from '../../stores' import { sessionStore } from '$src/stores'
import Register from '$components/auth/register/Register.svelte' import Register from '$components/auth/register/Register.svelte'
import Welcome from '$components/auth/register/Welcome.svelte' import Welcome from '$components/auth/register/Welcome.svelte'
</script> </script>
{#if $sessionStore.authed} {#if $sessionStore.session}
<Welcome /> <Welcome />
{:else} {:else}
<Register /> <Register />

View File

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

View File

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

View File

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