1 Commits

Author SHA1 Message Date
John de St. Germain
d2934fc2ca Fix typo 2024-09-09 15:52:50 -05:00
69 changed files with 1583 additions and 4022 deletions

View File

@@ -1,9 +1,13 @@
name: Build
on: [push]
on:
push:
tags:
- "v*"
workflow_dispatch:
jobs:
build:
CI:
name: 🔨🚀 Build and deploy
runs-on: ubuntu-latest
steps:
@@ -20,11 +24,11 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: 🐉 Use Node.js 22.4.x
version: 8
- name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3
with:
node-version: 22.4.x
node-version: 18.16.x
cache: "pnpm"
- name: ⏬ Install Node dependencies
run: pnpm install
@@ -34,17 +38,17 @@ jobs:
- name: 🔨 Build site
run: pnpm build
- name: Setup SSH
run: |
install -m 600 -D /dev/null ~/.ssh/id_rsa
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Publish Stable
if: ${{ github.ref == 'refs/tags/v*' }}
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
- name: Publish Branch
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
- name: Publish Commit
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}
- name: 📦 Upload build artifacts
uses: actions/upload-artifact@v3.1.2
with:
name: build
path: build
- name: Disable jekyll
run: touch build/.nojekyll
- name: Custom domain
run: echo 'manager.charachorder.com' > build/CNAME
- run: git config user.name github-actions
- run: git config user.email github-actions@github.com
- run: git --work-tree build add --all
- run: git commit -m "Automatic Deploy action run by github-actions"
- run: git push origin HEAD:gh-pages --force

View File

