mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-21 01:12:59 +00:00
stuff
This commit is contained in:
@@ -1,218 +0,0 @@
|
||||
<script>
|
||||
import {serialPort, syncStatus} from "$lib/serial/connection"
|
||||
import {page} from "$app/stores"
|
||||
import {slide, fly} from "svelte/transition"
|
||||
import {canShare, triggerShare} from "$lib/share"
|
||||
|
||||
const training = [
|
||||
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
|
||||
{slug: "chords", title: "ChM - Chords Mastered", icon: "piano"},
|
||||
{slug: "avg-wpm", title: "aWPM - Average Words Per Minute", icon: "avg_pace"},
|
||||
{slug: "sentences", title: "StM - Sentences Mastered", icon: "lyrics"},
|
||||
{slug: "top-wpm", title: "tWPM - Top Words Per Minute", icon: "speed"},
|
||||
{slug: "cm", title: "CM - Concepts Mastered", icon: "cognition"},
|
||||
]
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<a href="/" class="title">amaCC1ng</a>
|
||||
|
||||
<div class="steps">
|
||||
{#each training as { slug, title, icon }}
|
||||
<a
|
||||
href="/train/{slug}/"
|
||||
{title}
|
||||
class="icon train {slug}"
|
||||
class:active={$page.url.pathname === `/train/${slug}/`}>{icon}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if $canShare}
|
||||
<a transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</a>
|
||||
<div transition:slide class="separator" />
|
||||
{/if}
|
||||
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
||||
<PwaStatus />
|
||||
{/await}
|
||||
<a
|
||||
title="Backup & Restore"
|
||||
href="/backup/"
|
||||
class="icon {$syncStatus}"
|
||||
class:active={$page.url.pathname.startsWith("/backup/")}
|
||||
>
|
||||
{#if $syncStatus === "downloading"}
|
||||
backup
|
||||
{:else if $syncStatus === "uploading"}
|
||||
cloud_download
|
||||
{:else}
|
||||
cloud_done
|
||||
{/if}
|
||||
</a>
|
||||
<a
|
||||
href="/config/"
|
||||
title="Device Manager"
|
||||
class="icon connect"
|
||||
class:active={$page.url.pathname.startsWith("/config/")}
|
||||
class:error={$serialPort === undefined}
|
||||
>
|
||||
cable
|
||||
</a>
|
||||
<a href="/stats/" title="Statistics" class="icon account">person</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes sync {
|
||||
0% {
|
||||
scale: 1 1;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
scale: 1 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
86% {
|
||||
scale: 1 1;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
scale: 1 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.uploading::after,
|
||||
.downloading::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform-origin: top;
|
||||
translate: -50% 0;
|
||||
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
|
||||
animation: sync 1s linear infinite;
|
||||
}
|
||||
|
||||
.downloading.active::after,
|
||||
.uploading.active::after {
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.sync.downloading::after {
|
||||
top: 10px;
|
||||
transform-origin: bottom;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
margin-block: 8px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-block: 0;
|
||||
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
aspect-ratio: 1;
|
||||
padding: 4px;
|
||||
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&.error {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.steps {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
display: flex;
|
||||
|
||||
> a.icon {
|
||||
aspect-ratio: unset;
|
||||
margin-inline: -4px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-size: 24px;
|
||||
color: var(--md-sys-on-surface-variant);
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
clip-path: polygon(25% 50%, 0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%);
|
||||
border-radius: 0;
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background: var(--md-sys-color-tertiary);
|
||||
|
||||
&,
|
||||
~ * {
|
||||
translate: 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon.account {
|
||||
font-size: 32px;
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
</style>
|
||||
28
src/lib/popup.ts
Normal file
28
src/lib/popup.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import tippy from "tippy.js"
|
||||
import type {Action} from "svelte/action"
|
||||
import type {ComponentType, SvelteComponent} from "svelte"
|
||||
|
||||
export const popup: Action<HTMLButtonElement, ComponentType> = (node, Component) => {
|
||||
let component: SvelteComponent | undefined
|
||||
let target: HTMLElement | undefined
|
||||
const edit = tippy(node, {
|
||||
interactive: true,
|
||||
trigger: "click",
|
||||
onShow(instance) {
|
||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement
|
||||
target.classList.add("active")
|
||||
component ??= new Component({target})
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy()
|
||||
target?.classList.remove("active")
|
||||
component = undefined
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
edit.destroy()
|
||||
},
|
||||
}
|
||||
}
|
||||
32
src/lib/preferences.ts
Normal file
32
src/lib/preferences.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import {writable} from "svelte/store"
|
||||
import type {Action} from "svelte/action"
|
||||
|
||||
export interface UserPreferences {
|
||||
backup: boolean
|
||||
autoSync: boolean
|
||||
}
|
||||
|
||||
export const userPreferences = writable<UserPreferences>({
|
||||
backup: false,
|
||||
autoSync: true,
|
||||
})
|
||||
|
||||
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => {
|
||||
const unsubscribe = userPreferences.subscribe(it => {
|
||||
node.checked = it[key]
|
||||
})
|
||||
function update() {
|
||||
userPreferences.update(value => {
|
||||
value[key] = node.checked
|
||||
return value
|
||||
})
|
||||
}
|
||||
node.addEventListener("input", update)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe()
|
||||
node.removeEventListener("input", update)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {writable} from "svelte/store"
|
||||
import {get, writable} from "svelte/store"
|
||||
import {CharaDevice} from "$lib/serial/device"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import type {Writable} from "svelte/store"
|
||||
@@ -26,10 +26,11 @@ export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"
|
||||
let device: CharaDevice // @hmr:keep
|
||||
|
||||
export async function initSerial() {
|
||||
syncStatus.set("downloading")
|
||||
device ??= new CharaDevice()
|
||||
const device = get(serialPort) ?? new CharaDevice()
|
||||
await device.ready()
|
||||
serialPort.set(device)
|
||||
|
||||
syncStatus.set("downloading")
|
||||
const parsedLayout: CharaLayout = [[], [], []]
|
||||
for (let layer = 1; layer <= 3; layer++) {
|
||||
for (let i = 0; i < 90; i++) {
|
||||
|
||||
@@ -5,8 +5,12 @@ import {chordFromCommandCompatible} from "$lib/serial/chord"
|
||||
|
||||
export const VENDOR_ID = 0x239a
|
||||
|
||||
export async function hasSerialPermission() {
|
||||
return navigator.serial.getPorts().then(it => it.length > 0)
|
||||
export async function getViablePorts(): Promise<SerialPort[]> {
|
||||
return navigator.serial.getPorts().then(ports => ports.filter(it => it.getInfo().usbVendorId === VENDOR_ID))
|
||||
}
|
||||
|
||||
export async function canAutoConnect() {
|
||||
return getViablePorts().then(it => it.length === 1)
|
||||
}
|
||||
|
||||
export class CharaDevice {
|
||||
@@ -22,10 +26,11 @@ export class CharaDevice {
|
||||
deviceId: Promise<string>
|
||||
|
||||
constructor(baudRate = 115200) {
|
||||
this.port = navigator.serial.getPorts().then(async ports => {
|
||||
this.port = getViablePorts().then(async ports => {
|
||||
const port =
|
||||
ports.find(it => it.getInfo().usbVendorId === VENDOR_ID) ??
|
||||
(await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]}))
|
||||
ports.length === 1
|
||||
? ports[0]
|
||||
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]})
|
||||
await port.open({baudRate})
|
||||
const info = port.getInfo()
|
||||
serialLog.update(it => {
|
||||
@@ -91,6 +96,21 @@ export class CharaDevice {
|
||||
}
|
||||
}
|
||||
|
||||
async ready() {
|
||||
await this.port
|
||||
}
|
||||
|
||||
async forget() {
|
||||
await (await this.port).forget()
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
this.abortController1.abort()
|
||||
this.abortController2.abort()
|
||||
;(await this.reader).releaseLock()
|
||||
await (await this.port).close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write to serial port
|
||||
*/
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
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))
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
$padding: 16px;
|
||||
|
||||
.tippy-box[data-theme~="surface-variant"] {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
filter: drop-shadow(0 0 12px #000a);
|
||||
border-radius: calc(24px + $padding);
|
||||
|
||||
.tippy-content {
|
||||
padding: $padding;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-block-start: 8px;
|
||||
margin-block-end: calc(8px + $padding);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@each $placement in top, bottom, right, left {
|
||||
&[data-placement^="#{$placement}"] > .tippy-arrow::before {
|
||||
|
||||
64
src/lib/style/toggle.scss
Normal file
64
src/lib/style/toggle.scss
Normal file
@@ -0,0 +1,64 @@
|
||||
$padding: 3px;
|
||||
$border: 2px;
|
||||
$height: 1.5em;
|
||||
|
||||
label:has(input[type="checkbox"]) {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: $padding;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
font-size: 12px;
|
||||
|
||||
input {
|
||||
$width: calc($height * (5 / 3));
|
||||
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
|
||||
$radius: calc($diameter / 2);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
|
||||
width: $width;
|
||||
height: $height;
|
||||
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
|
||||
border-radius: calc($height / 2);
|
||||
outline: $border solid currentcolor;
|
||||
outline-offset: calc(-1 * $border);
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
top: calc($padding + $border);
|
||||
left: calc($padding + $border);
|
||||
|
||||
display: block;
|
||||
|
||||
width: $diameter;
|
||||
height: $diameter;
|
||||
|
||||
border-radius: calc($radius);
|
||||
outline-color: inherit;
|
||||
outline-style: solid;
|
||||
outline-width: $radius;
|
||||
outline-offset: calc(-1 * $radius);
|
||||
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
&:checked::after {
|
||||
translate: calc($width - 2 * $diameter - $padding / 2) 0;
|
||||
outline-width: calc($width - ($height - $border) + $padding);
|
||||
outline-offset: calc($padding / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user