mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-05 01:22:50 +00:00
feat: de-clutter navbar
fix: backup option not working refactor: persistent writable stores [deploy]
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
|
||||
|
||||
@@ -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
17
src/lib/storage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
button.primary {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -82,10 +82,6 @@
|
||||
mask: url("/browsers/googlechrome.svg");
|
||||
}
|
||||
|
||||
&.brave::before {
|
||||
mask: url("/browsers/brave.svg");
|
||||
}
|
||||
|
||||
&.edge::before {
|
||||
mask: url("/browsers/microsoftedge.svg");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
109
src/routes/Profile.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Brave</title><path d="M15.68 0l2.096 2.38s1.84-.512 2.709.358c.868.87 1.584 1.638 1.584 1.638l-.562 1.381.715 2.047s-2.104 7.98-2.35 8.955c-.486 1.919-.818 2.66-2.198 3.633-1.38.972-3.884 2.66-4.293 2.916-.409.256-.92.692-1.38.692-.46 0-.97-.436-1.38-.692a185.796 185.796 0 01-4.293-2.916c-1.38-.973-1.712-1.714-2.197-3.633-.247-.975-2.351-8.955-2.351-8.955l.715-2.047-.562-1.381s.716-.768 1.585-1.638c.868-.87 2.708-.358 2.708-.358L8.321 0h7.36zm-3.679 14.936c-.14 0-1.038.317-1.758.69-.72.373-1.242.637-1.409.742-.167.104-.065.301.087.409.152.107 2.194 1.69 2.393 1.866.198.175.489.464.687.464.198 0 .49-.29.688-.464.198-.175 2.24-1.759 2.392-1.866.152-.108.254-.305.087-.41-.167-.104-.689-.368-1.41-.741-.72-.373-1.617-.69-1.757-.69zm0-11.278s-.409.001-1.022.206-1.278.46-1.584.46c-.307 0-2.581-.434-2.581-.434S4.119 7.152 4.119 7.849c0 .697.339.881.68 1.243l2.02 2.149c.192.203.59.511.356 1.066-.235.555-.58 1.26-.196 1.977.384.716 1.042 1.194 1.464 1.115.421-.08 1.412-.598 1.776-.834.364-.237 1.518-1.19 1.518-1.554 0-.365-1.193-1.02-1.413-1.168-.22-.15-1.226-.725-1.247-.95-.02-.227-.012-.293.284-.851.297-.559.831-1.304.742-1.8-.089-.495-.95-.753-1.565-.986-.615-.232-1.799-.671-1.947-.74-.148-.068-.11-.133.339-.175.448-.043 1.719-.212 2.292-.052.573.16 1.552.403 1.632.532.079.13.149.134.067.579-.081.445-.5 2.581-.541 2.96-.04.38-.12.63.288.724.409.094 1.097.256 1.333.256s.924-.162 1.333-.256c.408-.093.329-.344.288-.723-.04-.38-.46-2.516-.541-2.961-.082-.445-.012-.45.067-.579.08-.129 1.059-.372 1.632-.532.573-.16 1.845.009 2.292.052.449.042.487.107.339.175-.148.069-1.332.508-1.947.74-.615.233-1.476.49-1.565.986-.09.496.445 1.241.742 1.8.297.558.304.624.284.85-.02.226-1.026.802-1.247.95-.22.15-1.413.804-1.413 1.169 0 .364 1.154 1.317 1.518 1.554.364.236 1.355.755 1.776.834.422.079 1.08-.4 1.464-1.115.384-.716.039-1.422-.195-1.977-.235-.555.163-.863.355-1.066l2.02-2.149c.341-.362.68-.546.68-1.243 0-.697-2.695-3.96-2.695-3.96s-2.274.436-2.58.436c-.307 0-.972-.256-1.585-.461-.613-.205-1.022-.206-1.022-.206z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
Reference in New Issue
Block a user