@@ -4,7 +4,6 @@ const config = {
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2",
icons: [
"deployed_code_update",
"adjust",
"add",
"piano",
@@ -19,8 +18,6 @@ const config = {
"update",
"offline_pin",
"warning",
"dangerous",
"check",
"cable",
"person",
"sync",
@@ -68,8 +65,6 @@ const config = {
"bolt",
"undo",
"redo",
"replay",
"reply",
"navigate_before",
"navigate_next",
"print",
@@ -96,10 +91,6 @@ const config = {
"upload_2",
"stat_minus_2",
"stat_2",
"send",
"more_horiz",
"add_reaction",
"stop",
"description",
"add_circle",
"refresh",
@@ -110,9 +101,6 @@ const config = {
"experiment",
"code",
"dictionary",
"developer_board",
"developer_board_off",
"memory",
],
codePoints: {
speed: "e9e4",

View File

@@ -1,11 +1,11 @@
{
"name": "charachorder-device-manager",
"version": "2.2.2",
"version": "1.5.2",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
"node": ">=22.4",
"pnpm": ">=9.4"
"node": ">=18.16",
"pnpm": ">=8.6"
},
"repository": {
"type": "git",
@@ -34,64 +34,58 @@
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.18.2",
"@codemirror/commands": "^6.7.1",
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.3",
"@codemirror/language": "^6.10.2",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.1",
"@fontsource-variable/material-symbols-rounded": "^5.1.3",
"@fontsource-variable/noto-sans-mono": "^5.1.0",
"@lezer/highlight": "^1.2.1",
"@codemirror/view": "^6.29.1",
"@fontsource-variable/material-symbols-rounded": "^5.0.36",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@lezer/highlight": "^1.2.0",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.0",
"@melt-ui/svelte": "^0.83.0",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.7.5",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@sveltejs/adapter-static": "^3.0.2",
"@sveltejs/kit": "^2.5.18",
"@sveltejs/vite-plugin-svelte": "^3.1.1",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.5",
"@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.7",
"@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5",
"@vite-pwa/sveltekit": "^0.6.6",
"autoprefixer": "^10.4.20",
"@vite-pwa/sveltekit": "^0.6.0",
"autoprefixer": "^10.4.19",
"codemirror": "^6.0.1",
"cypress": "^13.13.2",
"d3": "^7.9.0",
"esptool-js": "^0.4.7",
"flexsearch": "^0.7.43",
"fontkit": "^2.0.4",
"fontkit": "^2.0.2",
"glob": "^11.0.0",
"jsdom": "^25.0.1",
"matrix-js-sdk": "^34.9.0",
"jsdom": "^24.1.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.7",
"rxjs": "^7.8.1",
"sass": "^1.80.6",
"socket.io-client": "^4.8.1",
"stylelint": "^16.10.0",
"prettier-plugin-svelte": "^3.2.6",
"sass": "^1.77.8",
"stylelint": "^16.8.1",
"stylelint-config-clean-order": "^6.1.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^13.1.0",
"svelte": "5.1.9",
"svelte-check": "^4.0.5",
"svelte-preprocess": "^6.0.3",
"svelte": "5.0.0-next.221",
"svelte-check": "^3.8.5",
"svelte-preprocess": "^6.0.2",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-mkcert": "^1.17.6",
"vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.20.1",
"vitest": "^2.0.5",
"workbox-window": "^7.1.0"
},
"type": "module"
}

2028
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "2.2.2"
version = "1.5.2"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173",
"distDir": "../build"
},
"package": { "productName": "amacc1ng", "version": "2.2.2" },
"package": { "productName": "amacc1ng", "version": "1.5.2" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

2
src/env.d.ts vendored
View File

@@ -14,8 +14,6 @@ interface ImportMetaEnv {
readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string;
readonly VITE_MATRIX_URL: string;
readonly VITE_FIRMWARE_URL: string;
}
interface ImportMeta {

View File

@@ -17,11 +17,11 @@ const de = {
RELOAD: "Neu laden",
},
backup: {
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
TITLE: "Lokale Kopie",
INDIVIDUAL: "Einzeldateien",
DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles",
DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen",
},
modal: {
@@ -109,7 +109,7 @@ const de = {
},
configure: {
chords: {
TITLE: "Bibliothek",
TITLE: "Akkorde",
HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord",
DUPLICATE: "Akkord existiert bereits",
@@ -131,7 +131,7 @@ const de = {
TITLE: "Layout",
},
settings: {
TITLE: "Gerät",
TITLE: "Einstellungen",
},
},
plugin: {

View File

@@ -13,11 +13,11 @@ const en = {
TITLE: "Update your device",
},
backup: {
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
TITLE: "Local backup",
INDIVIDUAL: "Individual backups",
DISCLAIMER:
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything",
"A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Download Everything",
RESTORE: "Restore",
},
sync: {
@@ -108,7 +108,7 @@ const en = {
},
configure: {
chords: {
TITLE: "Library",
TITLE: "Chords",
HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord",
DUPLICATE: "Chord already exists",
@@ -130,7 +130,7 @@ const en = {
TITLE: "Layout",
},
settings: {
TITLE: "Device",
TITLE: "Settings",
},
},
plugin: {

View File

@@ -1,55 +0,0 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
$props();
let inDirection = $state(0);
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -395,7 +395,7 @@ actions:
350:
id: "KP_6"
keyCode: "Numpad6"
title: Keypad 6 and Rigth Arrow
title: Keypad 6 and Right Arrow
351:
id: "KP_7"
keyCode: "Numpad7"

View File

@@ -3,35 +3,35 @@ col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 }
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }

View File

@@ -1,37 +0,0 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -11,13 +11,11 @@
cursor = false,
keys = false,
children,
ondone,
}: {
replay: ReplayPlayer | Replay;
cursor?: boolean;
keys?: boolean;
children?: Snippet;
ondone?: () => void;
} = $props();
let replayPlayer: ReplayPlayer | undefined = $state();
@@ -63,7 +61,6 @@
const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer;
player.onDone = ondone;
player.start();
apply();
setTimeout(() => {

View File

@@ -18,8 +18,6 @@ export class ReplayPlayer {
private subscribers = new Set<(value: TextToken | undefined) => void>();
onDone?: () => void;
constructor(
readonly replay: Replay,
plugins: ReplayPlugin[] = [],
@@ -39,13 +37,8 @@ export class ReplayPlayer {
if (
this.replayCursor >= this.replay.keys.length &&
this.releaseAt.size === 0
) {
if (this.onDone) {
this.onDone();
}
)
return;
}
const now = performance.now() - this.startTime;
while (
@@ -125,12 +118,7 @@ export class ReplayPlayer {
start(delay = 200): this {
this.replayCursor = 0;
this.stepper = new ReplayStepper([], this.replay.challenge);
if (this.replay.keys.length === 0) {
if (this.onDone) {
this.onDone();
}
return this;
}
if (this.replay.keys.length === 0) return this;
setTimeout(() => {
this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));

View File

@@ -1,10 +1,6 @@
import { ReplayPlayer } from "./player.js";
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
function maybeRound<T>(value: T, round: boolean): T {
return typeof value === "number" && round ? (Math.round(value) as T) : value;
}
export class ReplayRecorder {
private held = new Map<string, [string, number]>();
@@ -43,7 +39,7 @@ export class ReplayRecorder {
this.player.playLiveEvent(event.key, event.code),
);
} else {
const [key, start] = this.held.get(event.code) ?? ["", 0];
const [key, start] = this.held.get(event.code)!;
const delta = event.timeStamp - start;
this.held.delete(event.code);
@@ -54,24 +50,16 @@ export class ReplayRecorder {
}
}
finish(trim = true, round = true) {
finish(trim = true) {
return {
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
finish: maybeRound(
trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
round,
),
start: trim ? this.replay[0]?.[2] : this.start,
finish: trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
keys: this.replay
.map(
([key, code, at, duration]) =>
[
key,
code,
maybeRound(at, round),
maybeRound(duration, round),
] as const,
[key, code, Math.round(at), Math.round(duration)] as const,
)
.sort((a, b) => a[2] - b[2]),
};

View File

@@ -1,71 +0,0 @@
<script lang="ts">
import type { RoomMember } from "matrix-js-sdk";
import { matrixClient, memberColor } from "./chat";
import { theme } from "$lib/preferences";
import { hexFromArgb } from "@material/material-color-utilities";
let { members }: { members: RoomMember[] } = $props();
</script>
<div class="member-list">
{#each members as member (member.userId)}
{@const avatar = member.getMxcAvatarUrl()}
<div class="member">
{#if avatar}
<img
class="avatar"
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
alt={member.name}
width="32"
height="32"
/>
{:else}
{@const color = memberColor(member, $theme)}
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
<div
style:background={hexFromArgb(modeColor.color)}
style:color={hexFromArgb(modeColor.onColor)}
class="avatar avatar-placeholder icon"
>
person
</div>
{/if}
<span>{member.name}</span>
</div>
{/each}
</div>
<style lang="scss">
.avatar {
border-radius: 50%;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.member {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 8px;
overflow-y: auto;
height: 100%;
}
span {
word-break: break-all;
}
</style>

View File

@@ -1,251 +0,0 @@
<script lang="ts">
import type {
EventTimeline,
MatrixEvent,
MsgType,
Room,
RoomEvent,
RoomMember,
RoomMemberEvent,
} from "matrix-js-sdk";
import { onDestroy, onMount, tick } from "svelte";
import { matrixClient } from "./chat";
import MatrixEventComponent from "./events/MatrixEvent.svelte";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { type Socket, io } from "socket.io-client";
import { SvelteMap } from "svelte/reactivity";
let { timeline }: { timeline: EventTimeline } = $props();
const excludeEvents = ["m.reaction", "m.room.redaction"];
let events = $state(
timeline
.getEvents()
.filter((it) => !excludeEvents.includes(it.getType()))
.reverse(),
);
let recorder = $state(new ReplayRecorder());
let showCursor = $state(false);
let timelineElement: HTMLElement = $state()!;
async function onTimeline(
event: MatrixEvent,
room?: Room,
toStartOfTimeline?: boolean,
) {
if (room?.roomId !== timeline.getRoomId()) return;
const sender = event.getSender();
if (sender) {
live.delete(sender);
}
if (excludeEvents.includes(event.getType())) return;
if (toStartOfTimeline) {
events.push(event);
} else {
const needScroll = timelineElement.scrollTop < 20;
events.unshift(event);
if (needScroll) {
await tick();
timelineElement.scroll({
top: 0,
behavior: "smooth",
});
}
}
}
let typing = $state<string[]>([]);
function onTyping(event: MatrixEvent, member: RoomMember) {
typing = event.event.content?.["user_ids"] ?? [];
}
async function send() {
const roomId = timeline.getRoomId();
if (!roomId) return;
const finalText = recorder.player.stepper.text
.map((token) => token.text)
.join("");
const finalRecording = recorder.finish();
if (!finalText) return;
recorder = new ReplayRecorder();
await $matrixClient.sendMessage(roomId, {
msgtype: "m.text" as MsgType.Text,
body: finalText,
// @ts-expect-error
"m.replay": finalRecording,
});
}
function onKey(event: KeyboardEvent) {
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
send();
return;
} else {
recorder.next(event);
}
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
recorder = new ReplayRecorder();
} else {
socket.emit("message", {
timeStamp: event.timeStamp,
type: event.type,
key: event.key,
code: event.code,
username: $matrixClient.getUserId(),
});
}
}
let socket: Socket = $state()!;
let live = new SvelteMap<string, ReplayRecorder>();
onMount(() => {
socket = io("https://srv.charachorder.io");
socket.emit("join", timeline.getRoomId());
socket.on("message", async ({ message }) => {
let userRecorder = live.get(message.username);
if (!userRecorder) {
userRecorder = new ReplayRecorder();
live.set(message.username, userRecorder);
}
await tick();
userRecorder.next(message);
if (userRecorder.player.stepper.text.length === 0) {
live.delete(message.username);
}
});
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
});
onDestroy(() => {
socket?.disconnect();
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
});
</script>
<section>
<div bind:this={timelineElement} class="timeline">
{#each live.entries() as [userId, recorder] (userId)}
{@const roomId = timeline.getRoomId()}
{#if roomId}
{@const room = $matrixClient.getRoom(roomId)}
{@const member = room?.getMember(userId)}
{#if member}
<MatrixEventComponent sender={member} replay={recorder.player} />
{/if}
{/if}
{/each}
{#each events as event, i (event.event["event_id"])}
{@const prev = events[i + 1]}
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
{/each}
</div>
<div class="static-elements">
<div class="indicators"></div>
<div class="input-box">
<button class="icon">add</button>
<div
role="textbox"
tabindex="0"
class="input"
onkeydown={onKey}
onkeyup={onKey}
onfocusin={() => (showCursor = true)}
onfocusout={() => (showCursor = false)}
>
<CharRecorder replay={recorder.player} cursor={showCursor} />
</div>
<button class="icon" onclick={send}>send</button>
</div>
</div>
</section>
<style lang="scss">
$border-radius: 16px;
h2 {
height: min-content;
}
.input {
border: 1px solid var(--md-sys-color-outline);
flex-grow: 1;
cursor: text;
padding: 0.5em;
font-size: 1rem;
border-radius: $border-radius;
text-wrap: wrap;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
&:focus-visible {
outline: none;
}
}
.input-box {
display: flex;
gap: 4px;
padding-block: 8px;
flex-shrink: 0;
width: 100%;
}
.static-elements {
position: relative;
width: 100%;
}
.timeline {
contain: content;
height: auto;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
overflow-x: hidden;
flex-grow: 1;
width: 100%;
}
.back-to-present {
position: fixed;
bottom: 0;
}
.scroll-controls {
position: sticky;
bottom: 0;
min-height: 16px;
background: linear-gradient(
to bottom,
transparent,
var(--md-sys-color-background)
);
}
section {
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: flex-end;
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,109 +0,0 @@
import { derived, writable, type Writable } from "svelte/store";
import type {
ClientEvent,
LoginResponse,
MatrixClient,
RoomMember,
} from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage";
import {
themeFromSourceColor,
argbFromHex,
type CustomColorGroup,
} from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences";
import { MatrixRx } from "./matrix-rx/client";
export const matrixClient: Writable<MatrixClient> = writable();
export const isLoggedIn: Writable<boolean> = writable(false);
export const matrix = derived(
[matrixClient, isLoggedIn],
([matrixClient, isLoggedIn]) =>
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
);
export const currentRoomId = persistentWritable<string | null>(
"currentRoomId",
null,
);
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
export function storeLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
export async function initMatrixClient() {
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
"matrix-js-sdk"
);
const storedLogin = getStoredLogin();
const store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
const cryptoStore = new IndexedDBCryptoStore(
window.indexedDB,
"matrix-crypto",
);
const client = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
console.log("store");
await store.startup();
console.log("cryptoStore");
await cryptoStore.startup();
console.log("client");
await client.startClient();
client.once("sync" as ClientEvent.Sync, () => {
isLoggedIn.set(client.isLoggedIn());
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
storeLogin(await client.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
isLoggedIn.set(client.isLoggedIn());
}
matrixClient.set(client);
console.log("done");
}
export function memberColor(
member: RoomMember,
theme: UserTheme,
): CustomColorGroup {
let hash = 0;
member.userId.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += value.toString(16).padStart(2, "0");
}
return themeFromSourceColor(argbFromHex(theme.color), [
{ value: argbFromHex(color), name: "member", blend: true },
]).customColors.find((c) => c.color.name === "member")!;
}

View File

@@ -1,357 +0,0 @@
<script lang="ts">
import type {
EventTimeline,
MatrixEvent,
MatrixEventEvent,
Relations,
RelationsEvent,
RoomMember,
} from "matrix-js-sdk";
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
import { matrixClient, memberColor } from "../chat";
import { theme } from "$lib/preferences";
import { hexFromArgb } from "@material/material-color-utilities";
import { fade } from "svelte/transition";
import type { Replay } from "$lib/charrecorder/core/types";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
import { onDestroy, onMount } from "svelte";
import { writable } from "svelte/store";
let {
event,
prev,
sender,
replay: replayPlayer,
timeline,
}: {
event?: MatrixEvent;
prev?: MatrixEvent;
sender?: RoomMember | null;
replay?: Replay | ReplayPlayer;
timeline?: EventTimeline;
} = $props();
let toolbarHover = $state(false);
let mainHover = $state(false);
let hover = $derived(toolbarHover || mainHover);
let replay: Replay | undefined = $state();
let reactions: Relations | undefined = $state(
timeline && event?.event.event_id
? timeline
.getTimelineSet()
.relations.getChildEventsForEvent(
event.event.event_id,
"m.annotation",
"m.reaction",
)
: undefined,
);
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
function createRelations() {
if (!timeline || !event?.event.event_id) return;
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
reactions?.off(
"Relations.remove" as RelationsEvent.Remove,
createRelations,
);
reactions = timeline
.getTimelineSet()
.relations.getChildEventsForEvent(
event.event.event_id,
"m.annotation",
"m.reaction",
);
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
reactions?.on(
"Relations.redaction" as RelationsEvent.Redaction,
createRelations,
);
annotations.set(
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
);
console.log("create");
}
onMount(() => {
createRelations();
event?.on(
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
createRelations,
);
});
onDestroy(() => {
event?.off(
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
createRelations,
);
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
reactions?.off(
"Relations.remove" as RelationsEvent.Remove,
createRelations,
);
reactions?.off(
"Relations.redaction" as RelationsEvent.Redaction,
createRelations,
);
});
</script>
<div
class="event"
role="log"
onmouseover={() => (mainHover = true)}
onfocus={() => (mainHover = true)}
onmouseout={() => (mainHover = false)}
onblur={() => (mainHover = false)}
>
{#if event && hover}
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
{/if}
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
{@const color = memberColor(sender, $theme)}
{@const avatarMxc = sender.getMxcAvatarUrl()}
{#if avatarMxc}
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
<img
class="avatar"
src={avatar}
alt={sender.name}
width="32"
height="32"
/>
{:else}
<div
class="avatar avatar-placeholder icon"
style:background={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
style:color={hexFromArgb(
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
)}
>
person
</div>
{/if}
<div
class="sender"
style:color={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
>
<strong>{sender.name}</strong>
{#if replay || replayPlayer}
<div class="dots">
{#each new Array(3) as _, i}
<div
style:animation-delay={i * 0.2 + "s"}
style:background={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
class="dot"
></div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="content">
{#if event}
{#if event.getType() === "m.room.message"}
<MatrixMessageEvent {event} bind:replay />
{:else}
<details>
<summary>{event.getType()}</summary>
<pre>{JSON.stringify(event.event, null, 2)}</pre>
</details>
{/if}
{/if}
{#if replayPlayer}
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
{/if}
</div>
{#if event && hover}
<div
role="toolbar"
tabindex="0"
class="toolbar"
transition:fade={{ duration: 100 }}
onmouseover={() => (toolbarHover = true)}
onfocus={() => (toolbarHover = true)}
onmouseout={() => (toolbarHover = false)}
onblur={() => (toolbarHover = false)}
>
<button class="icon">add_reaction</button>
<button class="icon">reply</button>
{#if event.event.content?.["m.replay"]}
{#if replay}
<button class="icon" onclick={() => (replay = undefined)}>stop</button
>
{:else}
<button
class="icon"
onclick={() => (replay = event.event.content?.["m.replay"])}
>replay</button
>
{/if}
{/if}
<button class="icon">more_horiz</button>
</div>
{/if}
{#if $annotations && $annotations.length > 0}
<div class="reactions">
{#each $annotations as [reaction, events]}
<button class="reaction"
>{reaction} <span class="count">{events.size}</span></button
>
{/each}
</div>
{/if}
</div>
<style lang="scss">
details {
opacity: 0.5;
word-wrap: break-word;
}
pre {
text-wrap: wrap;
word-wrap: break-word;
}
.toolbar {
position: absolute;
top: -26px;
right: 0;
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
padding: 4px;
border-radius: 4px;
display: flex;
z-index: 100;
button {
font-size: 16px;
width: 24px;
height: 24px;
}
}
.dots {
display: flex;
gap: 2px;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1s infinite;
}
.sender,
.avatar {
margin-block: 2px 4px;
}
.avatar {
grid-area: avatar;
width: 32px;
height: 32px;
border-radius: 50%;
translate: 0 2px;
}
div.avatar {
display: flex;
justify-content: center;
align-items: center;
}
.sender {
display: flex;
grid-area: sender;
align-items: center;
gap: 8px;
}
.reactions {
grid-area: reactions;
margin-top: 2px;
display: flex;
gap: 4px;
}
.reaction {
border: 1px solid var(--md-sys-color-outline);
padding: 6px;
border-radius: 6px;
height: 24px;
display: flex;
font-size: 12px;
> .count {
font-size: 10px;
}
}
.event {
display: grid;
position: relative;
padding-inline: 0.5em;
margin-inline: 0.5em;
padding-block: 0.25em;
border-radius: 4px;
grid-template-areas:
"avatar sender date"
"avatar content content"
"none reactions reactions";
grid-template-columns: 32px 1fr auto;
}
.content {
grid-area: content;
text-wrap: wrap;
word-wrap: break-word;
}
.reactions,
.content,
.sender {
margin-inline: 8px;
}
.backdrop {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0.25;
background: var(--md-sys-color-surface-variant);
border-radius: 8px;
}
</style>

View File

@@ -1,56 +0,0 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { Replay } from "$lib/charrecorder/core/types";
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { fade } from "svelte/transition";
import { matrixClient } from "../chat";
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
$props();
</script>
<div>
{#if event.event.content?.msgtype === "m.image"}
<img
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
alt={event.event.content["body"]}
/>
{:else}
<span class="content" style:opacity={replay && 0}
>{event.event.content?.["body"]}</span
>
{/if}
{#if replay}
<div class="replay" out:fade>
<CharRecorder
{replay}
cursor={true}
keys={true}
ondone={() => (replay = undefined)}
/>
</div>
{/if}
</div>
<style lang="scss">
div {
position: relative;
min-height: 1.5em;
}
img {
max-width: 100%;
max-height: 16em;
border-radius: 8px;
}
.content {
transition: opacity 0.2s;
}
.replay {
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -1,71 +0,0 @@
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
import {
filter,
map,
type Observable,
of,
distinctUntilChanged,
merge,
} from "rxjs";
import { fromMatrixClientEvent } from "./events";
function roomListDistinct(prev: Room[], curr: Room[]) {
if (prev.length !== curr.length) return false;
for (let i = 0; i < prev.length; i++) {
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
}
return true;
}
export class MatrixRx {
topLevelRooms$: Observable<Room[]>;
topLevelSpaces$: Observable<Room[]>;
topLevelChats$: Observable<Room[]>;
constructor(private client: MatrixClient) {
this.topLevelRooms$ = merge(
of([]),
fromMatrixClientEvent(client, "Room"),
fromMatrixClientEvent(client, "deleteRoom"),
fromMatrixClientEvent(client, "Room.myMembership"),
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
filter(
([_room, prev, curr]) =>
prev.getStateEvents("m.space.parent").length !==
curr.getStateEvents("m.space.parent").length,
),
),
).pipe(
map(() =>
this.client.getVisibleRooms().filter(
(room) =>
room.getMyMembership() !== "leave" &&
room
.getLiveTimeline()
.getState("f" as Direction.Forward)
?.getStateEvents("m.space.parent").length === 0,
),
),
distinctUntilChanged(roomListDistinct),
);
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
distinctUntilChanged(roomListDistinct),
);
this.topLevelChats$ = this.topLevelRooms$.pipe(
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
distinctUntilChanged(roomListDistinct),
);
}
}
export class SpaceRx {
constructor(
private client: MatrixClient,
private space: Room,
) {}
}

View File

@@ -1,11 +0,0 @@
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
import { fromEvent, type Observable } from "rxjs";
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
client: MatrixClient,
eventName: `${T}`, // hack so we can use strings instead of enums
): Observable<Parameters<ClientEventHandlerMap[T]>> {
return fromEvent(client, eventName) as Observable<
Parameters<ClientEventHandlerMap[T]>
>;
}

View File

@@ -1,85 +0,0 @@
import type {
MatrixClient,
MatrixEvent,
Room,
Direction,
RoomState,
RoomStateEventHandlerMap,
EventType,
} from "matrix-js-sdk";
import { fromMatrixClientEvent } from "./events";
import {
map,
filter,
merge,
startWith,
Observable,
of,
fromEvent,
concat,
defer,
} from "rxjs";
export function matrixRoom$(
client: MatrixClient,
roomId: string | undefined,
): Observable<Room | undefined> {
return merge([
fromMatrixClientEvent(client, "Room").pipe(
filter(([room]) => room.roomId === roomId),
),
fromMatrixClientEvent(client, "deleteRoom").pipe(
filter(([id]) => id === roomId),
),
]).pipe(
startWith([]),
map(() => client.getRoom(roomId) ?? undefined),
);
}
export function roomTimeline$(
client: MatrixClient,
room: Room | undefined,
): Observable<MatrixEvent[] | undefined> {
if (!room) return of(undefined);
const eventTimeline = room.getLiveTimeline();
return fromMatrixClientEvent(client, "Room.timeline").pipe(
filter(
([, eventRoom]) =>
eventRoom !== undefined && eventRoom.roomId === room.roomId,
),
startWith([]),
map(() => eventTimeline.getEvents()),
);
}
export function roomCurrentStateEvents$(
client: MatrixClient,
room: Room,
eventType: EventType | string,
): Observable<MatrixEvent[]> {
return concat(
defer(() =>
of(
room
.getLiveTimeline()
.getState("f" as Direction.Forward)
?.getStateEvents(eventType) ?? [],
),
),
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
filter(([room]) => room.roomId === room.roomId),
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
),
);
}
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
state: RoomState,
eventName: `${T}`,
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
return fromEvent(state, eventName) as Observable<
Parameters<RoomStateEventHandlerMap[T]>
>;
}

View File

@@ -1,19 +0,0 @@
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { filter, map, of, startWith, type Observable } from "rxjs";
import { fromMatrixClientEvent } from "./events";
export function roomTimeline(
client: MatrixClient,
roomId: string | undefined,
): Observable<MatrixEvent[]> {
if (!roomId) return of([]);
const room = client.getRoom(roomId);
if (!room) return of([]);
const eventTimeline = room.getLiveTimeline();
return fromMatrixClientEvent(client, "Room.timeline").pipe(
filter(([, room]) => room?.roomId === roomId),
startWith([]),
map(() => eventTimeline.getEvents()),
);
}

View File

@@ -17,11 +17,5 @@
<style lang="scss">
p {
margin-block: 0;
:global(kbd.icon) {
display: inline-flex;
font-size: inherit;
translate: 0 0.2em;
}
}
</style>

View File

@@ -37,10 +37,6 @@
import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout,
),
M4GR: () =>
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
),
};
</script>
@@ -74,7 +70,7 @@
width: 100%;
height: 100%;
max-height: 20cm;
margin-bottom: 96px;
}
fieldset {

View File

@@ -1,11 +0,0 @@
import { persistentWritable } from "$lib/storage";
interface ChordStats {
level: number;
lastUprank: number;
}
export const chordStats = persistentWritable<Record<string, ChordStats>>(
"chord-stats",
{},
);

View File

@@ -6,14 +6,9 @@ export interface UserPreferences {
autoConnect: boolean;
}
export interface UserTheme {
color: string;
mode: "light" | "dark" | "auto";
}
export const theme = persistentWritable<UserTheme>("user-theme", {
export const theme = persistentWritable("user-theme", {
color: "#6D81C7",
mode: "dark",
mode: "dark" as "light" | "dark" | "auto",
});
export const userPreferences = persistentWritable<UserPreferences>(

View File

@@ -53,13 +53,11 @@ export interface ProgressInfo {
}
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false, withSync = true) {
export async function initSerial(manual = false) {
const device = get(serialPort) ?? new CharaDevice();
await device.init(manual);
serialPort.set(device);
if (withSync) {
await sync();
}
await sync();
}
export async function sync() {

View File

@@ -12,8 +12,7 @@ import { browser } from "$app/environment";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }],
@@ -26,7 +25,6 @@ const KEY_COUNTS = {
LITE: 67,
X: 256,
M4G: 90,
M4GR: 90,
} as const;
if (
@@ -37,13 +35,6 @@ if (
await import("./tauri-serial");
}
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
// @ts-expect-error polyfill
navigator.serial = await import("web-serial-polyfill").then(
({ serial }) => serial,
);
}
export async function getViablePorts(): Promise<SerialPort[]> {
return navigator.serial.getPorts().then((ports) =>
ports.filter((it) => {
@@ -448,95 +439,4 @@ export class CharaDevice {
async getRamBytesAvailable(): Promise<number> {
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
}
async updateFirmware(file: File | Blob): Promise<void> {
while (this.lock) {
await this.lock;
}
let resolveLock: (result: true) => void;
this.lock = new Promise<true>((resolve) => {
resolveLock = resolve;
});
try {
if (this.suspendDebounceId) {
clearTimeout(this.suspendDebounceId);
} else {
await this.wake();
}
serialLog.update((it) => {
it.push({
type: "system",
value: "OTA Update",
});
return it;
});
const writer = this.port.writable!.getWriter();
try {
await writer.write(new TextEncoder().encode(`RST OTA\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST OTA",
});
return it;
});
} finally {
writer.releaseLock();
}
// Wait for the device to be ready
const signal = await this.reader.read();
serialLog.update((it) => {
it.push({
type: "output",
value: signal.value!.trim(),
});
return it;
});
await file.stream().pipeTo(this.port.writable!);
serialLog.update((it) => {
it.push({
type: "input",
value: `...${file.size} bytes`,
});
return it;
});
const result = (await this.reader.read()).value!.trim();
serialLog.update((it) => {
it.push({
type: "output",
value: result!,
});
return it;
});
if (result !== "OTA OK") {
throw new Error(result);
}
const writer2 = this.port.writable!.getWriter();
try {
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
serialLog.update((it) => {
it.push({
type: "input",
value: "RST RESTART",
});
return it;
});
} finally {
writer2.releaseLock();
}
await this.suspend();
} finally {
delete this.lock;
resolveLock!(true);
}
}
}

View File

@@ -9,17 +9,10 @@ export function persistentWritable<T>(
): Writable<T> {
if (browser) {
const persistedValue = localStorage.getItem(key);
let store: Writable<T>;
try {
store =
persistedValue !== null
? writable(JSON.parse(persistedValue))
: writable(value);
} catch (e) {
console.error(e);
} finally {
store = writable(value);
}
const store =
persistedValue !== null
? writable(JSON.parse(persistedValue))
: writable(value);
store.subscribe((value) => {
if (!condition || condition())
localStorage.setItem(key, JSON.stringify(value));

View File

@@ -0,0 +1,12 @@
import {
themeBase,
themeColor,
themeSuccessBase,
} from "$lib/style/theme.server";
import type { LayoutServerLoad } from "./$types";
export const load = (async () => ({
themeSuccessBase,
themeBase,
themeColor,
})) satisfies LayoutServerLoad;

View File

@@ -15,6 +15,7 @@
import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types";
import { browser } from "$app/environment";
import BrowserWarning from "./BrowserWarning.svelte";
import "tippy.js/animations/shift-away.css";
import "tippy.js/dist/tippy.css";
import tippy from "tippy.js";
@@ -107,7 +108,7 @@
</script>
<svelte:head>
{@html webManifestLink}
<!--{@html webManifestLink}-->
<title>{$LL.TITLE()}</title>
<meta name="description" content={$LL.DESCRIPTION()} />
<meta name="theme-color" content={data.themeColor} />
@@ -127,6 +128,10 @@
</PageTransition>
<Footer />
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning />
{/if}
</div>
<style lang="scss">

View File

@@ -1,14 +1,11 @@
import type { LayoutLoad } from "./$types";
import { browser } from "$app/environment";
import { charaFileFromUriComponent } from "$lib/share/share-url";
import { themeBase, themeColor, themeSuccessBase } from "$lib/style/theme";
export const load = (async ({ url, data, fetch }) => {
const importFile = browser && new URLSearchParams(url.search).get("import");
return {
themeSuccessBase,
themeBase,
themeColor,
...data,
importFile: importFile
? await charaFileFromUriComponent(importFile, fetch)
: undefined,

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
</script>
<section>
<h2>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.TITLE()}</label
>
</h2>
<p class="disclaimer">
<i>{$LL.backup.DISCLAIMER()}</i>
</p>
<fieldset>
<legend>{$LL.backup.INDIVIDUAL()}</legend>
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
{$LL.configure.settings.TITLE()}
</button>
</fieldset>
<div class="save">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</section>
<style lang="scss">
h2 {
margin-block-end: 0;
> label {
gap: 10px;
font-size: 24px;
> input {
font-size: 12px;
}
}
}
fieldset {
display: flex;
margin-block: 16px;
border: 1px solid currentcolor;
border-radius: 16px;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: min-content;
}
.disclaimer {
max-width: 16cm;
font-size: 12px;
opacity: 0.7;
}
input[type="file"] {
display: none;
}
.save {
display: flex;
gap: 4px;
}
</style>

View File

@@ -0,0 +1,256 @@
<script lang="ts">
import { initSerial, serialPort } from "$lib/serial/connection";
import { browser } from "$app/environment";
import { slide, fade } from "svelte/transition";
import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte";
import { downloadBackup } from "$lib/backup/backup";
function reboot() {
$serialPort?.reboot();
$serialPort = undefined;
powerDialog = false;
setTimeout(() => {
initSerial();
}, 1000);
}
function bootloader() {
downloadBackup();
$serialPort?.bootloader();
$serialPort = undefined;
rebootInfo = true;
powerDialog = false;
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
}
}
let rebootInfo = $derived($serialPort !== undefined);
let terminal = $state(false);
let powerDialog = $state(false);
</script>
<section>
<div class="row">
<h2>{$LL.deviceManager.TITLE()}</h2>
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></label
>
</div>
{#if $serialPort}
<p transition:slide>
{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}
<br />
Version {$serialPort.version}
</p>
{#if $serialPort.version.toString() !== import.meta.env.VITE_LATEST_FIRMWARE}
<a
href="https://docs.charachorder.com/CharaChorder%20One.html#updating-the-firmware"
>Firmware Update Instructions</a
>
{/if}
<!--<button on:click={updateFirmware}>Update</button>-->
{/if}
{#if browser}
{#if navigator.userAgent.includes("Linux") && !$serialPort}
<div class="linux-info">
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
<p>
In most cases you can simply follow the <a
target="_blank"
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
>Arduino Guide</a
> on serial port permissions.
</p>
<p>Special systems:</p>
<ul>
<li>
<a
target="_blank"
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
>Arch and Arch-based like Manjaro or EndeavourOS</a
>
</li>
<li>
<a
target="_blank"
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
>NixOS</a
>
</li>
<li>
<a
target="_blank"
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
>Gentoo</a
>
</li>
</ul>
</div>
{/if}
{#if rebootInfo}
<p transition:slide>
<b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b>
</p>
{/if}
<div class="row">
{#if $serialPort}
<button
class="secondary"
onclick={() => {
$serialPort?.forget();
$serialPort = undefined;
}}
><span class="icon">usb_off</span
>{$LL.deviceManager.DISCONNECT()}</button
>
{:else}
<button class="error" onclick={connect}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{/if}
<div class="row" style="justify-content: flex-end">
<a
href="/terminal"
title={$LL.deviceManager.TERMINAL()}
class="icon"
class:disabled={$serialPort === undefined}
onclick={() => (terminal = !terminal)}>terminal</a
>
<button
class="icon"
title={$LL.deviceManager.bootMenu.TITLE()}
disabled={$serialPort === undefined}
onclick={() => (powerDialog = !powerDialog)}>settings_power</button
>
</div>
</div>
{#if powerDialog}
<div
class="backdrop"
role="button"
tabindex="-1"
transition:fade={{ duration: 250 }}
onclick={() => (powerDialog = !powerDialog)}
onkeypress={(event) => {
if (event.key === "Enter") powerDialog = !powerDialog;
}}
></div>
<dialog open transition:slide={{ duration: 250 }}>
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
<button onclick={reboot}
><span class="icon">restart_alt</span
>{$LL.deviceManager.bootMenu.REBOOT()}</button
>
<button onclick={bootloader}
><span class="icon">rule_settings</span
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
>
</dialog>
{/if}
{/if}
</section>
<style lang="scss">
h2 {
margin-block: 8px;
}
p {
margin-block: 8px;
}
.linux-info a {
display: inline;
padding-inline: 0;
text-decoration: underline;
}
section {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 300px;
}
.backdrop {
position: absolute;
z-index: 1;
inset: 0;
background: #0005;
border-radius: 40px;
}
dialog {
position: relative;
z-index: 2;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
margin: 0;
margin-block-start: 16px;
padding: 0;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border: none;
border-radius: 32px;
}
.row {
display: flex;
gap: 0;
justify-content: space-between;
width: 100%;
height: fit-content;
}
dialog > * {
margin-inline: 16px;
}
dialog > :first-child {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin: 0;
padding-block: 8px;
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
}
button:active:not(:disabled) {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
}
</style>

View File

@@ -8,25 +8,11 @@
import { loadLocaleAsync } from "$i18n/i18n-util.async";
import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte";
import {
initSerial,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { serialPort } from "$lib/serial/connection";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
);
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
$effect(() => {
if (!browser) return;
localStorage.setItem("locale", locale);
@@ -47,26 +33,6 @@
}
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
alert(
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
);
}
}
function disconnect(event: MouseEvent) {
if (event.shiftKey) {
sync();
} else {
$serialPort?.forget();
$serialPort = undefined;
}
}
let languageSelect: HTMLSelectElement;
</script>
@@ -74,58 +40,39 @@
<ul>
<li>
<a
use:action={{ title: "Branch" }}
href={import.meta.env.VITE_HOMEPAGE_URL}
rel="noreferrer"
target="_blank"><span class="icon">commit</span> v{version}</a
>
</li>
<li>
<a
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
use:action={{ title: "Updates" }}
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Issues</a
>
</li>
<li>
<a href={import.meta.env.VITE_DOCS_URL} rel="noreferrer" target="_blank"
><span class="icon">description</span> Docs</a
>
CCOS {$serialPort?.version ?? "Updates"}
</a>
</li>
</ul>
<div class="sync-box">
<div>
{#if !$serialPort}
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
{:else}
<button
transition:slide={{ axis: "x" }}
onclick={disconnect}
use:action={{
title: "Disconnect<br><kbd class='icon'>shift</kbd> Sync",
}}
><b
>{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}</b
><span class="icon">usb_off</span></button
>
{/if}
{#if $syncStatus !== "done"}
<progress
transition:fade
max={$syncProgress?.max ?? 1}
value={$syncProgress?.current ?? 1}
></progress>
<div class="warning">
<span class="icon">warning</span>{$LL.deviceManager.NO_DEVICE()}
</div>
{/if}
<SyncOverlay />
</div>
<ul>
<li>
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
><span class="icon">bug_report</span> Bugs</a
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
>
</li>
<li>
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
><span class="icon">shopping_bag</span> Store</a
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
><span class="icon">school</span> Train</a
>
</li>
<li class="hide-forced-colors">
@@ -154,7 +101,7 @@
</button>
{/if}
</li>
<!--<li>
<li>
<div
role="button"
class="icon"
@@ -169,7 +116,7 @@
{/each}
</select>
</div>
</li>-->
</li>
</ul>
</footer>
@@ -179,37 +126,6 @@
opacity: 0;
}
.sync-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
button {
text-wrap: nowrap;
}
}
progress {
position: absolute;
z-index: -1;
bottom: 0;
left: 16px;
right: 16px;
overflow: hidden;
width: calc(100% - 32px);
height: 8px;
border-radius: 4px;
}
progress::-webkit-progress-bar {
background: var(--md-sys-color-background);
}
progress::-webkit-progress-value {
background: var(--md-sys-color-primary);
}
.warning {
color: var(--md-sys-color-error);
gap: 8px;

View File

@@ -14,26 +14,27 @@
let isNavigating = $state(false);
const routeOrder = [
"/config",
"/learn",
"/docs",
"/editor",
"/chat",
"/plugin",
"/config/chords/",
"/config/layout/",
"/config/settings/",
];
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => {
outroEnd = resolve;
@@ -48,16 +49,10 @@
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -1,45 +1,36 @@
<script lang="ts">
import { page } from "$app/stores";
import { browser } from "$app/environment";
import { LL } from "$i18n/i18n-svelte";
import { popup } from "$lib/popup";
import { userPreferences } from "$lib/preferences";
import { serialPort, syncStatus } from "$lib/serial/connection";
import { action } from "$lib/title";
import BackupPopup from "./BackupPopup.svelte";
import ConnectionPopup from "./ConnectionPopup.svelte";
import { onMount } from "svelte";
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click();
}
});
const routes = [
[
{
href: "/config/settings/",
icon: "cable",
title: "Device",
primary: true,
},
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
{ href: "/config/chords/", icon: "dictionary", title: "Chords" },
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
{ href: "/config/settings/", icon: "tune", title: "Config" },
],
[
// { href: "/learn", icon: "school", title: "Learn", wip: true },
{
href: import.meta.env.VITE_LEARN_URL,
icon: "school",
title: "Learn",
external: true,
},
{
href: import.meta.env.VITE_DOCS_URL,
icon: "description",
title: "Docs",
external: true,
},
{ href: "/learn", icon: "school", title: "Learn", wip: true },
{ href: "/learn", icon: "description", title: "Docs" },
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true },
],
/*[
[
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
],*/
] satisfies {
href: string;
icon: string;
title: string;
wip?: boolean;
external?: boolean;
primary?: boolean;
}[][];
],
] satisfies { href: string; icon: string; title: string; wip?: boolean }[][];
let connectButton: HTMLButtonElement;
</script>
@@ -48,18 +39,10 @@
<nav>
{#each routes as group}
<ul>
{#each group as { href, icon, title, wip, external }}
{#each group as { href, icon, title, wip }}
<li>
<a
class:wip
{href}
rel={external ? "noreferrer" : undefined}
target={external ? "_blank" : undefined}
class:active={$page.url.pathname.startsWith(href)}
>
<div class="icon">
{icon}
</div>
<a class:wip {href}>
<div class="icon">{icon}</div>
<div class="content">
{title}
</div>
@@ -69,6 +52,28 @@
</ul>
{/each}
</nav>
<ul class="sidebar-footer">
<li>
<button
bind:this={connectButton}
use:action={{ title: $LL.deviceManager.TITLE() }}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
</li>
<li>
<button
use:action={{ title: $LL.backup.TITLE() }}
use:popup={BackupPopup}
class="icon {$syncStatus}"
>
account_circle
</button>
</li>
</ul>
</div>
<style lang="scss">
@@ -104,30 +109,12 @@
display: flex;
justify-content: center;
font-size: 24px;
padding: 8px;
border-radius: 8px;
transition: all 250ms ease;
}
> .content {
display: flex;
justify-content: center;
align-items: center;
translate: 0 -8px;
transition: all 250ms ease;
}
&.active {
> .content {
translate: 0;
}
.icon {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 50%;
}
}
}

View File

@@ -1,15 +0,0 @@
<script lang="ts">
let { children } = $props();
</script>
<h1><a href="/ccos">Firmware Updates</a></h1>
{@render children()}
<style lang="scss">
h1 {
margin-block: 1em;
padding: 0;
font-size: 3em;
}
</style>

View File

@@ -1,80 +0,0 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
import { slide } from "svelte/transition";
let { data } = $props();
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
</script>
<ul>
{#each data.devices as device}
<li>
<a href="./{device.name}/" class:highlight={device.name === currentDevice}
>{device.name}</a
>
</li>
{/each}
</ul>
{#if !currentDevice}
<aside transition:slide>Connect your device to see which one you need</aside>
{/if}
<style lang="scss">
ul {
display: flex;
list-style: none;
gap: 8px;
}
li {
margin: 0;
padding: 0;
}
a {
outline: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
transition:
background-color 200ms ease,
color 200ms ease,
outline-offset 200ms ease,
outline-color 200ms ease;
}
@keyframes highlight {
0% {
outline-offset: 0;
}
100% {
outline-offset: 4px;
}
}
@keyframes wiggle {
0% {
transform: rotate(0deg);
scale: 1;
}
50% {
transform: rotate(-5deg);
}
100% {
transform: rotate(-5deg);
scale: 1.1;
}
}
.highlight {
outline-width: 2px;
outline-color: var(--md-sys-color-primary);
animation: wiggle 500ms ease 2 alternate;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
</style>

View File

@@ -1,9 +0,0 @@
import type { PageLoad } from "./$types";
import type { DirectoryListing } from "./listing";
export const load = (async ({ fetch }) => {
const result = await fetch(import.meta.env.VITE_FIRMWARE_URL);
const data = await result.json();
return { devices: data as DirectoryListing[] };
}) satisfies PageLoad;

View File

@@ -1,85 +0,0 @@
<script lang="ts">
let { data } = $props();
let showPrerelease = $state(false);
</script>
<div class="title">
<h2>Versions available for <em>{data.device}</em></h2>
<label
>Include Pre-releases<input
type="checkbox"
bind:checked={showPrerelease}
/></label
>
</div>
{#if data.versions}
<ul>
{#each data.versions as version}
{@const isPrerelease = version.name.includes("-")}
<li class:pre-release={isPrerelease}>
<a href="./{version.name}/"
>{version.name}
<time datetime={version.mtime}
>{new Date(version.mtime).toLocaleDateString()}</time
></a
>
</li>
{/each}
</ul>
{:else}
<h2>The device {data.device} does not exist.</h2>
{/if}
<style lang="scss">
.pre-release {
margin-inline-start: 2em;
}
ul {
list-style: none;
padding: 0;
}
li {
height: 2em;
overflow: hidden;
transition:
height 200ms ease,
opacity 200ms ease;
}
label {
padding: 0;
opacity: 0.6;
}
.title {
display: flex;
flex-direction: column;
justify-content: flex-start;
h2 {
margin-block-end: 0;
em {
font-style: normal;
color: var(--md-sys-color-primary);
}
}
}
time {
opacity: 0.5;
&:before {
content: "•";
padding-inline: 0.4ch;
}
}
div.title:has(input:not(:checked)) ~ ul .pre-release {
height: 0;
opacity: 0;
}
</style>

View File

@@ -1,16 +0,0 @@
import type { PageLoad } from "./$types";
import type { DirectoryListing } from "../listing";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/`,
);
const data = await result.json();
return {
versions: (data as DirectoryListing[]).sort((a, b) =>
b.name.localeCompare(a.name),
),
device: params.device,
};
}) satisfies PageLoad;

View File

@@ -1,505 +0,0 @@
<script lang="ts">
import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import type { LoaderOptions, ESPLoader } from "esptool-js";
let { data } = $props();
let working = $state(false);
let success = $state(false);
let error = $state<Error | undefined>(undefined);
let terminalOutput = $state("");
let step = $state(0);
let eraseAll = $state(false);
let espLoader;
async function update() {
working = true;
error = undefined;
success = false;
const port = $serialPort!;
$serialPort = undefined;
try {
const file = await fetch(
`${data.meta.path}/${data.meta.update.ota?.name}`,
).then((it) => it.blob());
await port.updateFirmware(file);
success = true;
} catch (e) {
error = e as Error;
} finally {
working = false;
}
}
let currentDevice = $derived(
$serialPort
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
: undefined,
);
let isCorrectDevice = $derived(
currentDevice ? currentDevice === data.meta.target : undefined,
);
/**
* Bytes to respective units
*/
function toByteUnit(value: number) {
if (value < 1024) {
return `${value}B`;
} else if (value < 1024 * 1024) {
return `${(value / 1024).toFixed(2)}KB`;
} else {
return `${(value / 1024 / 1024).toFixed(2)}MB`;
}
}
async function connect() {
try {
await initSerial(true, false);
step = 1;
} catch (e) {
error = e as Error;
}
}
function backup() {
downloadBackup();
step = 2;
}
function bootloader() {
$serialPort?.bootloader();
$serialPort = undefined;
step = 3;
}
async function getFileSystem() {
if (!data.meta.update.uf2) return;
const uf2Promise = fetch(
`${data.meta.path}/${data.meta.update.uf2.name}`,
).then((it) => it.blob());
const handle = await window.showSaveFilePicker({
id: `${data.meta.target}-update`,
suggestedName: "CURRENT.UF2",
excludeAcceptAllOption: true,
types: [
{
description: "UF2 Firmware",
accept: { "application/octet-stream": [".UF2"] },
},
],
});
const writable = await handle.createWritable();
const uf2 = await uf2Promise;
await uf2.stream().pipeTo(writable);
step = 4;
}
async function espBootloader() {
$serialPort?.forget();
const port = await navigator.serial.requestPort();
port.open({ baudRate: 1200 });
}
async function connectEsp(port: SerialPort): Promise<ESPLoader> {
const esptool = data.meta.update.esptool!;
const { Transport, ESPLoader } = await import("esptool-js");
const espLoader = new ESPLoader({
transport: new Transport(port),
baudrate: 9600, // Number(esptool.baud),
romBaudrate: 9600, // Number(esptool.baud),
debugLogging: true,
terminal: {
clean: () => {
terminalOutput = "";
},
writeLine: (data) => {
terminalOutput += data + "\n";
},
write: (data) => {
terminalOutput += data;
},
},
} satisfies LoaderOptions);
await espLoader.detectChip(esptool.before);
if (!espLoader.IS_STUB) {
await espLoader.runStub();
}
return espLoader;
}
async function flashImages() {
const port = await navigator.serial.requestPort();
try {
const esptool = data.meta.update.esptool!;
espLoader = await connectEsp(port);
const fileArray = await Promise.all(
Object.entries(esptool.files).map(([offset, name]) =>
fetch(`${data.meta.path}/${name}`)
.then((it) => it.blob())
.then((it) => it.text())
.then((it) => ({
address: Number(offset),
data: it,
})),
),
);
await espLoader.writeFlash({
flashSize: esptool.flash_size,
flashMode: esptool.flash_mode,
flashFreq: esptool.flash_freq,
compress: true,
eraseAll,
fileArray,
});
} finally {
port.close();
}
}
async function eraseSPI() {
const port = await navigator.serial.requestPort();
try {
console.log(data.meta);
const spiFlash = data.meta.spi_flash!;
espLoader = await connectEsp(port);
/*espLoader.flashSpiAttach(
(spiFlash.connection.clk << 0) |
(spiFlash.connection.q << 8) |
(spiFlash.connection.d << 16) |
(spiFlash.connection.cs << 24),
);
espLoader.flashId();*/
} finally {
port.close();
}
}
</script>
<div class="container">
<h2>
<a class="inline-link" href="/ccos">CCOS</a> /
<a
href="/ccos/{data.meta.target}"
class="device inline-link"
class:correct-device={isCorrectDevice === true}
class:incorrect-device={isCorrectDevice === false}>{data.meta.target}</a
>
/ <em class="version">{data.meta.version}</em>
</h2>
{#if data.meta.update.ota && !data.meta.target.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
class="update-button"
class:working
class:primary={!buttonError}
class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</button
>
{#if $serialPort && isCorrectDevice}
<div transition:slide>
Your
<b
>{$serialPort.company}
{$serialPort.device}
{$serialPort.chipset}</b
>
will be updated from <b class="version">{$serialPort.version}</b> to
<b class="version">{data.meta.version}</b>
</div>
{:else if $serialPort && isCorrectDevice === false}
<div class="error" transition:slide>
Your device is incompatible with the selected update.
</div>
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
<div class="primary" transition:slide>
Connect your device to continue
</div>
{/if}
</section>
<h3>Manual Update</h3>
{/if}
{#if isCorrectDevice === false}
<div transition:slide class="incorrect-device">
These files are incompatible with your device
</div>
{/if}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
{#if data.meta.update.esptool}
<section>
<h3>Factory Flash (WIP)</h3>
<p>
If everything else fails, you can go through the same process that is
being used in the factory.
</p>
<p>
This will temporarily brick your device if the process is not done
completely or incorrectly.
</p>
<div class="esp-buttons">
<button onclick={espBootloader}
><span class="icon">memory</span>ESP Bootloader</button
>
<button onclick={flashImages}
><span class="icon">developer_board</span>Flash Images</button
>
<label
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase All</label
>
<button onclick={eraseSPI}
><span class="icon">developer_board</span>Erase SPI Flash</button
>
</div>
<pre>{terminalOutput}</pre>
</section>
{/if}
</div>
<style lang="scss">
h2 > em {
font-style: normal;
transition: color 200ms ease;
}
h3 {
margin-block-start: 4em;
}
pre {
overflow: auto;
}
.primary {
color: var(--md-sys-color-primary);
}
.error {
color: var(--md-sys-color-error);
}
.container {
width: calc(min(100%, 16cm));
overflow: auto;
}
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button.inline-button {
display: inline;
padding: 0;
margin: 0;
height: unset;
font-size: inherit;
color: var(--md-sys-color-primary);
.icon {
font-size: 1.2em;
translate: 0 0.1em;
padding-inline-end: 0.2em;
}
}
.icon.ok {
font-size: 1.2em;
translate: 0 0.1em;
--icon-fill: 1;
}
.faded {
opacity: 0.8;
}
button.update-button {
overflow: hidden;
position: relative;
height: 42px;
border: 2px solid currentcolor;
border-radius: 8px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
background: var(--md-sys-color-background);
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
margin-block: 16px;
&.primary {
color: var(--md-sys-color-primary);
background: none;
}
&.working {
border-color: transparent;
}
&.working::before {
z-index: -1;
position: absolute;
background: var(--md-sys-color-background);
width: calc(100% - 4px);
height: calc(100% - 4px);
border-radius: 8px;
content: "";
}
&.working::after {
z-index: -2;
position: absolute;
content: "";
background: var(--md-sys-color-primary);
animation: rotate 1s ease-out forwards infinite;
height: 30%;
width: 120%;
}
}
hr {
color: var(--md-sys-color-outline);
margin-block: 3em;
margin-inline: 5em;
border-style: dashed;
}
.files {
list-style: none;
display: flex;
padding: 0;
gap: 8px;
}
a[download] {
display: grid;
grid-template-columns: 1fr auto;
grid-template-rows: 1fr;
border: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
font-size: 0.9em;
height: auto;
.size {
font-size: 0.8em;
opacity: 0.8;
}
.icon {
padding-inline-start: 0.4em;
grid-column: 2;
grid-row: 1 / span 2;
}
}
.version {
color: var(--md-sys-color-secondary);
}
.device {
opacity: 0.6;
}
.inline-link {
display: inline;
padding: 0;
}
.correct-device {
color: var(--md-sys-color-primary);
opacity: 1;
}
.incorrect-device {
color: var(--md-sys-color-error);
}
.esp-buttons {
display: flex;
}
</style>

View File

@@ -1,50 +0,0 @@
import type { PageLoad } from "./$types";
import type { FileListing, Listing } from "../../listing";
import type { VersionMeta } from "./meta";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
);
const data: Listing[] = await result.json();
const meta: VersionMeta | undefined = data.some(
(entry) => entry.type === "file" && entry.name === "meta.json",
)
? await fetch(
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/meta.json`,
).then((res) => res.json())
: undefined;
return {
meta: {
version: meta?.version ?? params.version,
target: meta?.target ?? params.device,
path: `${import.meta.env.VITE_FIRMWARE_URL}${params.device}/${params.version}`,
git_commit: meta?.git_commit ?? "",
git_is_dirty: meta?.git_is_dirty ?? false,
git_date: meta?.git_date ?? data[0]?.mtime ?? "",
public_build: meta?.public_build ?? !params.version.startsWith("."),
development_mode: meta?.development_mode ?? 0,
update: {
uf2:
(data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.uf2 ?? "CURRENT.UF2"),
) as FileListing) ?? undefined,
ota:
data.find(
(entry) =>
entry.type === "file" &&
entry.name === (meta?.update?.ota ?? "firmware.bin"),
) ?? undefined,
esptool: meta?.update?.esptool ?? undefined,
},
files: data.filter(
(entry) =>
entry.type === "file" && (!meta?.files || entry.name in meta.files),
) as FileListing[],
spi_flash: meta?.spi_flash ?? undefined,
},
};
}) satisfies PageLoad;

View File

@@ -1,41 +0,0 @@
export interface VersionMeta {
version: string;
target: string;
git_commit: string;
git_is_dirty: boolean;
git_date: string;
public_build: boolean;
development_mode: number;
update: {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
};
files: string[];
spi_flash: SPIFlashInfo | null;
}
export interface SPIFlashInfo {
type: string;
size: string;
connection: SPIConnection;
}
export interface SPIConnection {
clk: number;
q: number;
d: number;
hd: number;
cs: number;
}
export interface EspToolData {
chip: string;
baud: string;
before: string;
after: string;
flash_mode: string;
flash_freq: string;
flash_size: string;
files: Record<string, string>;
}

View File

@@ -1,14 +0,0 @@
export type Listing = FileListing | DirectoryListing;
export interface DirectoryListing {
name: string;
type: "directory";
mtime: string;
}
export interface FileListing {
name: string;
type: "file";
mtime: string;
size: number;
}

View File

@@ -1,92 +1 @@
<script lang="ts">
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat";
import { flip } from "svelte/animate";
import { slide } from "svelte/transition";
import Login from "./Login.svelte";
import { onMount } from "svelte";
import { browser } from "$app/environment";
onMount(async () => {
if (browser) {
await initMatrixClient();
}
});
let { children } = $props();
let spaces = $derived($matrix?.topLevelSpaces$);
function spaceShort(name: string) {
return name
.split(" ")
.map((it) => it[0])
.join("");
}
</script>
{#if $isLoggedIn}
<div class="layout">
<nav class="spaces">
<a href="/chat/chats" class="icon chats">chat</a>
<hr />
{#if $spaces}
<ul>
{#each $spaces as space (space.roomId)}
<li animate:flip transition:slide>
<a class="space" href="/chat/space/{space.roomId}">
{spaceShort(space.name)}
</a>
</li>
{/each}
</ul>
{/if}
<button class="icon">add</button>
</nav>
</div>
{:else}
<Login />
{/if}
<style lang="scss">
nav {
display: flex;
flex-direction: column;
}
.layout {
display: flex;
height: 100%;
width: 100%;
}
hr {
width: 60%;
height: 1px;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
button,
a {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
width: 56px;
height: 56px;
background: var(--md-sys-color-surface-variant);
}
.chats {
font-size: 24px;
}
.space {
font-size: 20px;
margin-bottom: 8px;
}
</style>
<h2>WIP</h2>

View File

@@ -1,33 +0,0 @@
<script lang="ts">
import { matrixClient } from "$lib/chat/chat";
function passwordLogin() {
// TODO
}
</script>
{#if $matrixClient}
{#await $matrixClient.loginFlows() then flows}
{#each flows.flows as flow}
{#if flow.type === "m.login.sso"}
<a
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
>
{#each flow.identity_providers as idp}
{#if idp.icon}
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
{:else}
{idp.name}
{/if}
{/each}
</a>
{:else if flow.type === "m.login.password"}
<form onsubmit={passwordLogin}>
<input name="username" type="text" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
{/if}
{/each}
{/await}
{/if}

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import type { Snippet } from "svelte";
import PageTransition from "./PageTransition.svelte";
import type { Snippet } from "svelte";
import Navigation from "./Navigation.svelte";
let { children }: { children?: Snippet } = $props();
@@ -9,9 +8,5 @@
<Navigation />
{#if children}
<PageTransition>
{#if children}
{@render children()}
{/if}
</PageTransition>
{@render children()}
{/if}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { page } from "$app/stores";
import LL from "$i18n/i18n-svelte";
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props();
let paths = $derived([
{
href: "/config/chords/",
title: $LL.configure.chords.TITLE(),
icon: "piano",
},
{
href: "/config/layout/",
title: $LL.configure.layout.TITLE(),
icon: "keyboard",
},
{
href: "/config/settings/",
title: $LL.configure.settings.TITLE(),
icon: "settings",
},
]);
</script>
<nav>
{#each paths as { href, title, icon }}
<a {href} class:active={$page.url.pathname.startsWith(href)}>
<span class="icon">{icon}</span>
{title}
</a>
{/each}
</nav>
{#if children}
{@render children()}
{/if}
<style lang="scss">
nav {
display: flex;
gap: 8px;
padding: 8px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
border-radius: 32px;
}
a.active {
--icon-fill: 1;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -3,8 +3,8 @@
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import LL from "$i18n/i18n-svelte";
import ConfigTabs from "./ConfigTabs.svelte";
import EditActions from "./EditActions.svelte";
import { sync, syncStatus } from "$lib/serial/connection";
</script>
<nav>
@@ -12,6 +12,8 @@
<EditActions />
</div>
<ConfigTabs />
<div class="actions">
{#if $canShare}
<button
@@ -38,7 +40,7 @@
<style lang="scss">
nav {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr auto 1fr;
width: calc(min(100%, 28cm));
margin-block: 8px;
@@ -46,20 +48,6 @@
padding-inline: 16px;
}
@keyframes syncing {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.syncing {
transform-origin: 50% 49%;
animation: syncing 1s linear infinite;
}
.title {
display: flex;
align-items: center;

View File

@@ -1,74 +0,0 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import { type Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
let inDirection = $state(0);
let outDirection = $state(0);
let done: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
const routeOrder = [
"/config/settings/",
"/config/chords/",
"/config/layout/",
];
function outroEnd() {
done?.();
}
beforeNavigate((navigation) => {
const from = navigation.from?.url.pathname;
const to = navigation.to?.url.pathname;
if (from === to) return;
isNavigating = true;
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
inDirection = 0;
outDirection = 0;
} else {
const fromIndex = routeOrder.indexOf(from);
const toIndex = routeOrder.indexOf(to);
inDirection = fromIndex > toIndex ? -1 : 1;
outDirection = fromIndex > toIndex ? 1 : -1;
}
animationDone = new Promise((resolve) => {
done = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{
y: inDirection * 24,
duration: 150,
delay: 1, // flicker for some reason without this
easing: expoOut,
}}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
width: 100%;
}
</style>

View File

@@ -43,7 +43,7 @@
buildIndex($chords, $osLayout).then(searchIndex.set);
});
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>, onlyPhrase: boolean = false) {
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
const plainPhrase: string[] = [""];
const extraActions: string[] = [];
const extraCodes: string[] = [];
@@ -103,10 +103,6 @@
return result ?? `0x${it.toString(16)}`;
});
if (onlyPhrase) {
return plainPhrase.join();
}
return [
...plainPhrase,
`+${input.join("+")}`,
@@ -186,7 +182,7 @@
function downloadVocabulary() {
const vocabulary = new Set(
$chords.map((it) =>
"phrase" in it ? encodeChord(it, $osLayout, true).trim() : "",
"phrase" in it ? plainPhrase(it.phrase, $osLayout).trim() : "",
),
);
vocabulary.delete("");
@@ -266,7 +262,9 @@
{/if}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
{#if chord}
<ChordEdit {chord} onduplicate={() => (page = 0)} />
<tr>
<ChordEdit {chord} onduplicate={() => (page = 0)} />
</tr>
{/if}
{/each}</tbody
>
@@ -399,7 +397,7 @@
table {
height: fit-content;
overflow-y: hidden;
overflow: hidden;
transition: all 1s ease;
}
</style>

View File

@@ -46,7 +46,7 @@
id.splice(id.indexOf(0), 1);
id.push(0);
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
id[id.length - 1] = id[id.length - 1]! + 1;
id[id.length - 1]!++;
}
changes.update((changes) => {
@@ -89,37 +89,33 @@
}
</script>
<tr>
<th>
<ChordActionEdit {chord} onsubmit={() => {}} />
</th>
<td class="phrase-edit">
<ChordPhraseEdit {chord} />
</td>
<td>
<div class="table-buttons">
{#if !chord.deleted}
<button transition:slide class="icon compact" onclick={remove}
>delete</button
>
{:else}
<button transition:slide class="icon compact" onclick={restore}
>restore_from_trash</button
>
{/if}
<button disabled={chord.deleted} class="icon compact" onclick={duplicate}
>content_copy</button
>
<button
class="icon compact"
class:disabled={chord.isApplied}
onclick={restore}>undo</button
>
<div class="separator"></div>
<button class="icon compact" onclick={share}>share</button>
</div>
</td>
</tr>
<th>
<ChordActionEdit {chord} onsubmit={() => {}} />
</th>
<td>
<ChordPhraseEdit {chord} />
</td>
<td class="table-buttons">
{#if !chord.deleted}
<button transition:slide class="icon compact" onclick={remove}
>delete</button
>
{:else}
<button transition:slide class="icon compact" onclick={restore}
>restore_from_trash</button
>
{/if}
<button disabled={chord.deleted} class="icon compact" onclick={duplicate}
>content_copy</button
>
<button
class="icon compact"
class:disabled={chord.isApplied}
onclick={restore}>undo</button
>
<div class="separator"></div>
<button class="icon compact" onclick={share}>share</button>
</td>
<style lang="scss">
.separator {
@@ -136,29 +132,17 @@
transition: opacity 75ms ease;
}
.phrase-edit {
position: relative;
}
tr {
td {
position: relative;
}
.table-buttons {
opacity: 0;
transition: opacity 75ms ease;
position: absolute;
top: 0;
right: 0;
transform: translate(100%, -50%);
background: var(--md-sys-color-surface-variant);
}
.icon {
font-size: 18px;
}
tr:hover .table-buttons {
:global(tr):focus-within > .table-buttons,
:global(tr):hover > .table-buttons {
opacity: 1;
}
</style>

View File

@@ -1,21 +0,0 @@
<div class="table-buttons">
{#if !chord.deleted}
<button transition:slide class="icon compact" onclick={remove}
>delete</button
>
{:else}
<button transition:slide class="icon compact" onclick={restore}
>restore_from_trash</button
>
{/if}
<button disabled={chord.deleted} class="icon compact" onclick={duplicate}
>content_copy</button
>
<button
class="icon compact"
class:disabled={chord.isApplied}
onclick={restore}>undo</button
>
<div class="separator"></div>
<button class="icon compact" onclick={share}>share</button>
</div>

View File

@@ -4,16 +4,6 @@
import { serialPort } from "$lib/serial/connection";
import { setting } from "$lib/setting";
import ResetPopup from "./ResetPopup.svelte";
import LL from "$i18n/i18n-svelte";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
import { preference } from "$lib/preferences";
</script>
<svelte:head>
@@ -21,67 +11,8 @@
<meta name="description" content="Change your device's settings" />
</svelte:head>
<section>
<fieldset>
<legend>{$LL.backup.TITLE()}</legend>
<label
><input
type="checkbox"
use:preference={"backup"}
/>{$LL.backup.AUTO_BACKUP()}</label
>
<p class="disclaimer">
{$LL.backup.DISCLAIMER()}
</p>
<div class="row" style="margin-top: auto">
<button onclick={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
Settings
</button>
</div>
<div class="row">
<button class="primary" onclick={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$LL.backup.RESTORE()}</label
>
</div>
</fieldset>
<fieldset>
<legend>Device</legend>
<label
>{$LL.deviceManager.AUTO_CONNECT()}<input
type="checkbox"
use:preference={"autoConnect"}
/></label
>
{#if $serialPort}
<label
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
>
<label
>GTM Realtime Feedback<input
type="checkbox"
use:setting={{ id: 0x92 }}
/></label
>
<button class="outline" use:popup={ResetPopup}>Reset...</button>
{/if}
</fieldset>
{#if $serialPort}
{#if $serialPort}
<section>
<fieldset>
<legend
><label
@@ -300,6 +231,20 @@
>
</fieldset>
<fieldset>
<legend>Device</legend>
<label
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
>
<label
>GTM Realtime Feedback<input
type="checkbox"
use:setting={{ id: 0x92 }}
/></label
>
<button class="outline" use:popup={ResetPopup}>Reset...</button>
</fieldset>
{#if $serialPort.device === "LITE"}
<fieldset>
<legend
@@ -330,8 +275,8 @@
</select>
</fieldset>
{/if}
{/if}
</section>
</section>
{/if}
<style lang="scss">
section {
@@ -370,21 +315,18 @@
}
input[type="checkbox"] {
font-size: 12px !important;
font-size: 12px;
}
fieldset {
display: flex;
flex-direction: column;
max-width: 400px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 24px;
/*&:has(> legend input:not(:checked)) > :not(legend) {
&:has(> legend input:not(:checked)) > :not(legend) {
pointer-events: none;
opacity: 0.7;
}*/
}
> label {
position: relative;
@@ -487,14 +429,4 @@
content: "•";
}
}
.row {
display: flex;
justify-content: space-evenly;
margin-block: 8px;
}
input[type="file"] {
display: none;
}
</style>

View File

@@ -1,13 +1,12 @@
import type { Action } from "svelte/action";
import ConfirmChallenge from "./ConfirmChallenge.svelte";
import tippy from "tippy.js";
import { mount, unmount } from "svelte";
export const confirmChallenge: Action<
HTMLElement,
{ onConfirm: () => void; challenge: string }
> = (node, { onConfirm, challenge }) => {
let component: {} | undefined;
let component: ConfirmChallenge | undefined;
let target: HTMLElement | undefined;
const edit = tippy(node, {
interactive: true,
@@ -16,22 +15,15 @@ export const confirmChallenge: Action<
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active");
if (component === undefined) {
component = mount(ConfirmChallenge, {
target,
props: {
challenge,
onconfirm() {
edit.hide();
onConfirm();
},
},
component = new ConfirmChallenge({ target, props: { challenge } });
component.$on("confirm", () => {
edit.hide();
onConfirm();
});
}
},
onHidden() {
if (component) {
unmount(component);
}
component?.$destroy();
target?.classList.remove("active");
component = undefined;
},

View File

@@ -155,31 +155,30 @@
doc: examplePlugin,
});
});
let channels = $derived.by(() => {
if (!$serialPort) return {} as any;
return {
getVersion: (..._args: unknown[]) => Promise.resolve($serialPort.version),
getDevice: (..._args: unknown[]) => Promise.resolve($serialPort.device),
commit: (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return Promise.resolve($serialPort.commit());
}
return Promise.resolve();
},
...Object.fromEntries(
charaMethods.map(
(it) => [it, $serialPort[it].bind($serialPort)] as const,
),
),
} satisfies Record<string, Function>;
});
let channels = $derived(
$serialPort
? ({
getVersion: async (..._args: unknown[]) => $serialPort.version,
getDevice: async (..._args: unknown[]) => $serialPort.device,
commit: async (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return $serialPort.commit();
}
},
...Object.fromEntries(
charaMethods.map(
(it) => [it, $serialPort[it].bind($serialPort)] as const,
),
),
} satisfies Record<string, Function>)
: ({} as any),
);
async function onMessage(event: MessageEvent) {
if (event.origin !== "null" || event.source !== frame.contentWindow) return;

12
static/.htaccess Normal file
View File

@@ -0,0 +1,12 @@
RewriteEngine On
# force https
RewriteCond %{HTTPS} off
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
# https://kit.svelte.dev/docs/single-page-apps#apache
# RewriteBase /
# RewriteRule ^index\.html$ - [L]
# RewriteCond %{REQUEST_FILENAME} !-f
# RewriteCond %{REQUEST_FILENAME} !-d
# RewriteRule . /index.html [QSA,L]

View File

@@ -1,5 +1,5 @@
// noinspection ES6PreferShortImport
import { themeColor } from "./src/lib/style/theme";
import { themeColor } from "./src/lib/style/theme.server";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
@@ -22,8 +22,6 @@ process.env["VITE_BUGS_URL"] = bugs.url;
process.env["VITE_LEARN_URL"] = "https://www.iq-eq.io/";
process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware/";
export default defineConfig({
build: {
@@ -34,9 +32,6 @@ export default defineConfig({
external: isTauri ? [/virtual:pwa.*/] : [],
},
},
define: {
global: "window",
},
envPrefix: ["TAURI_", "VITE_"],
plugins: [
ViteYaml(),
@@ -47,7 +42,6 @@ export default defineConfig({
SvelteKitPWA({
kit: {
trailingSlash: "always",
adapterFallback: "404.html",
},
scope: "/",
base: "/",
@@ -55,12 +49,10 @@ export default defineConfig({
workbox: {
// https://vite-pwa-org.netlify.app/frameworks/sveltekit.html#globpatterns
globPatterns: [
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
"client/**/*.{js,map,css,ico,woff2,csv,png,webp,svg,webmanifest}",
"prerendered/**/*.html",
],
globIgnores: ["prerendered/pages/ccos/**/*"],
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
ignoreURLParametersMatching: [/^import$/],
},
manifest: {
name: "CharaChorder Device Manager",