feat: de-clutter navbar

fix: backup option not working
refactor: persistent writable stores

[deploy]
This commit is contained in:
2023-07-29 22:50:18 +02:00
parent 7d148d0c2c
commit 4cc9462655
16 changed files with 166 additions and 244 deletions

View File

@@ -9,6 +9,16 @@ const de = {
DOWNLOAD: "Kopie Speichern",
RESTORE: "Wiederherstellen",
},
profile: {
TITLE: "Profil",
LANGUAGE: "Sprache",
theme: {
TITLE: "Darstellung",
COLOR_SCHEME: "Farbschema",
DARK_MODE: "Dunkel",
LIGHT_MODE: "Hell",
},
},
deviceManager: {
TITLE: "Gerät",
AUTO_CONNECT: "Automatisch Verbinden",

View File

@@ -8,6 +8,16 @@ const en = {
DOWNLOAD: "Download Backup",
RESTORE: "Restore",
},
profile: {
TITLE: "Profile",
LANGUAGE: "Language",
theme: {
TITLE: "Theme",
COLOR_SCHEME: "Color scheme",
DARK_MODE: "Dark",
LIGHT_MODE: "Light",
},
},
deviceManager: {
TITLE: "Device",
AUTO_CONNECT: "Auto-connect",

View File

@@ -1,17 +1,17 @@
import {writable} from "svelte/store"
import type {Action} from "svelte/action"
import {persistentWritable} from "$lib/storage"
export interface UserPreferences {
backup: boolean
autoConnect: boolean
}
export const theme = writable({
export const theme = persistentWritable("user-theme", {
color: "#6D81C7",
mode: "dark" as "light" | "dark" | "auto",
})
export const userPreferences = writable<UserPreferences>({
export const userPreferences = persistentWritable<UserPreferences>("user-preferences", {
backup: false,
autoConnect: true,
})

View File

@@ -3,6 +3,8 @@ import {CharaDevice} from "$lib/serial/device"
import type {Chord} from "$lib/serial/chord"
import type {Writable} from "svelte/store"
import type {CharaLayout} from "$lib/serialization/layout"
import {persistentWritable} from "$lib/storage"
import {userPreferences} from "$lib/preferences"
export const serialPort = writable<CharaDevice>()
@@ -13,9 +15,13 @@ export interface SerialLogEntry {
export const serialLog = writable<SerialLogEntry[]>([])
export const chords = writable<Chord[]>([])
export const chords = persistentWritable<Chord[]>("chord-library", [], () => get(userPreferences).backup)
export const layout = writable<CharaLayout>([[], [], []])
export const layout = persistentWritable<CharaLayout>(
"layout",
[[], [], []],
() => get(userPreferences).backup,
)
export const settings = writable({})

View File

@@ -1,33 +0,0 @@
import {chords, layout} from "$lib/serial/connection"
import {userPreferences} from "$lib/preferences"
const PROFILE_KEY = "profiles"
const CHORD_LIBRARY_STORAGE_KEY = "chord-library"
const LAYOUT_STORAGE_KEY = "layouts"
const PREFERENCES = "user-preferences"
export function initLocalStorage() {
const storedPreferences = localStorage.getItem(PREFERENCES)
if (storedPreferences) {
userPreferences.set(JSON.parse(storedPreferences))
}
userPreferences.subscribe(preferences => {
localStorage.setItem(PREFERENCES, JSON.stringify(preferences))
})
const storedLayout = localStorage.getItem(LAYOUT_STORAGE_KEY)
if (storedLayout) {
layout.set(JSON.parse(storedLayout))
}
const storedChords = localStorage.getItem(CHORD_LIBRARY_STORAGE_KEY)
if (storedChords) {
chords.set(JSON.parse(storedChords))
}
layout.subscribe(layout => {
localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify(layout))
})
chords.subscribe(chords => {
localStorage.setItem(CHORD_LIBRARY_STORAGE_KEY, JSON.stringify(chords))
})
}

17
src/lib/storage.ts Normal file
View File

@@ -0,0 +1,17 @@
import type {Writable} from "svelte/store"
import {writable} from "svelte/store"
import {browser} from "$app/environment"
export function persistentWritable<T>(key: string, value: T, condition?: () => boolean): Writable<T> {
if (browser) {
const persistedValue = localStorage.getItem(key)
const store = persistedValue !== null ? writable(JSON.parse(persistedValue)) : writable(value)
store.subscribe(value => {
if (!condition || condition()) localStorage.setItem(key, JSON.stringify(value))
})
return store
} else {
return writable(value)
}
}

View File

@@ -25,9 +25,9 @@ $padding: 16px;
}
.tippy-box[data-theme~="search-completion"] {
overflow: hidden;
filter: none;
border-radius: 0 0 16px 16px;
overflow: hidden;
.tippy-content {
padding: 0;

View File

@@ -12,7 +12,6 @@
import {pwaInfo} from "virtual:pwa-info"
import type {LayoutServerData} from "./$types"
import type {RegisterSWOptions} from "vite-plugin-pwa/types"
import {initLocalStorage} from "$lib/serial/storage"
import {browser} from "$app/environment"
import BrowserWarning from "./BrowserWarning.svelte"
import "tippy.js/animations/shift-away.css"
@@ -47,7 +46,6 @@
const dark = it.mode === "dark" // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, {target: document.body, dark})
})
initLocalStorage()
if (pwaInfo) {
const {registerSW} = await import("virtual:pwa-register")

View File

@@ -28,7 +28,7 @@
URL.revokeObjectURL(downloadUrl)
}
async function restoreBackup(event: InputEvent) {
async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0]
if (!input) return
const backup = await parseCompressed<Backup>(input)
@@ -119,10 +119,10 @@
border-radius: 32px;
transition: all 250ms ease;
}
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
button.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -82,10 +82,6 @@
mask: url("/browsers/googlechrome.svg");
}
&.brave::before {
mask: url("/browsers/brave.svg");
}
&.edge::before {
mask: url("/browsers/microsoftedge.svg");
}

View File

@@ -1,18 +0,0 @@
<script lang="ts">
import {locales} from "../i18n/i18n-util"
import type {Locales} from "../i18n/i18n-types"
import {loadLocaleAsync} from "../i18n/i18n-util.async"
import {setLocale} from "../i18n/i18n-svelte"
async function applyLocale(locale: Locales) {
localStorage.setItem("locale", locale)
await loadLocaleAsync(locale)
setLocale(locale)
}
</script>
<ul>
{#each locales as locale}
<li><button on:click={() => applyLocale(locale)}>{locale}</button></li>
{/each}
</ul>

View File

@@ -9,9 +9,8 @@
import {canAutoConnect} from "$lib/serial/device"
import {browser} from "$app/environment"
import {userPreferences} from "$lib/preferences"
import Theme from "./Theme.svelte"
import Languages from "./Languages.svelte"
import LL from "../i18n/i18n-svelte"
import Profile from "./Profile.svelte"
const training = [
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
@@ -45,7 +44,7 @@
<div class="actions">
{#if $canShare}
<a transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</a>
<button transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</button>
<div transition:slide class="separator" />
{/if}
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
@@ -64,7 +63,6 @@
{/if}
</button>
{/if}
<button class="icon" use:popup={Languages}>translate</button>
<button
bind:this={connectButton}
title="Devices"
@@ -74,8 +72,7 @@
>
cable
</button>
<button title="Theme" use:popup={Theme} class="icon">format_paint</button>
<a href="/stats/" title="Statistics" class="icon account">person</a>
<button title={$LL.profile.TITLE()} use:popup={Profile} class="icon account">person</button>
</div>
</nav>

109
src/routes/Profile.svelte Normal file
View File

@@ -0,0 +1,109 @@
<script lang="ts">
import LL, {setLocale} from "../i18n/i18n-svelte"
import {theme} from "$lib/preferences"
import {tick} from "svelte"
import {detectLocale, locales} from "../i18n/i18n-util"
import {loadLocaleAsync} from "../i18n/i18n-util.async"
import type {Locales} from "../i18n/i18n-types"
let locale = (localStorage.getItem("locale") as Locales) || detectLocale()
$: (async () => {
localStorage.setItem("locale", locale)
await loadLocaleAsync(locale)
setLocale(locale)
})()
</script>
<section>
<h2>{$LL.profile.TITLE()}</h2>
<fieldset>
<legend>
<span class="icon">format_paint</span>
{$LL.profile.theme.TITLE()}
</legend>
<input title={$LL.profile.theme.COLOR_SCHEME()} type="color" bind:value={$theme.color} />
<button
title={$theme.mode === "light" ? $LL.profile.theme.LIGHT_MODE() : $LL.profile.theme.DARK_MODE()}
class="icon"
on:click={() => {
document.startViewTransition(async () => {
$theme.mode = $theme.mode === "light" ? "dark" : "light"
await tick()
})
}}
>
{#if $theme.mode === "light"}
light_mode
{:else if $theme.mode === "dark"}
dark_mode
{:else}
TODO
{/if}
</button>
</fieldset>
<fieldset>
<legend>
<span class="icon">translate</span>
{$LL.profile.LANGUAGE()}
</legend>
{#each locales as code}
<label>{code}<input bind:group={locale} type="radio" value={code} name="language" /></label>
{/each}
</fieldset>
</section>
<style lang="scss">
h2 {
grid-column: 1 / span 2;
}
section {
display: grid;
grid-template-columns: auto auto;
min-width: 300px;
}
fieldset {
display: flex;
justify-content: space-around;
border: 1px solid var(--md-sys-color-outline);
border-radius: 16px;
}
legend {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
}
button,
input[type="color"] {
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
inline-size: 24px;
block-size: 24px;
margin: 0;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: 50%;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
}
}
</style>

View File

@@ -1,61 +0,0 @@
<script>
import {theme} from "$lib/preferences"
import {tick} from "svelte"
</script>
<section>
<input type="color" bind:value={$theme.color} />
<button
class="icon"
on:click={() => {
document.startViewTransition(async () => {
$theme.mode = $theme.mode === "light" ? "dark" : "light"
await tick()
})
}}
>
{#if $theme.mode === "light"}
light_mode
{:else if $theme.mode === "dark"}
dark_mode
{:else}
TODO
{/if}
</button>
</section>
<style lang="scss">
section {
display: flex;
gap: 16px;
}
button,
input[type="color"] {
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
inline-size: 24px;
block-size: 24px;
margin: 0;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: 50%;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
&::-webkit-color-swatch {
border: none;
}
}
</style>

View File

@@ -1,108 +0,0 @@
<script lang="ts">
import {getSharableUrl, parseCompressed, stringifyCompressed} from "$lib/serial/serialization"
import {chords, layout} from "$lib/serial/connection"
import type {Chord} from "$lib/serial/chord"
import type {CharaLayout} from "$lib/serialization/layout"
interface CharaBackup {
isCharaBackup: "v1.0"
chords: Chord[]
layout: CharaLayout
}
async function downloadBackup() {
const downloadUrl = URL.createObjectURL(
await stringifyCompressed<CharaBackup>({
isCharaBackup: "v1.0",
chords: $chords,
layout: $layout,
}),
)
const element = document.createElement("a")
element.setAttribute("download", "chords.chb")
element.href = downloadUrl
element.setAttribute("target", "_blank")
element.click()
URL.revokeObjectURL(downloadUrl)
}
async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0]
if (!input) return
const backup = await parseCompressed<CharaBackup>(input)
if (backup.isCharaBackup !== "v1.0") throw new Error("Invalid Backup")
if (backup.chords) {
$chords = backup.chords
}
if (backup.layout) {
$layout = backup.layout
}
}
async function createShareUrl() {
console.log(await getSharableUrl("chords", $chords))
}
</script>
<section>
<h1>Backup & Restore</h1>
<p class="disclaimer">
<i
>We automatically backup your device settings. Backups remain on your computer and are never shared or
uploaded to our servers.</i
>
</p>
<div class="save">
<button class="primary" on:click={downloadBackup}><span class="icon">save</span> Download Backup</button>
<label class="button"
><input on:input={restoreBackup} type="file" /><span class="icon">settings_backup_restore</span> Restore</label
>
</div>
</section>
<style lang="scss">
.disclaimer {
max-width: 16cm;
font-size: 12px;
opacity: 0.7;
}
input[type="file"] {
display: none;
}
.save {
display: flex;
gap: 4px;
}
.button,
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding-block: 8px;
padding-inline: 16px;
font-family: "Noto Sans Mono", monospace;
font-weight: 600;
color: var(--md-sys-color-on-background);
background: transparent;
border: none;
border-radius: 32px;
transition: all 250ms ease;
}
button.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>