Cleanup and refactoring (#60)
* Use production infra * Move theme out of subdirectory * Remove unused example test * Separate webnative module into init and account * Move state off of module global state * Move utils out of common directory * Organize imports consistently * Replace last remaining Toast with a Notification * Rename theme to themeStore Renaming keeps the theme store naming consistent with the other stores. * Replace uuid helper with WebCrypto randomUUID * Enfore minimum node version at >=16.9 * Refactor delegate-account and link-device routes Use component views for consistency with the other routes * Use FilesystemActivity component in Register * Remove console logs * Add extractSearchParam Generalizes extracting a search param from a URL. * Hide app name in header on mobile * Remove deviceStore We weren't using it in the app. Mobile styles are handled with tailwind utility classes. * Add social preview image * Remove navigate event from AreYouSure component The event was unused. * Replace convertUint8ToString with uint8arrays * Add initializeFilesystem Separates out app-specific resource creation from the required registration code * Allow minor webnative version upgrades * Update package-lock.json
This commit is contained in:
parent
05b70fd159
commit
102ecd9b3e
|
|
@ -10,7 +10,8 @@
|
|||
"dependencies": {
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"webnative": "0.34.1"
|
||||
"uint8arrays": "^3.1.0",
|
||||
"webnative": "^0.34.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "1.0.0-next.36",
|
||||
|
|
@ -37,6 +38,9 @@
|
|||
"tslib": "^2.0.0",
|
||||
"typescript": "^4.4.4",
|
||||
"vite": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
|
|
@ -47,6 +51,100 @@
|
|||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz",
|
||||
"integrity": "sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
|
||||
"integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.18.6",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/highlight/node_modules/supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"has-flag": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
|
|
@ -3235,6 +3333,12 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
|
|
@ -5712,6 +5816,81 @@
|
|||
"@babel/highlight": "^7.10.4"
|
||||
}
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz",
|
||||
"integrity": "sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/highlight": {
|
||||
"version": "7.18.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz",
|
||||
"integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.18.6",
|
||||
"chalk": "^2.0.0",
|
||||
"js-tokens": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ansi-styles": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
|
||||
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-convert": "^1.9.0"
|
||||
}
|
||||
},
|
||||
"chalk": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
|
||||
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ansi-styles": "^3.2.1",
|
||||
"escape-string-regexp": "^1.0.5",
|
||||
"supports-color": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"color-convert": {
|
||||
"version": "1.9.3",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
|
||||
"dev": true
|
||||
},
|
||||
"escape-string-regexp": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==",
|
||||
"dev": true
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
|
||||
"dev": true
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
|
||||
|
|
@ -7996,6 +8175,12 @@
|
|||
"integrity": "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg==",
|
||||
"dev": true
|
||||
},
|
||||
"js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "3.14.1",
|
||||
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@
|
|||
"dependencies": {
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"qrcode-svg": "^1.1.0",
|
||||
"webnative": "0.34.1"
|
||||
"uint8arrays": "^3.1.0",
|
||||
"webnative": "^0.34.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import { sessionStore, theme } from '../stores'
|
||||
import { storeTheme, type Theme } from '$lib/theme/index'
|
||||
|
||||
import { appName } from '$lib/app-info'
|
||||
import { sessionStore, themeStore } from '../stores'
|
||||
import { storeTheme, type Theme } from '$lib/theme'
|
||||
import Brand from '$components/icons/Brand.svelte'
|
||||
import Shield from '$components/icons/Shield.svelte'
|
||||
import LightMode from '$components/icons/LightMode.svelte'
|
||||
import DarkMode from '$components/icons/DarkMode.svelte'
|
||||
import LightMode from '$components/icons/LightMode.svelte'
|
||||
import Shield from '$components/icons/Shield.svelte'
|
||||
|
||||
const setTheme = (newTheme: Theme) => {
|
||||
theme.set(newTheme)
|
||||
themeStore.set(newTheme)
|
||||
storeTheme(newTheme)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
<header class="navbar bg-base-100 pt-0">
|
||||
<div class="flex-1 cursor-pointer hover:underline" on:click={() => goto('/')}>
|
||||
<Brand />
|
||||
<span class="text-xl ml-2">{appName}</span>
|
||||
<span class="text-xl ml-2 hidden md:inline">{appName}</span>
|
||||
</div>
|
||||
|
||||
{#if !$sessionStore.loading && !$sessionStore.authed}
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
{/if}
|
||||
|
||||
<span class="ml-2">
|
||||
{#if $theme === 'light'}
|
||||
{#if $themeStore === 'light'}
|
||||
<span on:click={() => setTheme('dark')}>
|
||||
<LightMode />
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
import { filesystemStore, sessionStore } from '../../../stores'
|
||||
import { setBackupStatus } from '$lib/auth/backup'
|
||||
import type { BackupView } from '$lib/views'
|
||||
import { goto } from '$app/navigation'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const navigate = (view: BackupView) => {
|
||||
dispatch('navigate', { view })
|
||||
}
|
||||
|
||||
const skipBackup = () => {
|
||||
setBackupStatus($filesystemStore, { created: false })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation'
|
||||
import clipboardCopy from 'clipboard-copy'
|
||||
|
||||
import ClipboardIcon from '$components/icons/ClipboardIcon.svelte'
|
||||
|
||||
export let qrcode
|
||||
export let connectionLink: string
|
||||
export let backupCreated: boolean
|
||||
|
||||
const copyLink = async () => {
|
||||
await clipboardCopy(connectionLink)
|
||||
}
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="backup-device-modal" checked class="modal-toggle" />
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box w-80 relative text-center dark:border-slate-600 dark:border"
|
||||
>
|
||||
<div>
|
||||
<h3 class="pb-1 text-xl font-serif">Connect a backup device</h3>
|
||||
{@html qrcode}
|
||||
<p class="pt-1 mb-8">
|
||||
Scan this code on the new device, or share the connection link.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-outline" on:click={copyLink}>
|
||||
<ClipboardIcon />
|
||||
<span class="ml-2">Copy connection link</span>
|
||||
</button>
|
||||
{#if !backupCreated}
|
||||
<button
|
||||
class="btn btn-xs btn-link text-base text-error font-normal underline mt-4"
|
||||
on:click={() => goto('/backup?view=are-you-sure')}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let pinInput: string
|
||||
export let pinError: boolean
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const cancelConnection = () => {
|
||||
dispatch('cancel')
|
||||
}
|
||||
|
||||
const checkPin = () => {
|
||||
dispatch('checkpin')
|
||||
}
|
||||
</script>
|
||||
|
||||
<input
|
||||
type="checkbox"
|
||||
id="delegate-account-modal"
|
||||
checked
|
||||
class="modal-toggle"
|
||||
/>
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box w-80 relative text-center dark:border-slate-600 dark:border"
|
||||
>
|
||||
<div>
|
||||
<h3 class="mb-7 text-xl font-serif">
|
||||
A new device would like to connect to your account
|
||||
</h3>
|
||||
<div class="mb-5">
|
||||
<input
|
||||
id="pin"
|
||||
type="text"
|
||||
class="input input-bordered w-full max-w-xs mb-2 rounded-full h-16 font-mono text-3xl text-center tracking-[0.18em] font-light dark:border-slate-300"
|
||||
bind:value={pinInput}
|
||||
/>
|
||||
<label for="pin" class="label">
|
||||
{#if !pinError}
|
||||
<span class="label-text-alt text-slate-500">
|
||||
Enter the connection code to approve the connection.
|
||||
</span>
|
||||
{:else}
|
||||
<span class="label-text-alt text-error">
|
||||
Entered pin does not match a pin from a known device.
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary mb-5 w-full" on:click={checkPin}>
|
||||
Approve the connection
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-outline w-full"
|
||||
on:click={cancelConnection}
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let pin: string
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const cancelConnection = () => {
|
||||
dispatch('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box w-80 relative text-center dark:border-slate-600 dark:border"
|
||||
>
|
||||
<div class="grid grid-flow-row auto-rows-max gap-7">
|
||||
<h3 class="text-xl font-serif">Connection Requested</h3>
|
||||
<div class="grid grid-flow-row auto-rows-max gap-4 justify-items-center">
|
||||
{#if pin}
|
||||
<span
|
||||
class="btn bg-blue-100 dark:bg-blue-900 hover:bg-blue-100 dark:hover:bg-blue-900 border-0 btn-lg rounded-full text-3xl tracking-[.18em] w-3/4 cursor-default font-mono font-light"
|
||||
>
|
||||
{pin}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-md">Enter this code on your connected device.</span>
|
||||
<div
|
||||
class="grid grid-flow-col auto-cols-max gap-4 justify-center items-center text-slate-500"
|
||||
>
|
||||
<span
|
||||
class="rounded-lg border-t-2 border-l-2 border-slate-600 dark:border-slate-50 w-4 h-4 block animate-spin"
|
||||
/>
|
||||
Waiting for a response...
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-primary btn-outline text-base font-normal mt-4"
|
||||
on:click={cancelConnection}
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
<script lang="ts">
|
||||
import clipboardCopy from 'clipboard-copy'
|
||||
import QRCode from 'qrcode-svg'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { createAccountLinkingProducer } from '$lib/auth/linking'
|
||||
import { filesystemStore, sessionStore, theme } from '../../../stores'
|
||||
import { getBackupStatus, setBackupStatus } from '$lib/auth/backup'
|
||||
import ClipboardIcon from '$components/icons/ClipboardIcon.svelte'
|
||||
|
||||
let view: 'backup-device' | 'delegate-account' = 'backup-device'
|
||||
|
||||
let connectionLink = null
|
||||
let qrcode = null
|
||||
|
||||
let fs = null
|
||||
let backupCreated = true
|
||||
|
||||
let pin: number[]
|
||||
let pinInput = ''
|
||||
let pinError = false
|
||||
let confirmPin = () => {}
|
||||
let rejectPin = () => {}
|
||||
|
||||
let unsubscribeFilesystemStore = () => {}
|
||||
let unsubscribeSessionStore = () => {}
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeFilesystemStore = filesystemStore.subscribe(async val => {
|
||||
fs = val
|
||||
|
||||
if (fs) {
|
||||
const backupStatus = await getBackupStatus(fs)
|
||||
backupCreated = backupStatus.created
|
||||
}
|
||||
})
|
||||
|
||||
unsubscribeSessionStore = sessionStore.subscribe(async val => {
|
||||
const username = val.username
|
||||
|
||||
if (username) {
|
||||
const origin = window.location.origin
|
||||
|
||||
connectionLink = `${origin}/link-device?username=${username}`
|
||||
qrcode = new QRCode({
|
||||
content: connectionLink,
|
||||
color: $theme === 'light' ? '#334155' : '#E2E8F0',
|
||||
background: '#ffffff00'
|
||||
}).svg()
|
||||
|
||||
initAccountLinkingProducer(username)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const initAccountLinkingProducer = async (username: string) => {
|
||||
const accountLinkingProducer = await createAccountLinkingProducer(username)
|
||||
|
||||
accountLinkingProducer.on('challenge', detail => {
|
||||
pin = detail.pin
|
||||
confirmPin = detail.confirmPin
|
||||
rejectPin = detail.rejectPin
|
||||
|
||||
view = 'delegate-account'
|
||||
})
|
||||
|
||||
accountLinkingProducer.on('link', async ({ approved }) => {
|
||||
if (approved) {
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
backupCreated: true
|
||||
}))
|
||||
|
||||
if (fs) {
|
||||
await setBackupStatus(fs, { created: true })
|
||||
|
||||
addNotification("You've connected a backup device!", 'success')
|
||||
goto('/')
|
||||
} else {
|
||||
addNotification(
|
||||
'Missing filesystem. Unable to create a backup device.',
|
||||
'error'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cancelConnection = () => {
|
||||
rejectPin()
|
||||
|
||||
addNotification('The connection attempt was cancelled', 'info')
|
||||
goto('/')
|
||||
}
|
||||
|
||||
const copyLink = async () => {
|
||||
await clipboardCopy(connectionLink)
|
||||
}
|
||||
|
||||
const checkPin = () => {
|
||||
if (pin.join('') === pinInput) {
|
||||
confirmPin()
|
||||
} else {
|
||||
pinError = true
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeFilesystemStore()
|
||||
unsubscribeSessionStore()
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if view === 'backup-device'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id="backup-device-modal"
|
||||
checked
|
||||
class="modal-toggle"
|
||||
/>
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box w-80 relative text-center dark:border-slate-600 dark:border"
|
||||
>
|
||||
<div>
|
||||
<h3 class="pb-1 text-xl font-serif">Connect a backup device</h3>
|
||||
{@html qrcode}
|
||||
<p class="pt-1 mb-8">
|
||||
Scan this code on the new device, or share the connection link.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-outline" on:click={copyLink}>
|
||||
<ClipboardIcon />
|
||||
<span class="ml-2">Copy connection link</span>
|
||||
</button>
|
||||
{#if !backupCreated}
|
||||
<button
|
||||
class="btn btn-xs btn-link text-base text-error font-normal underline mt-4"
|
||||
on:click={() => goto('/backup?view=are-you-sure')}
|
||||
>
|
||||
Skip for now
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if view === 'delegate-account'}
|
||||
<input
|
||||
type="checkbox"
|
||||
id="delegate-account-modal"
|
||||
checked
|
||||
class="modal-toggle"
|
||||
/>
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box w-80 relative text-center dark:border-slate-600 dark:border"
|
||||
>
|
||||
<div>
|
||||
<h3 class="mb-7 text-xl font-serif">
|
||||
A new device would like to connect to your account
|
||||
</h3>
|
||||
<div class="mb-5">
|
||||
<input
|
||||
id="pin"
|
||||
type="text"
|
||||
class="input input-bordered w-full max-w-xs mb-2 rounded-full h-16 font-mono text-3xl text-center tracking-[0.18em] font-light dark:border-slate-300"
|
||||
bind:value={pinInput}
|
||||
/>
|
||||
<label for="pin" class="label">
|
||||
{#if !pinError}
|
||||
<span class="label-text-alt text-slate-500">
|
||||
Enter the connection code to approve the connection.
|
||||
</span>
|
||||
{:else}
|
||||
<span class="label-text-alt text-error">
|
||||
Entered pin does not match a pin from a known device.
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-primary mb-5 w-full" on:click={checkPin}>
|
||||
Approve the connection
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-outline w-full"
|
||||
on:click={cancelConnection}
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { account } from 'webnative'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { createAccountLinkingConsumer } from '$lib/auth/linking'
|
||||
import { loadAccount } from '$lib/common/webnative'
|
||||
|
||||
let accountLinkingConsumer: account.AccountLinkingConsumer
|
||||
|
||||
let loadingFilesystem = false
|
||||
|
||||
let displayPin: string = ''
|
||||
let url = $page.url
|
||||
const username = url.searchParams.get('username')
|
||||
|
||||
// clear the params
|
||||
url.searchParams.delete('username')
|
||||
history.replaceState(null, document.title, url.toString())
|
||||
|
||||
const initAccountLinkingConsumer = async () => {
|
||||
accountLinkingConsumer = await createAccountLinkingConsumer(username)
|
||||
|
||||
accountLinkingConsumer.on('challenge', ({ pin }) => {
|
||||
displayPin = pin.join('')
|
||||
})
|
||||
|
||||
accountLinkingConsumer.on('link', async ({ approved, username }) => {
|
||||
if (approved) {
|
||||
loadingFilesystem = true
|
||||
|
||||
await loadAccount(username)
|
||||
|
||||
addNotification("You're now connected!", 'success')
|
||||
goto('/')
|
||||
} else {
|
||||
addNotification('The connection attempt was cancelled', 'info')
|
||||
goto('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cancelConnection = async () => {
|
||||
addNotification('The connection attempt was cancelled', 'info')
|
||||
|
||||
await accountLinkingConsumer?.cancel()
|
||||
goto('/')
|
||||
}
|
||||
|
||||
initAccountLinkingConsumer()
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||
{#if loadingFilesystem}
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box rounded-lg shadow-sm bg-slate-100 w-80 relative text-center dark:bg-slate-900 dark:border-slate-600 dark:border "
|
||||
>
|
||||
<p class="text-slate-500 dark:text-slate-50">
|
||||
<span
|
||||
class="rounded-lg border-t-2 border-l-2 border-slate-500 dark:border-slate-50 w-4 h-4 inline-block animate-spin mr-1"
|
||||
/>
|
||||
Loading file system...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box w-80 relative text-center dark:border-slate-600 dark:border"
|
||||
>
|
||||
<div class="grid grid-flow-row auto-rows-max gap-7">
|
||||
<h3 class="text-xl font-serif">Connection Requested</h3>
|
||||
<div
|
||||
class="grid grid-flow-row auto-rows-max gap-4 justify-items-center"
|
||||
>
|
||||
{#if displayPin}
|
||||
<span
|
||||
class="btn bg-blue-100 dark:bg-blue-900 hover:bg-blue-100 dark:hover:bg-blue-900 border-0 btn-lg rounded-full text-3xl tracking-[.18em] w-3/4 cursor-default font-mono font-light"
|
||||
>
|
||||
{displayPin}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-md">Enter this code on your connected device.</span>
|
||||
<div
|
||||
class="grid grid-flow-col auto-cols-max gap-4 justify-center items-center text-slate-500"
|
||||
>
|
||||
<span
|
||||
class="rounded-lg border-t-2 border-l-2 border-slate-600 dark:border-slate-50 w-4 h-4 block animate-spin"
|
||||
/>
|
||||
Waiting for a response...
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-primary btn-outline text-base font-normal mt-4"
|
||||
on:click={cancelConnection}
|
||||
>
|
||||
Cancel Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -4,9 +4,10 @@
|
|||
isUsernameValid,
|
||||
isUsernameAvailable,
|
||||
register
|
||||
} from '$lib/common/webnative'
|
||||
} from '$lib/auth/account'
|
||||
import CheckIcon from '$components/icons/CheckIcon.svelte'
|
||||
import XIcon from '$components/icons/XIcon.svelte'
|
||||
import FilesystemActivity from '$components/common/FilesystemActivity.svelte'
|
||||
|
||||
let username: string = ''
|
||||
let usernameValid = true
|
||||
|
|
@ -14,6 +15,8 @@
|
|||
let registrationSuccess = true
|
||||
let checkingUsername = false
|
||||
|
||||
let initializingFilesystem = false
|
||||
|
||||
const checkUsername = async (event: Event) => {
|
||||
const { value } = event.target as HTMLInputElement
|
||||
|
||||
|
|
@ -28,30 +31,17 @@
|
|||
checkingUsername = false
|
||||
}
|
||||
|
||||
let authInProcess = false
|
||||
const registerUser = async () => {
|
||||
authInProcess = true
|
||||
initializingFilesystem = true
|
||||
|
||||
registrationSuccess = await register(username)
|
||||
|
||||
if (!registrationSuccess) authInProcess = false
|
||||
if (!registrationSuccess) initializingFilesystem = false
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if authInProcess}
|
||||
<input type="checkbox" id="initializing" checked class="modal-toggle" />
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box rounded-lg shadow-sm bg-slate-100 w-80 relative text-center dark:bg-slate-900 dark:border-slate-600 dark:border "
|
||||
>
|
||||
<p class="text-slate-500 dark:text-slate-50">
|
||||
<span
|
||||
class="rounded-lg border-t-2 border-l-2 border-slate-500 dark:border-slate-50 w-4 h-4 inline-block animate-spin mr-1"
|
||||
/>
|
||||
Initializing file system...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if initializingFilesystem}
|
||||
<FilesystemActivity activity="Initializing" />
|
||||
{:else}
|
||||
<input type="checkbox" id="register-modal" checked class="modal-toggle" />
|
||||
<div class="modal">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { appName } from '$lib/app-info'
|
||||
import { sessionStore } from '../../../stores'
|
||||
import WelcomeCheckIcon from '$components/icons/WelcomeCheckIcon.svelte'
|
||||
import { appName } from '$lib/app-info'
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="link-device-modal" checked class="modal-toggle" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
export let activity: 'Initializing' | 'Loading'
|
||||
</script>
|
||||
|
||||
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||
<div class="modal">
|
||||
<div
|
||||
class="modal-box rounded-lg shadow-sm bg-slate-100 w-80 relative text-center dark:bg-slate-900 dark:border-slate-600 dark:border "
|
||||
>
|
||||
<p class="text-slate-500 dark:text-slate-50">
|
||||
<span
|
||||
class="rounded-lg border-t-2 border-l-2 border-slate-500 dark:border-slate-50 w-4 h-4 inline-block animate-spin mr-1"
|
||||
/>
|
||||
{activity} file system...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
import { galleryStore } from '../../../stores'
|
||||
import { AREAS, getImagesFromWNFS } from '$lib/gallery'
|
||||
import type { Image } from '$lib/gallery'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||
|
||||
import { deleteImageFromWNFS } from '$lib/gallery'
|
||||
import { galleryStore } from '../../../stores'
|
||||
import type { Gallery, Image } from '$lib/gallery'
|
||||
import { deleteImageFromWNFS } from '$lib/gallery'
|
||||
|
||||
export let image: Image
|
||||
export let isModalOpen: boolean = false
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { getImagesFromWNFS, uploadImageToWNFS } from '$lib/gallery'
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { getImagesFromWNFS, uploadImageToWNFS } from '$lib/gallery'
|
||||
|
||||
/**
|
||||
* Detect when a user drags a file in or out of the dropzone to change the styles
|
||||
|
|
@ -19,8 +19,6 @@
|
|||
|
||||
const files = Array.from(event.dataTransfer.items)
|
||||
|
||||
console.log(`${files.length} file${files.length > 1 ? 's' : ''} dropped`)
|
||||
|
||||
// Iterate over the dropped files and upload them to WNFS
|
||||
await Promise.all(
|
||||
files.map(async (item, index) => {
|
||||
|
|
@ -30,9 +28,7 @@
|
|||
// If the dropped files aren't images, we don't want them!
|
||||
if (!file.type.match('image/*')) {
|
||||
addNotification('Please upload images only', 'error')
|
||||
console.error('Please upload images only')
|
||||
} else {
|
||||
console.log(`file[${index + 1}].name = ${file.name}`)
|
||||
await uploadImageToWNFS(file)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
|
||||
import { themeStore } from '../../stores'
|
||||
import type { Notification } from '$lib/notifications'
|
||||
import CheckThinIcon from '$components/icons/CheckThinIcon.svelte'
|
||||
import XThinIcon from '$components/icons/XThinIcon.svelte'
|
||||
import { theme as themeStore } from '../../stores'
|
||||
import type { Notification } from '$lib/notifications'
|
||||
|
||||
export let notification: Notification
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate'
|
||||
import Notification from '$components/notifications/Notification.svelte'
|
||||
|
||||
import { notificationStore } from '../../stores'
|
||||
import Notification from '$components/notifications/Notification.svelte'
|
||||
</script>
|
||||
|
||||
{#if $notificationStore.length}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { quintOut } from 'svelte/easing'
|
||||
import { fade } from 'svelte/transition'
|
||||
|
||||
import { theme } from '../../stores'
|
||||
import CheckThinIcon from '$components/icons/CheckThinIcon.svelte'
|
||||
import XThinIcon from '$components/icons/XThinIcon.svelte'
|
||||
|
||||
export let kind: 'success' | 'error'
|
||||
export let message: string
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const clearNotification = () => {
|
||||
dispatch('clear')
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="toast"
|
||||
on:click={clearNotification}
|
||||
out:fade={{ duration: 800, easing: quintOut }}
|
||||
>
|
||||
<div
|
||||
class="alert {kind === 'success' ? 'alert-success' : 'alert-error'} text-sm"
|
||||
>
|
||||
<div>
|
||||
{#if kind === 'success'}
|
||||
<CheckThinIcon color={$theme === 'light' ? '#b8ffd3' : '#002e12'} />
|
||||
{:else if kind === 'error'}
|
||||
<XThinIcon color={$theme === 'light' ? '#ffd6d7' : '#fec3c3'} />
|
||||
{/if}
|
||||
<span class="pl-1">{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export const appName = 'Awesome Webnative App'
|
||||
export const appDescription = 'This is another awesome Webnative app.'
|
||||
export const appURL = 'https://webnative.netlify.app'
|
||||
export const appImageURL = `${appURL}/preview.png`
|
||||
|
|
|
|||
|
|
@ -1,75 +1,10 @@
|
|||
import * as webnative from 'webnative'
|
||||
import { setup } from 'webnative'
|
||||
import type FileSystem from 'webnative/fs/index'
|
||||
|
||||
import { asyncDebounce } from '$lib/common/utils'
|
||||
import { asyncDebounce } from '$lib/utils'
|
||||
import { filesystemStore, sessionStore } from '../../stores'
|
||||
import { getBackupStatus } from '$lib/auth/backup'
|
||||
import { AREAS, GALLERY_DIRS } from '$lib/gallery'
|
||||
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
|
||||
|
||||
// runfission.net = staging
|
||||
setup.endpoints({
|
||||
api: 'https://runfission.net',
|
||||
lobby: 'https://auth.runfission.net',
|
||||
user: 'fissionuser.net'
|
||||
})
|
||||
|
||||
let state: webnative.AppState
|
||||
|
||||
// TODO: Add a flag or script to turn debugging on/off
|
||||
setup.debug({ enabled: false })
|
||||
|
||||
export const initialize = async (): Promise<void> => {
|
||||
try {
|
||||
let backupStatus: BackupStatus = null
|
||||
|
||||
state = await webnative.app({ useWnfs: true })
|
||||
|
||||
switch (state.scenario) {
|
||||
case webnative.AppScenario.NotAuthed:
|
||||
sessionStore.set({
|
||||
username: '',
|
||||
authed: false,
|
||||
loading: false,
|
||||
backupCreated: null
|
||||
})
|
||||
break
|
||||
|
||||
case webnative.AppScenario.Authed:
|
||||
backupStatus = await getBackupStatus(state.fs)
|
||||
|
||||
sessionStore.set({
|
||||
username: state.username,
|
||||
authed: state.authenticated,
|
||||
loading: false,
|
||||
backupCreated: backupStatus.created
|
||||
})
|
||||
|
||||
filesystemStore.set(state.fs)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
switch (error) {
|
||||
case webnative.InitialisationError.InsecureContext:
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
loading: false,
|
||||
error: 'Insecure Context'
|
||||
}))
|
||||
break
|
||||
|
||||
case webnative.InitialisationError.UnsupportedBrowser:
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
loading: false,
|
||||
error: 'Unsupported Browser'
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const isUsernameValid = async (username: string): Promise<boolean> => {
|
||||
return webnative.account.isUsernameValid(username)
|
||||
|
|
@ -94,9 +29,8 @@ export const register = async (username: string): Promise<boolean> => {
|
|||
const fs = await webnative.bootstrapRootFileSystem()
|
||||
filesystemStore.set(fs)
|
||||
|
||||
// Create public and private directories for the gallery
|
||||
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PUBLIC]))
|
||||
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PRIVATE]))
|
||||
// TODO Remove if only public and private directories are needed
|
||||
await initializeFilesystem(fs)
|
||||
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
|
|
@ -107,6 +41,16 @@ export const register = async (username: string): Promise<boolean> => {
|
|||
return success
|
||||
}
|
||||
|
||||
/**
|
||||
* Create additional directories and files needed by the app
|
||||
*
|
||||
* @param fs FileSystem
|
||||
*/
|
||||
const initializeFilesystem = async (fs: FileSystem): Promise<void> => {
|
||||
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PUBLIC]))
|
||||
await fs.mkdir(webnative.path.directory(...GALLERY_DIRS[AREAS.PRIVATE]))
|
||||
}
|
||||
|
||||
export const loadAccount = async (username: string): Promise<void> => {
|
||||
await checkDataRoot(username)
|
||||
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import test from 'ava'
|
||||
|
||||
test('my passing test', t => {
|
||||
t.pass()
|
||||
})
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
export function asyncDebounce<A extends unknown[], R>(
|
||||
fn: (...args: A) => Promise<R>,
|
||||
wait: number
|
||||
): (...args: A) => Promise<R> {
|
||||
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
return (...args: A): Promise<R> => {
|
||||
clearTimeout(lastTimeoutId)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const currentTimeoutId = setTimeout(async () => {
|
||||
try {
|
||||
if (currentTimeoutId === lastTimeoutId) {
|
||||
const result = await fn(...args)
|
||||
resolve(result)
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}, wait)
|
||||
|
||||
lastTimeoutId = currentTimeoutId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Util to convert a Uint8Array to a string
|
||||
* @param u8array
|
||||
* @returns string
|
||||
*/
|
||||
export const convertUint8ToString: (u8array: Uint8Array) => string = u8array => {
|
||||
const CHUNK_SZ = 0x8000
|
||||
const c = []
|
||||
for (let i = 0; i < u8array.length; i += CHUNK_SZ) {
|
||||
c.push(String.fromCharCode.apply(null, u8array.subarray(i, i + CHUNK_SZ)))
|
||||
}
|
||||
return c.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new uuid
|
||||
* @returns uuid
|
||||
*/
|
||||
export const uuid: () => string = () =>
|
||||
// @ts-expect-error disable number[] + number warning
|
||||
([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c: number) =>
|
||||
(
|
||||
c ^
|
||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
|
||||
).toString(16)
|
||||
)
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
export type Device = {
|
||||
isMobile: boolean
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import * as uint8arrays from 'uint8arrays'
|
||||
import { get as getStore } from 'svelte/store'
|
||||
|
||||
import * as wn from 'webnative'
|
||||
import { filesystemStore, galleryStore } from '../stores'
|
||||
import { convertUint8ToString } from '$lib/common/utils'
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { filesystemStore, galleryStore } from '../stores'
|
||||
|
||||
export enum AREAS {
|
||||
PUBLIC = 'Public',
|
||||
|
|
@ -60,9 +61,7 @@ export const getImagesFromWNFS: () => Promise<void> = async () => {
|
|||
const cid = isPrivate ? file.header.content.toString() : file.cid.toString()
|
||||
|
||||
// Create a base64 string to use as the image `src`
|
||||
const src = `data:image/jpeg;base64, ${btoa(
|
||||
convertUint8ToString(file.content as Uint8Array)
|
||||
)}`
|
||||
const src = `data:image/jpeg;base64, ${uint8arrays.toString(file.content, 'base64')}`
|
||||
|
||||
return {
|
||||
cid,
|
||||
|
|
@ -90,7 +89,6 @@ export const getImagesFromWNFS: () => Promise<void> = async () => {
|
|||
loading: false,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
galleryStore.update(store => ({
|
||||
...store,
|
||||
loading: false,
|
||||
|
|
@ -132,12 +130,10 @@ export const uploadImageToWNFS: (
|
|||
// Announce the changes to the server
|
||||
await fs.publish()
|
||||
|
||||
console.log(`${image.name} image has been published`)
|
||||
addNotification(`${image.name} image has been published`, 'success')
|
||||
|
||||
} catch (error) {
|
||||
addNotification(error.message, 'error')
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,7 +157,6 @@ export const deleteImageFromWNFS: (name: string) => Promise<void> = async (name)
|
|||
// Announce the changes to the server
|
||||
await fs.publish()
|
||||
|
||||
console.log(`${name} image has been deleted`)
|
||||
addNotification(`${name} image has been deleted`, 'success')
|
||||
|
||||
// Refetch images and update galleryStore
|
||||
|
|
@ -171,7 +166,6 @@ export const deleteImageFromWNFS: (name: string) => Promise<void> = async (name)
|
|||
}
|
||||
} catch (error) {
|
||||
addNotification(error.message, 'error')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import * as webnative from 'webnative'
|
||||
import { setup } from 'webnative'
|
||||
|
||||
import { filesystemStore, sessionStore } from '../stores'
|
||||
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
|
||||
|
||||
// TODO: Add a flag or script to turn debugging on/off
|
||||
setup.debug({ enabled: false })
|
||||
|
||||
export const initialize = async (): Promise<void> => {
|
||||
try {
|
||||
let backupStatus: BackupStatus = null
|
||||
|
||||
const state: webnative.AppState = await webnative.app({ useWnfs: true })
|
||||
|
||||
switch (state.scenario) {
|
||||
case webnative.AppScenario.NotAuthed:
|
||||
sessionStore.set({
|
||||
username: '',
|
||||
authed: false,
|
||||
loading: false,
|
||||
backupCreated: null
|
||||
})
|
||||
break
|
||||
|
||||
case webnative.AppScenario.Authed:
|
||||
backupStatus = await getBackupStatus(state.fs)
|
||||
|
||||
sessionStore.set({
|
||||
username: state.username,
|
||||
authed: state.authenticated,
|
||||
loading: false,
|
||||
backupCreated: backupStatus.created
|
||||
})
|
||||
|
||||
filesystemStore.set(state.fs)
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
switch (error) {
|
||||
case webnative.InitialisationError.InsecureContext:
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
loading: false,
|
||||
error: 'Insecure Context'
|
||||
}))
|
||||
break
|
||||
|
||||
case webnative.InitialisationError.UnsupportedBrowser:
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
loading: false,
|
||||
error: 'Unsupported Browser'
|
||||
}))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import { notificationStore } from '../stores'
|
||||
import { uuid } from '$lib/common/utils'
|
||||
|
||||
export type Notification = {
|
||||
id?: string
|
||||
|
|
@ -22,7 +21,7 @@ export const addNotification: (
|
|||
timeout?: number
|
||||
) => void = (msg, type = 'info', timeout = 5000) => {
|
||||
// uuid for each notification
|
||||
const id = uuid()
|
||||
const id = crypto.randomUUID()
|
||||
|
||||
// adding new notifications to the bottom of the list so they stack from bottom to top
|
||||
notificationStore.update(rest => [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
export function asyncDebounce<A extends unknown[], R>(
|
||||
fn: (...args: A) => Promise<R>,
|
||||
wait: number
|
||||
): (...args: A) => Promise<R> {
|
||||
let lastTimeoutId: ReturnType<typeof setTimeout> | undefined = undefined
|
||||
|
||||
return (...args: A): Promise<R> => {
|
||||
clearTimeout(lastTimeoutId)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const currentTimeoutId = setTimeout(async () => {
|
||||
try {
|
||||
if (currentTimeoutId === lastTimeoutId) {
|
||||
const result = await fn(...args)
|
||||
resolve(result)
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err)
|
||||
}
|
||||
}, wait)
|
||||
|
||||
lastTimeoutId = currentTimeoutId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const extractSearchParam = (url: URL, param: string): string | null => {
|
||||
const val = url.searchParams.get(param)
|
||||
|
||||
// clear the param from the URL
|
||||
url.searchParams.delete(param)
|
||||
history.replaceState(null, document.title, url.toString())
|
||||
|
||||
return val
|
||||
}
|
||||
|
|
@ -1,3 +1,7 @@
|
|||
export type BackupView = 'backup' | 'are-you-sure'
|
||||
|
||||
export type ConnectView = 'connect' | 'open-connected-device'
|
||||
|
||||
export type DelegateAccountView = 'connect-backup-device' | 'delegate-account'
|
||||
|
||||
export type LinkDeviceView = 'link-device' | 'load-filesystem'
|
||||
|
|
@ -1,42 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
import '../global.css'
|
||||
import { appDescription, appName, appURL } from '$lib/app-info'
|
||||
import { initialize } from '$lib/common/webnative'
|
||||
import { deviceStore, sessionStore, theme } from '../stores'
|
||||
import { errorToMessage, type Session } from '$lib/session'
|
||||
import Toast from '$components/notifications/Toast.svelte'
|
||||
import Notifications from '$components/notifications/Notifications.svelte'
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { appDescription, appImageURL, appName, appURL } from '$lib/app-info'
|
||||
import { sessionStore, themeStore } from '../stores'
|
||||
import { errorToMessage } from '$lib/session'
|
||||
import { initialize } from '$lib/init'
|
||||
import Header from '$components/Header.svelte'
|
||||
import Notifications from '$components/notifications/Notifications.svelte'
|
||||
|
||||
let session: Session = null
|
||||
|
||||
sessionStore.subscribe(val => {
|
||||
session = val
|
||||
})
|
||||
|
||||
onMount(() => { setDevice() })
|
||||
|
||||
const setDevice = () => {
|
||||
if (window.innerWidth <= 768) {
|
||||
deviceStore.set({ isMobile: true })
|
||||
} else {
|
||||
deviceStore.set({ isMobile: false })
|
||||
sessionStore.subscribe(session => {
|
||||
if (session.error) {
|
||||
const message = errorToMessage(session.error)
|
||||
addNotification(message, 'error')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const init = async () => {
|
||||
await initialize()
|
||||
}
|
||||
|
||||
const clearNotification = () => {
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
error: null
|
||||
}))
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
|
|
@ -51,14 +33,14 @@
|
|||
<meta property="og:description" content={appDescription} />
|
||||
<meta property="og:url" content={appURL} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content="TODO" />
|
||||
<meta property="og:image" content={appImageURL} />
|
||||
<meta property="og:image:alt" content="WebNative Template" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:width" content="1250" />
|
||||
<meta property="og:image:height" content="358" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={appName} />
|
||||
<meta name="twitter:description" content={appDescription} />
|
||||
<meta name="twitter:image" content="TODO" />
|
||||
<meta name="twitter:image" content={appImageURL} />
|
||||
<meta name="twitter:image:alt" content={appName} />
|
||||
|
||||
<!-- See https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs for description. -->
|
||||
|
|
@ -68,19 +50,8 @@
|
|||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
</svelte:head>
|
||||
|
||||
<svelte:window on:resize={setDevice} />
|
||||
|
||||
<div data-theme={$theme} class="min-h-screen">
|
||||
<div data-theme={$themeStore} class="min-h-screen">
|
||||
<Header />
|
||||
<Notifications />
|
||||
|
||||
{#if session.error}
|
||||
<Toast
|
||||
kind="error"
|
||||
message={errorToMessage(session.error)}
|
||||
on:clear={clearNotification}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import { extractSearchParam } from '$lib/utils'
|
||||
import type { BackupView } from '$lib/views'
|
||||
import Backup from '$components/auth/backup/Backup.svelte'
|
||||
import AreYouSure from '$components/auth/backup/AreYouSure.svelte'
|
||||
import Backup from '$components/auth/backup/Backup.svelte'
|
||||
|
||||
let url = $page.url
|
||||
let view = url.searchParams.get('view') ?? 'backup'
|
||||
|
||||
// clear the params
|
||||
url.searchParams.delete('view')
|
||||
history.replaceState(null, document.title, url.toString())
|
||||
let view = extractSearchParam($page.url, 'view') ?? 'backup'
|
||||
|
||||
const navigate = (event: CustomEvent<{ view: BackupView }>) => {
|
||||
view = event.detail.view
|
||||
|
|
@ -19,5 +16,5 @@
|
|||
{#if view === 'backup'}
|
||||
<Backup on:navigate={navigate} />
|
||||
{:else if view === 'are-you-sure'}
|
||||
<AreYouSure on:navigate={navigate} />
|
||||
<AreYouSure />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
<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'
|
||||
import type { ConnectView } from '$lib/views'
|
||||
|
||||
let view: ConnectView = 'connect'
|
||||
|
||||
const navigate = (event: CustomEvent<{ view: ConnectView }>) => {
|
||||
console.log(event.detail.view)
|
||||
view = event.detail.view
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,123 @@
|
|||
<script lang="ts">
|
||||
import DelegateAccount from '$components/auth/link/DelegateAccount.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import QRCode from 'qrcode-svg'
|
||||
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { createAccountLinkingProducer } from '$lib/auth/linking'
|
||||
import { filesystemStore, sessionStore, themeStore } from '../stores'
|
||||
import { getBackupStatus, setBackupStatus } from '$lib/auth/backup'
|
||||
import ConnectBackupDevice from '$components/auth/delegate-account/ConnectBackupDevice.svelte'
|
||||
import DelegateAccount from '$components/auth/delegate-account/DelegateAccount.svelte'
|
||||
|
||||
import type { DelegateAccountView } from '$lib/views'
|
||||
|
||||
let view: DelegateAccountView = 'connect-backup-device'
|
||||
|
||||
let connectionLink = null
|
||||
let qrcode = null
|
||||
|
||||
let fs = null
|
||||
let backupCreated = true
|
||||
|
||||
let pin: number[]
|
||||
let pinInput = ''
|
||||
let pinError = false
|
||||
let confirmPin = () => {}
|
||||
let rejectPin = () => {}
|
||||
|
||||
let unsubscribeFilesystemStore = () => {}
|
||||
let unsubscribeSessionStore = () => {}
|
||||
|
||||
onMount(() => {
|
||||
unsubscribeFilesystemStore = filesystemStore.subscribe(async val => {
|
||||
fs = val
|
||||
|
||||
if (fs) {
|
||||
const backupStatus = await getBackupStatus(fs)
|
||||
backupCreated = backupStatus.created
|
||||
}
|
||||
})
|
||||
|
||||
unsubscribeSessionStore = sessionStore.subscribe(async val => {
|
||||
const username = val.username
|
||||
|
||||
if (username) {
|
||||
const origin = window.location.origin
|
||||
|
||||
connectionLink = `${origin}/link-device?username=${username}`
|
||||
qrcode = new QRCode({
|
||||
content: connectionLink,
|
||||
color: $themeStore === 'light' ? '#334155' : '#E2E8F0',
|
||||
background: '#ffffff00'
|
||||
}).svg()
|
||||
|
||||
initAccountLinkingProducer(username)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const initAccountLinkingProducer = async (username: string) => {
|
||||
const accountLinkingProducer = await createAccountLinkingProducer(username)
|
||||
|
||||
accountLinkingProducer.on('challenge', detail => {
|
||||
pin = detail.pin
|
||||
confirmPin = detail.confirmPin
|
||||
rejectPin = detail.rejectPin
|
||||
|
||||
view = 'delegate-account'
|
||||
})
|
||||
|
||||
accountLinkingProducer.on('link', async ({ approved }) => {
|
||||
if (approved) {
|
||||
sessionStore.update(session => ({
|
||||
...session,
|
||||
backupCreated: true
|
||||
}))
|
||||
|
||||
if (fs) {
|
||||
await setBackupStatus(fs, { created: true })
|
||||
|
||||
addNotification("You've connected a backup device!", 'success')
|
||||
goto('/')
|
||||
} else {
|
||||
addNotification(
|
||||
'Missing filesystem. Unable to create a backup device.',
|
||||
'error'
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const checkPin = () => {
|
||||
if (pin.join('') === pinInput) {
|
||||
confirmPin()
|
||||
} else {
|
||||
pinError = true
|
||||
}
|
||||
}
|
||||
|
||||
const cancelConnection = () => {
|
||||
rejectPin()
|
||||
|
||||
addNotification('The connection attempt was cancelled', 'info')
|
||||
goto('/')
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribeFilesystemStore()
|
||||
unsubscribeSessionStore()
|
||||
})
|
||||
</script>
|
||||
|
||||
<DelegateAccount />
|
||||
{#if view === 'connect-backup-device'}
|
||||
<ConnectBackupDevice {qrcode} {connectionLink} {backupCreated} />
|
||||
{:else if view === 'delegate-account'}
|
||||
<DelegateAccount
|
||||
bind:pinInput
|
||||
bind:pinError
|
||||
on:cancel={cancelConnection}
|
||||
on:checkpin={checkPin}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import { galleryStore, sessionStore, theme as themeStore } from '../stores'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
import { galleryStore, sessionStore, themeStore } from '../stores'
|
||||
import { AREAS } from '$lib/gallery'
|
||||
import Dropzone from '$components/gallery/upload/Dropzone.svelte'
|
||||
import ImageGallery from '$components/gallery/imageGallery/ImageGallery.svelte'
|
||||
import { AREAS } from '$lib/gallery'
|
||||
|
||||
/**
|
||||
* Tab between the public/private areas and load associated images
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import { goto } from '$app/navigation'
|
||||
import { onDestroy } from 'svelte'
|
||||
|
||||
import { sessionStore } from '../stores'
|
||||
import { appName } from '$lib/app-info'
|
||||
import { sessionStore } from '../stores'
|
||||
import type { Session } from '$lib/session'
|
||||
import Shield from '$components/icons/Shield.svelte'
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,60 @@
|
|||
<script lang="ts">
|
||||
import LinkDevice from '$components/auth/link/LinkDevice.svelte'
|
||||
import type { account } from 'webnative'
|
||||
import { goto } from '$app/navigation'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
import { addNotification } from '$lib/notifications'
|
||||
import { createAccountLinkingConsumer } from '$lib/auth/linking'
|
||||
import { loadAccount } from '$lib/auth/account'
|
||||
import type { LinkDeviceView } from '$lib/views'
|
||||
import FilesystemActivity from '$components/common/FilesystemActivity.svelte'
|
||||
import LinkDevice from '$components/auth/link-device/LinkDevice.svelte'
|
||||
|
||||
import { extractSearchParam } from '$lib/utils'
|
||||
|
||||
let view: LinkDeviceView = 'link-device'
|
||||
|
||||
let accountLinkingConsumer: account.AccountLinkingConsumer
|
||||
let displayPin: string = ''
|
||||
|
||||
const username = extractSearchParam($page.url, 'username')
|
||||
|
||||
const initAccountLinkingConsumer = async () => {
|
||||
accountLinkingConsumer = await createAccountLinkingConsumer(username)
|
||||
|
||||
accountLinkingConsumer.on('challenge', ({ pin }) => {
|
||||
displayPin = pin.join('')
|
||||
})
|
||||
|
||||
accountLinkingConsumer.on('link', async ({ approved, username }) => {
|
||||
if (approved) {
|
||||
view = 'load-filesystem'
|
||||
|
||||
await loadAccount(username)
|
||||
|
||||
addNotification("You're now connected!", 'success')
|
||||
goto('/')
|
||||
} else {
|
||||
addNotification('The connection attempt was cancelled', 'info')
|
||||
goto('/')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const cancelConnection = async () => {
|
||||
addNotification('The connection attempt was cancelled', 'info')
|
||||
|
||||
await accountLinkingConsumer?.cancel()
|
||||
goto('/')
|
||||
}
|
||||
|
||||
initAccountLinkingConsumer()
|
||||
</script>
|
||||
|
||||
<LinkDevice />
|
||||
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
|
||||
|
||||
{#if view === 'link-device'}
|
||||
<LinkDevice pin={displayPin} on:cancel={cancelConnection} />
|
||||
{:else if view === 'load-filesystem'}
|
||||
<FilesystemActivity activity="Loading" />
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@ import type { Writable } from 'svelte/store'
|
|||
import type FileSystem from 'webnative/fs/index'
|
||||
|
||||
import { loadTheme } from '$lib/theme'
|
||||
import type { Device } from '$lib/device'
|
||||
import { AREAS } from '$lib/gallery'
|
||||
import type { Gallery } from '$lib/gallery'
|
||||
import type { Notification } from '$lib/notifications'
|
||||
import type { Session } from '$lib/session'
|
||||
import type { Theme } from '$lib/theme'
|
||||
|
||||
export const theme: Writable<Theme> = writable(loadTheme())
|
||||
export const themeStore: Writable<Theme> = writable(loadTheme())
|
||||
|
||||
export const sessionStore: Writable<Session> = writable({
|
||||
username: null,
|
||||
|
|
@ -21,8 +20,6 @@ export const sessionStore: Writable<Session> = writable({
|
|||
|
||||
export const filesystemStore: Writable<FileSystem | null> = writable(null)
|
||||
|
||||
export const deviceStore: Writable<Device> = writable({ isMobile: true })
|
||||
|
||||
export const galleryStore: Writable<Gallery> = writable({
|
||||
loading: true,
|
||||
publicImages: [],
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -49,4 +49,9 @@ module.exports = {
|
|||
rtl: false,
|
||||
darkTheme: "dark",
|
||||
},
|
||||
purge: {
|
||||
options: {
|
||||
safelist: ['alert-success', 'alert-error', 'alert-info', 'alert-warning']
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue