mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-07-11 15:08:53 +00:00
Compare commits
28 Commits
fix-typo-n
...
f3704e4051
| Author | SHA1 | Date | |
|---|---|---|---|
|
f3704e4051
|
|||
|
3e6298717e
|
|||
|
aced0bbbb7
|
|||
|
|
36874c59e3 | ||
|
9dc61a3482
|
|||
|
d9183f952a
|
|||
|
913a833824
|
|||
|
0d6ef4d011
|
|||
|
232045964c
|
|||
|
3659b80e41
|
|||
|
3a02caeb6d
|
|||
|
259fd3a989
|
|||
|
dcf1d89fa0
|
|||
|
c79237ce22
|
|||
|
d68f1b19fa
|
|||
|
9cb36662b3
|
|||
|
b4605fe84d
|
|||
|
06d122b5d6
|
|||
|
3d25b030c6
|
|||
|
bf490ba823
|
|||
|
397f4bb6a9
|
|||
|
1f4604bcbc
|
|||
|
68faf57a22
|
|||
|
1d976947e1
|
|||
|
ca8bfac3bc
|
|||
|
2f0d8f2e1d
|
|||
|
236e23086c
|
|||
|
d1fefb88a1
|
40
.github/workflows/build.yml
vendored
40
.github/workflows/build.yml
vendored
@@ -1,13 +1,9 @@
|
|||||||
name: Build
|
name: Build
|
||||||
|
|
||||||
on:
|
on: [push]
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
CI:
|
build:
|
||||||
name: 🔨🚀 Build and deploy
|
name: 🔨🚀 Build and deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -25,10 +21,10 @@ jobs:
|
|||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 8
|
||||||
- name: 🐉 Use Node.js 18.16.x
|
- name: 🐉 Use Node.js 22.4.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18.16.x
|
node-version: 22.4.x
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
- name: ⏬ Install Node dependencies
|
- name: ⏬ Install Node dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -38,17 +34,17 @@ jobs:
|
|||||||
- name: 🔨 Build site
|
- name: 🔨 Build site
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: 📦 Upload build artifacts
|
- name: Setup SSH
|
||||||
uses: actions/upload-artifact@v3.1.2
|
run: |
|
||||||
with:
|
install -m 600 -D /dev/null ~/.ssh/id_rsa
|
||||||
name: build
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||||
path: build
|
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||||
- name: Disable jekyll
|
|
||||||
run: touch build/.nojekyll
|
- name: Publish Stable
|
||||||
- name: Custom domain
|
if: ${{ github.ref == 'refs/tags/v*' }}
|
||||||
run: echo 'manager.charachorder.com' > build/CNAME
|
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
|
||||||
- run: git config user.name github-actions
|
|
||||||
- run: git config user.email github-actions@github.com
|
- name: Publish Branch
|
||||||
- run: git --work-tree build add --all
|
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
|
||||||
- run: git commit -m "Automatic Deploy action run by github-actions"
|
- name: Publish Commit
|
||||||
- run: git push origin HEAD:gh-pages --force
|
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const config = {
|
|||||||
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
|
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
|
||||||
outputPath: "src/lib/assets/icons.min.woff2",
|
outputPath: "src/lib/assets/icons.min.woff2",
|
||||||
icons: [
|
icons: [
|
||||||
|
"deployed_code_update",
|
||||||
"adjust",
|
"adjust",
|
||||||
"add",
|
"add",
|
||||||
"piano",
|
"piano",
|
||||||
@@ -18,6 +19,8 @@ const config = {
|
|||||||
"update",
|
"update",
|
||||||
"offline_pin",
|
"offline_pin",
|
||||||
"warning",
|
"warning",
|
||||||
|
"dangerous",
|
||||||
|
"check",
|
||||||
"cable",
|
"cable",
|
||||||
"person",
|
"person",
|
||||||
"sync",
|
"sync",
|
||||||
@@ -65,6 +68,8 @@ const config = {
|
|||||||
"bolt",
|
"bolt",
|
||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"redo",
|
||||||
|
"replay",
|
||||||
|
"reply",
|
||||||
"navigate_before",
|
"navigate_before",
|
||||||
"navigate_next",
|
"navigate_next",
|
||||||
"print",
|
"print",
|
||||||
@@ -91,6 +96,10 @@ const config = {
|
|||||||
"upload_2",
|
"upload_2",
|
||||||
"stat_minus_2",
|
"stat_minus_2",
|
||||||
"stat_2",
|
"stat_2",
|
||||||
|
"send",
|
||||||
|
"more_horiz",
|
||||||
|
"add_reaction",
|
||||||
|
"stop",
|
||||||
"description",
|
"description",
|
||||||
"add_circle",
|
"add_circle",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
|||||||
62
package.json
62
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "1.5.2",
|
"version": "2.1.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -34,58 +34,62 @@
|
|||||||
"typesafe-i18n": "typesafe-i18n"
|
"typesafe-i18n": "typesafe-i18n"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.17.0",
|
"@codemirror/autocomplete": "^6.18.2",
|
||||||
"@codemirror/commands": "^6.6.0",
|
"@codemirror/commands": "^6.7.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
"@codemirror/language": "^6.10.2",
|
"@codemirror/language": "^6.10.3",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.29.1",
|
"@codemirror/view": "^6.34.1",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.0.36",
|
"@fontsource-variable/material-symbols-rounded": "^5.1.3",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
"@fontsource-variable/noto-sans-mono": "^5.1.0",
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@melt-ui/pp": "^0.3.2",
|
"@melt-ui/pp": "^0.3.2",
|
||||||
"@melt-ui/svelte": "^0.83.0",
|
"@melt-ui/svelte": "^0.86.0",
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.2",
|
"@sveltejs/adapter-static": "^3.0.6",
|
||||||
"@sveltejs/kit": "^2.5.18",
|
"@sveltejs/kit": "^2.7.5",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@tauri-apps/api": "^1.6.0",
|
"@tauri-apps/api": "^1.6.0",
|
||||||
"@tauri-apps/cli": "^1.6.0",
|
"@tauri-apps/cli": "^1.6.0",
|
||||||
"@types/dom-view-transitions": "^1.0.5",
|
"@types/dom-view-transitions": "^1.0.5",
|
||||||
"@types/flexsearch": "^0.7.6",
|
"@types/flexsearch": "^0.7.6",
|
||||||
"@types/w3c-web-serial": "^1.0.6",
|
"@types/w3c-web-serial": "^1.0.7",
|
||||||
"@types/w3c-web-usb": "^1.0.10",
|
"@types/w3c-web-usb": "^1.0.10",
|
||||||
"@vite-pwa/sveltekit": "^0.6.0",
|
"@types/wicg-file-system-access": "^2023.10.5",
|
||||||
"autoprefixer": "^10.4.19",
|
"@vite-pwa/sveltekit": "^0.6.6",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"cypress": "^13.13.2",
|
"cypress": "^13.13.2",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"fontkit": "^2.0.2",
|
"fontkit": "^2.0.4",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
"jsdom": "^24.1.1",
|
"jsdom": "^25.0.1",
|
||||||
|
"matrix-js-sdk": "^34.9.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
"sass": "^1.77.8",
|
"rxjs": "^7.8.1",
|
||||||
"stylelint": "^16.8.1",
|
"sass": "^1.80.6",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"stylelint": "^16.10.0",
|
||||||
"stylelint-config-clean-order": "^6.1.0",
|
"stylelint-config-clean-order": "^6.1.0",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-recommended-scss": "^14.1.0",
|
"stylelint-config-recommended-scss": "^14.1.0",
|
||||||
"stylelint-config-standard-scss": "^13.1.0",
|
"stylelint-config-standard-scss": "^13.1.0",
|
||||||
"svelte": "5.0.0-next.221",
|
"svelte": "5.1.9",
|
||||||
"svelte-check": "^3.8.5",
|
"svelte-check": "^4.0.5",
|
||||||
"svelte-preprocess": "^6.0.2",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^5.3.5",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-mkcert": "^1.17.5",
|
"vite-plugin-mkcert": "^1.17.6",
|
||||||
"vite-plugin-pwa": "^0.20.1",
|
"vite-plugin-pwa": "^0.20.5",
|
||||||
"vitest": "^2.0.5",
|
"vitest": "^2.1.4",
|
||||||
"workbox-window": "^7.1.0"
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
1998
pnpm-lock.yaml
generated
1998
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "1.5.2"
|
version = "2.1.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||||
license = "AGPL-3"
|
license = "AGPL-3"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devPath": "http://localhost:5173",
|
"devPath": "http://localhost:5173",
|
||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": { "productName": "amacc1ng", "version": "1.5.2" },
|
"package": { "productName": "amacc1ng", "version": "2.1.0" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@@ -14,6 +14,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_LEARN_URL: string;
|
readonly VITE_LEARN_URL: string;
|
||||||
readonly VITE_LATEST_FIRMWARE: string;
|
readonly VITE_LATEST_FIRMWARE: string;
|
||||||
readonly VITE_STORE_URL: string;
|
readonly VITE_STORE_URL: string;
|
||||||
|
readonly VITE_MATRIX_URL: string;
|
||||||
|
readonly VITE_FIRMWARE_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ const de = {
|
|||||||
RELOAD: "Neu laden",
|
RELOAD: "Neu laden",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Lokale Kopie",
|
TITLE: "Backup",
|
||||||
INDIVIDUAL: "Einzeldateien",
|
AUTO_BACKUP: "Auto-backup",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
||||||
DOWNLOAD: "Alles herunterladen",
|
DOWNLOAD: "Alles",
|
||||||
RESTORE: "Wiederherstellen",
|
RESTORE: "Wiederherstellen",
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
@@ -109,7 +109,7 @@ const de = {
|
|||||||
},
|
},
|
||||||
configure: {
|
configure: {
|
||||||
chords: {
|
chords: {
|
||||||
TITLE: "Akkorde",
|
TITLE: "Bibliothek",
|
||||||
HOLD_KEYS: "Akkord halten",
|
HOLD_KEYS: "Akkord halten",
|
||||||
NEW_CHORD: "Neuer Akkord",
|
NEW_CHORD: "Neuer Akkord",
|
||||||
DUPLICATE: "Akkord existiert bereits",
|
DUPLICATE: "Akkord existiert bereits",
|
||||||
@@ -131,7 +131,7 @@ const de = {
|
|||||||
TITLE: "Layout",
|
TITLE: "Layout",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
TITLE: "Einstellungen",
|
TITLE: "Gerät",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ const en = {
|
|||||||
TITLE: "Update your device",
|
TITLE: "Update your device",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Local backup",
|
TITLE: "Backup",
|
||||||
INDIVIDUAL: "Individual backups",
|
AUTO_BACKUP: "Auto-backup",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"A backup is made and stored in this browser, and always remains only on your computer.",
|
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
|
||||||
DOWNLOAD: "Download Everything",
|
DOWNLOAD: "Everything",
|
||||||
RESTORE: "Restore",
|
RESTORE: "Restore",
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
@@ -108,7 +108,7 @@ const en = {
|
|||||||
},
|
},
|
||||||
configure: {
|
configure: {
|
||||||
chords: {
|
chords: {
|
||||||
TITLE: "Chords",
|
TITLE: "Library",
|
||||||
HOLD_KEYS: "Hold chord",
|
HOLD_KEYS: "Hold chord",
|
||||||
NEW_CHORD: "New chord",
|
NEW_CHORD: "New chord",
|
||||||
DUPLICATE: "Chord already exists",
|
DUPLICATE: "Chord already exists",
|
||||||
@@ -130,7 +130,7 @@ const en = {
|
|||||||
TITLE: "Layout",
|
TITLE: "Layout",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
TITLE: "Settings",
|
TITLE: "Device",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
|
|||||||
55
src/lib/PageTransition.svelte
Normal file
55
src/lib/PageTransition.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<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>
|
||||||
@@ -395,7 +395,7 @@ actions:
|
|||||||
350:
|
350:
|
||||||
id: "KP_6"
|
id: "KP_6"
|
||||||
keyCode: "Numpad6"
|
keyCode: "Numpad6"
|
||||||
title: Keypad 6 and Right Arrow
|
title: Keypad 6 and Rigth Arrow
|
||||||
351:
|
351:
|
||||||
id: "KP_7"
|
id: "KP_7"
|
||||||
keyCode: "Numpad7"
|
keyCode: "Numpad7"
|
||||||
|
|||||||
@@ -3,35 +3,22 @@ col:
|
|||||||
# Ring / Middle
|
# Ring / Middle
|
||||||
- offset: [2, 0]
|
- offset: [2, 0]
|
||||||
row:
|
row:
|
||||||
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
|
- switch: { e: 26, n: 27, w: 28, s: 29 }
|
||||||
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
|
- switch: { e: 21, n: 22, w: 23, s: 24 }
|
||||||
- offset: [4, 0]
|
|
||||||
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]
|
- offset: [2, 0]
|
||||||
row:
|
row:
|
||||||
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
|
- switch: { e: 41, n: 42, w: 43, s: 44 }
|
||||||
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
|
- switch: { e: 36, n: 37, w: 38, s: 39 }
|
||||||
- offset: [4, 0]
|
|
||||||
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
|
|
||||||
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
|
|
||||||
# Pinkie / Index
|
# Pinkie / Index
|
||||||
- offset: [0, -3]
|
- offset: [0, -3]
|
||||||
row:
|
row:
|
||||||
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
|
- switch: { e: 31, n: 32, w: 33, s: 34 }
|
||||||
- offset: [4, 0]
|
- offset: [4, 0]
|
||||||
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
|
switch: { e: 16, n: 17, w: 18, s: 19 }
|
||||||
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
|
|
||||||
- offset: [4, 0]
|
|
||||||
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
|
|
||||||
# Thumbs
|
# Thumbs
|
||||||
- row:
|
- row:
|
||||||
- offset: [5.5, 0.5]
|
- offset: [5.5, 0.5]
|
||||||
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
|
switch: { e: 11, n: 12, w: 13, s: 14 }
|
||||||
- offset: [1, 0.5]
|
|
||||||
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
|
|
||||||
- row:
|
- row:
|
||||||
- offset: [4.5, -0.25]
|
- offset: [4.5, -0.25]
|
||||||
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
|
switch: { e: 6, n: 7, w: 8, s: 9 }
|
||||||
- offset: [3, -0.25]
|
|
||||||
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }
|
|
||||||
|
|||||||
24
src/lib/assets/layouts/m4gr.yml
Normal file
24
src/lib/assets/layouts/m4gr.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: M4GR
|
||||||
|
col:
|
||||||
|
# Ring / Middle
|
||||||
|
- offset: [2, 0]
|
||||||
|
row:
|
||||||
|
- switch: { e: 23, n: 22, w: 21, s: 24 }
|
||||||
|
- switch: { e: 28, n: 27, w: 26, s: 29 }
|
||||||
|
- offset: [2, 0]
|
||||||
|
row:
|
||||||
|
- switch: { e: 38, n: 37, w: 36, s: 39 }
|
||||||
|
- switch: { e: 43, n: 42, w: 41, s: 44 }
|
||||||
|
# Pinkie / Index
|
||||||
|
- offset: [0, -3]
|
||||||
|
row:
|
||||||
|
- switch: { e: 18, n: 17, w: 16, s: 19 }
|
||||||
|
- offset: [4, 0]
|
||||||
|
switch: { e: 33, n: 32, w: 31, s: 34 }
|
||||||
|
# Thumbs
|
||||||
|
- row:
|
||||||
|
- offset: [0.5, 0.5]
|
||||||
|
switch: { e: 13, n: 12, w: 11, s: 14 }
|
||||||
|
- row:
|
||||||
|
- offset: [1.5, -0.25]
|
||||||
|
switch: { e: 8, n: 7, w: 6, s: 9 }
|
||||||
@@ -11,11 +11,13 @@
|
|||||||
cursor = false,
|
cursor = false,
|
||||||
keys = false,
|
keys = false,
|
||||||
children,
|
children,
|
||||||
|
ondone,
|
||||||
}: {
|
}: {
|
||||||
replay: ReplayPlayer | Replay;
|
replay: ReplayPlayer | Replay;
|
||||||
cursor?: boolean;
|
cursor?: boolean;
|
||||||
keys?: boolean;
|
keys?: boolean;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
|
ondone?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let replayPlayer: ReplayPlayer | undefined = $state();
|
let replayPlayer: ReplayPlayer | undefined = $state();
|
||||||
@@ -61,6 +63,7 @@
|
|||||||
const unsubscribePlayer = player.subscribe(apply);
|
const unsubscribePlayer = player.subscribe(apply);
|
||||||
textRenderer = renderer;
|
textRenderer = renderer;
|
||||||
|
|
||||||
|
player.onDone = ondone;
|
||||||
player.start();
|
player.start();
|
||||||
apply();
|
apply();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export class ReplayPlayer {
|
|||||||
|
|
||||||
private subscribers = new Set<(value: TextToken | undefined) => void>();
|
private subscribers = new Set<(value: TextToken | undefined) => void>();
|
||||||
|
|
||||||
|
onDone?: () => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly replay: Replay,
|
readonly replay: Replay,
|
||||||
plugins: ReplayPlugin[] = [],
|
plugins: ReplayPlugin[] = [],
|
||||||
@@ -37,8 +39,13 @@ export class ReplayPlayer {
|
|||||||
if (
|
if (
|
||||||
this.replayCursor >= this.replay.keys.length &&
|
this.replayCursor >= this.replay.keys.length &&
|
||||||
this.releaseAt.size === 0
|
this.releaseAt.size === 0
|
||||||
)
|
) {
|
||||||
|
if (this.onDone) {
|
||||||
|
this.onDone();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const now = performance.now() - this.startTime;
|
const now = performance.now() - this.startTime;
|
||||||
|
|
||||||
while (
|
while (
|
||||||
@@ -118,7 +125,12 @@ export class ReplayPlayer {
|
|||||||
start(delay = 200): this {
|
start(delay = 200): this {
|
||||||
this.replayCursor = 0;
|
this.replayCursor = 0;
|
||||||
this.stepper = new ReplayStepper([], this.replay.challenge);
|
this.stepper = new ReplayStepper([], this.replay.challenge);
|
||||||
if (this.replay.keys.length === 0) return this;
|
if (this.replay.keys.length === 0) {
|
||||||
|
if (this.onDone) {
|
||||||
|
this.onDone();
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.startTime = performance.now();
|
this.startTime = performance.now();
|
||||||
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { ReplayPlayer } from "./player.js";
|
import { ReplayPlayer } from "./player.js";
|
||||||
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.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 {
|
export class ReplayRecorder {
|
||||||
private held = new Map<string, [string, number]>();
|
private held = new Map<string, [string, number]>();
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ export class ReplayRecorder {
|
|||||||
this.player.playLiveEvent(event.key, event.code),
|
this.player.playLiveEvent(event.key, event.code),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const [key, start] = this.held.get(event.code)!;
|
const [key, start] = this.held.get(event.code) ?? ["", 0];
|
||||||
const delta = event.timeStamp - start;
|
const delta = event.timeStamp - start;
|
||||||
this.held.delete(event.code);
|
this.held.delete(event.code);
|
||||||
|
|
||||||
@@ -50,16 +54,24 @@ export class ReplayRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(trim = true) {
|
finish(trim = true, round = true) {
|
||||||
return {
|
return {
|
||||||
start: trim ? this.replay[0]?.[2] : this.start,
|
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
|
||||||
finish: trim
|
finish: maybeRound(
|
||||||
|
trim
|
||||||
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
||||||
: performance.now(),
|
: performance.now(),
|
||||||
|
round,
|
||||||
|
),
|
||||||
keys: this.replay
|
keys: this.replay
|
||||||
.map(
|
.map(
|
||||||
([key, code, at, duration]) =>
|
([key, code, at, duration]) =>
|
||||||
[key, code, Math.round(at), Math.round(duration)] as const,
|
[
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
maybeRound(at, round),
|
||||||
|
maybeRound(duration, round),
|
||||||
|
] as const,
|
||||||
)
|
)
|
||||||
.sort((a, b) => a[2] - b[2]),
|
.sort((a, b) => a[2] - b[2]),
|
||||||
};
|
};
|
||||||
|
|||||||
71
src/lib/chat/MatrixRoomMembers.svelte
Normal file
71
src/lib/chat/MatrixRoomMembers.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<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>
|
||||||
0
src/lib/chat/MatrixSpace.svelte
Normal file
0
src/lib/chat/MatrixSpace.svelte
Normal file
251
src/lib/chat/MatrixTimeline.svelte
Normal file
251
src/lib/chat/MatrixTimeline.svelte
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
<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>
|
||||||
109
src/lib/chat/chat.ts
Normal file
109
src/lib/chat/chat.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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")!;
|
||||||
|
}
|
||||||
357
src/lib/chat/events/MatrixEvent.svelte
Normal file
357
src/lib/chat/events/MatrixEvent.svelte
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<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>
|
||||||
56
src/lib/chat/events/MatrixMessageEvent.svelte
Normal file
56
src/lib/chat/events/MatrixMessageEvent.svelte
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<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>
|
||||||
71
src/lib/chat/matrix-rx/client.ts
Normal file
71
src/lib/chat/matrix-rx/client.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
11
src/lib/chat/matrix-rx/events.ts
Normal file
11
src/lib/chat/matrix-rx/events.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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]>
|
||||||
|
>;
|
||||||
|
}
|
||||||
85
src/lib/chat/matrix-rx/rooms.ts
Normal file
85
src/lib/chat/matrix-rx/rooms.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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]>
|
||||||
|
>;
|
||||||
|
}
|
||||||
19
src/lib/chat/matrix-rx/timeline.ts
Normal file
19
src/lib/chat/matrix-rx/timeline.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,5 +17,11 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
p {
|
p {
|
||||||
margin-block: 0;
|
margin-block: 0;
|
||||||
|
|
||||||
|
:global(kbd.icon) {
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: inherit;
|
||||||
|
translate: 0 0.2em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
import("$lib/assets/layouts/m4g.yml").then(
|
import("$lib/assets/layouts/m4g.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
),
|
),
|
||||||
|
M4GR: () =>
|
||||||
|
import("$lib/assets/layouts/m4gr.yml").then(
|
||||||
|
(it) => it.default as VisualLayout,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-bottom: 96px;
|
max-height: 20cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
|
|||||||
11
src/lib/learn/stats.ts
Normal file
11
src/lib/learn/stats.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { persistentWritable } from "$lib/storage";
|
||||||
|
|
||||||
|
interface ChordStats {
|
||||||
|
level: number;
|
||||||
|
lastUprank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
||||||
|
"chord-stats",
|
||||||
|
{},
|
||||||
|
);
|
||||||
@@ -6,9 +6,14 @@ export interface UserPreferences {
|
|||||||
autoConnect: boolean;
|
autoConnect: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const theme = persistentWritable("user-theme", {
|
export interface UserTheme {
|
||||||
|
color: string;
|
||||||
|
mode: "light" | "dark" | "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = persistentWritable<UserTheme>("user-theme", {
|
||||||
color: "#6D81C7",
|
color: "#6D81C7",
|
||||||
mode: "dark" as "light" | "dark" | "auto",
|
mode: "dark",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userPreferences = persistentWritable<UserPreferences>(
|
export const userPreferences = persistentWritable<UserPreferences>(
|
||||||
|
|||||||
@@ -53,11 +53,13 @@ export interface ProgressInfo {
|
|||||||
}
|
}
|
||||||
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
||||||
|
|
||||||
export async function initSerial(manual = false) {
|
export async function initSerial(manual = false, withSync = true) {
|
||||||
const device = get(serialPort) ?? new CharaDevice();
|
const device = get(serialPort) ?? new CharaDevice();
|
||||||
await device.init(manual);
|
await device.init(manual);
|
||||||
serialPort.set(device);
|
serialPort.set(device);
|
||||||
|
if (withSync) {
|
||||||
await sync();
|
await sync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sync() {
|
export async function sync() {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { browser } from "$app/environment";
|
|||||||
|
|
||||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||||
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
|
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
|
||||||
|
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
||||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
||||||
@@ -24,7 +25,8 @@ const KEY_COUNTS = {
|
|||||||
TWO: 90,
|
TWO: 90,
|
||||||
LITE: 67,
|
LITE: 67,
|
||||||
X: 256,
|
X: 256,
|
||||||
M4G: 90,
|
M4G: 64,
|
||||||
|
M4GR: 64,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -439,4 +441,95 @@ export class CharaDevice {
|
|||||||
async getRamBytesAvailable(): Promise<number> {
|
async getRamBytesAvailable(): Promise<number> {
|
||||||
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ export function persistentWritable<T>(
|
|||||||
): Writable<T> {
|
): Writable<T> {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const persistedValue = localStorage.getItem(key);
|
const persistedValue = localStorage.getItem(key);
|
||||||
const store =
|
let store: Writable<T>;
|
||||||
|
try {
|
||||||
|
store =
|
||||||
persistedValue !== null
|
persistedValue !== null
|
||||||
? writable(JSON.parse(persistedValue))
|
? writable(JSON.parse(persistedValue))
|
||||||
: writable(value);
|
: writable(value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
store = writable(value);
|
||||||
|
}
|
||||||
store.subscribe((value) => {
|
store.subscribe((value) => {
|
||||||
if (!condition || condition())
|
if (!condition || condition())
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
import {
|
|
||||||
themeBase,
|
|
||||||
themeColor,
|
|
||||||
themeSuccessBase,
|
|
||||||
} from "$lib/style/theme.server";
|
|
||||||
import type { LayoutServerLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load = (async () => ({
|
|
||||||
themeSuccessBase,
|
|
||||||
themeBase,
|
|
||||||
themeColor,
|
|
||||||
})) satisfies LayoutServerLoad;
|
|
||||||
@@ -15,7 +15,6 @@
|
|||||||
import { initSerial } from "$lib/serial/connection";
|
import { initSerial } from "$lib/serial/connection";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import BrowserWarning from "./BrowserWarning.svelte";
|
|
||||||
import "tippy.js/animations/shift-away.css";
|
import "tippy.js/animations/shift-away.css";
|
||||||
import "tippy.js/dist/tippy.css";
|
import "tippy.js/dist/tippy.css";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
@@ -108,7 +107,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!--{@html webManifestLink}-->
|
{@html webManifestLink}
|
||||||
<title>{$LL.TITLE()}</title>
|
<title>{$LL.TITLE()}</title>
|
||||||
<meta name="description" content={$LL.DESCRIPTION()} />
|
<meta name="description" content={$LL.DESCRIPTION()} />
|
||||||
<meta name="theme-color" content={data.themeColor} />
|
<meta name="theme-color" content={data.themeColor} />
|
||||||
@@ -128,10 +127,6 @@
|
|||||||
</PageTransition>
|
</PageTransition>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
|
||||||
<BrowserWarning />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { LayoutLoad } from "./$types";
|
import type { LayoutLoad } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { charaFileFromUriComponent } from "$lib/share/share-url";
|
import { charaFileFromUriComponent } from "$lib/share/share-url";
|
||||||
|
import { themeBase, themeColor, themeSuccessBase } from "$lib/style/theme";
|
||||||
|
|
||||||
export const load = (async ({ url, data, fetch }) => {
|
export const load = (async ({ url, data, fetch }) => {
|
||||||
const importFile = browser && new URLSearchParams(url.search).get("import");
|
const importFile = browser && new URLSearchParams(url.search).get("import");
|
||||||
return {
|
return {
|
||||||
...data,
|
themeSuccessBase,
|
||||||
|
themeBase,
|
||||||
|
themeColor,
|
||||||
importFile: importFile
|
importFile: importFile
|
||||||
? await charaFileFromUriComponent(importFile, fetch)
|
? await charaFileFromUriComponent(importFile, fetch)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -8,11 +8,25 @@
|
|||||||
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import SyncOverlay from "./SyncOverlay.svelte";
|
import SyncOverlay from "./SyncOverlay.svelte";
|
||||||
import { serialPort } from "$lib/serial/connection";
|
import {
|
||||||
|
initSerial,
|
||||||
|
serialPort,
|
||||||
|
sync,
|
||||||
|
syncProgress,
|
||||||
|
syncStatus,
|
||||||
|
} from "$lib/serial/connection";
|
||||||
|
import { fade, slide } from "svelte/transition";
|
||||||
|
|
||||||
let locale = $state(
|
let locale = $state(
|
||||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let currentDevice = $derived(
|
||||||
|
$serialPort
|
||||||
|
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
localStorage.setItem("locale", locale);
|
localStorage.setItem("locale", locale);
|
||||||
@@ -33,6 +47,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
let languageSelect: HTMLSelectElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,39 +74,58 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
|
use:action={{ title: "Branch" }}
|
||||||
href={import.meta.env.VITE_HOMEPAGE_URL}
|
href={import.meta.env.VITE_HOMEPAGE_URL}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"><span class="icon">commit</span> v{version}</a
|
target="_blank"><span class="icon">commit</span> v{version}</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
<a
|
||||||
><span class="icon">bug_report</span> Issues</a
|
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
|
||||||
>
|
use:action={{ title: "Updates" }}
|
||||||
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div>
|
<div class="sync-box">
|
||||||
{#if !$serialPort}
|
{#if !$serialPort}
|
||||||
<div class="warning">
|
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
|
||||||
<span class="icon">warning</span>{$LL.deviceManager.NO_DEVICE()}
|
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||||
</div>
|
>
|
||||||
|
{: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>
|
||||||
{/if}
|
{/if}
|
||||||
<SyncOverlay />
|
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
|
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
||||||
><span class="icon">shopping_bag</span> Store</a
|
><span class="icon">bug_report</span> Bugs</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
|
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
|
||||||
><span class="icon">school</span> Train</a
|
><span class="icon">shopping_bag</span> Store</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="hide-forced-colors">
|
<li class="hide-forced-colors">
|
||||||
@@ -101,7 +154,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<!--<li>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
class="icon"
|
class="icon"
|
||||||
@@ -116,7 +169,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>-->
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
@@ -126,6 +179,37 @@
|
|||||||
opacity: 0;
|
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 {
|
.warning {
|
||||||
color: var(--md-sys-color-error);
|
color: var(--md-sys-color-error);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -14,27 +14,26 @@
|
|||||||
let isNavigating = $state(false);
|
let isNavigating = $state(false);
|
||||||
|
|
||||||
const routeOrder = [
|
const routeOrder = [
|
||||||
"/config/chords/",
|
"/config",
|
||||||
"/config/layout/",
|
"/learn",
|
||||||
"/config/settings/",
|
"/docs",
|
||||||
|
"/editor",
|
||||||
|
"/chat",
|
||||||
|
"/plugin",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function routeIndex(route: string | undefined): number {
|
||||||
|
return routeOrder.findIndex((it) => route?.startsWith(it));
|
||||||
|
}
|
||||||
|
|
||||||
beforeNavigate((navigation) => {
|
beforeNavigate((navigation) => {
|
||||||
const from = navigation.from?.url.pathname;
|
const from = routeIndex(navigation.from?.url.pathname);
|
||||||
const to = navigation.to?.url.pathname;
|
const to = routeIndex(navigation.to?.url.pathname);
|
||||||
if (from === to) return;
|
if (from === -1 || to === -1 || from === to) return;
|
||||||
isNavigating = true;
|
isNavigating = true;
|
||||||
|
|
||||||
if (!(from && to && routeOrder.includes(from) && routeOrder.includes(to))) {
|
inDirection = from > to ? -1 : 1;
|
||||||
inDirection = 0;
|
outDirection = from > to ? 1 : -1;
|
||||||
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) => {
|
animationDone = new Promise((resolve) => {
|
||||||
outroEnd = resolve;
|
outroEnd = resolve;
|
||||||
@@ -49,10 +48,16 @@
|
|||||||
|
|
||||||
{#if !isNavigating}
|
{#if !isNavigating}
|
||||||
<main
|
<main
|
||||||
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
|
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
|
||||||
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
|
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
|
||||||
onoutroend={outroEnd}
|
onoutroend={outroEnd}
|
||||||
>
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
main {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,36 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from "$app/environment";
|
import { page } from "$app/stores";
|
||||||
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 = [
|
const routes = [
|
||||||
[
|
[
|
||||||
{ href: "/config/chords/", icon: "dictionary", title: "Chords" },
|
{
|
||||||
|
href: "/config/settings/",
|
||||||
|
icon: "cable",
|
||||||
|
title: "Device",
|
||||||
|
primary: true,
|
||||||
|
},
|
||||||
|
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
|
||||||
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
|
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
|
||||||
{ href: "/config/settings/", icon: "tune", title: "Config" },
|
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{ href: "/learn", icon: "school", title: "Learn", wip: true },
|
// { href: "/learn", icon: "school", title: "Learn", wip: true },
|
||||||
{ href: "/learn", icon: "description", title: "Docs" },
|
{
|
||||||
|
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: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
{ 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 },
|
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
||||||
],
|
],*/
|
||||||
] satisfies { href: string; icon: string; title: string; wip?: boolean }[][];
|
] satisfies {
|
||||||
|
href: string;
|
||||||
|
icon: string;
|
||||||
|
title: string;
|
||||||
|
wip?: boolean;
|
||||||
|
external?: boolean;
|
||||||
|
primary?: boolean;
|
||||||
|
}[][];
|
||||||
|
|
||||||
let connectButton: HTMLButtonElement;
|
let connectButton: HTMLButtonElement;
|
||||||
</script>
|
</script>
|
||||||
@@ -39,10 +48,18 @@
|
|||||||
<nav>
|
<nav>
|
||||||
{#each routes as group}
|
{#each routes as group}
|
||||||
<ul>
|
<ul>
|
||||||
{#each group as { href, icon, title, wip }}
|
{#each group as { href, icon, title, wip, external }}
|
||||||
<li>
|
<li>
|
||||||
<a class:wip {href}>
|
<a
|
||||||
<div class="icon">{icon}</div>
|
class:wip
|
||||||
|
{href}
|
||||||
|
rel={external ? "noreferrer" : undefined}
|
||||||
|
target={external ? "_blank" : undefined}
|
||||||
|
class:active={$page.url.pathname.startsWith(href)}
|
||||||
|
>
|
||||||
|
<div class="icon">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
@@ -52,28 +69,6 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</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>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -109,12 +104,30 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
transition: all 250ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
> .content {
|
> .content {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: 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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
src/routes/(app)/ccos/+layout.svelte
Normal file
15
src/routes/(app)/ccos/+layout.svelte
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<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>
|
||||||
80
src/routes/(app)/ccos/+page.svelte
Normal file
80
src/routes/(app)/ccos/+page.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<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>
|
||||||
9
src/routes/(app)/ccos/+page.ts
Normal file
9
src/routes/(app)/ccos/+page.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
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;
|
||||||
0
src/routes/(app)/ccos/OTA.svelte
Normal file
0
src/routes/(app)/ccos/OTA.svelte
Normal file
85
src/routes/(app)/ccos/[device]/+page.svelte
Normal file
85
src/routes/(app)/ccos/[device]/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<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>
|
||||||
16
src/routes/(app)/ccos/[device]/+page.ts
Normal file
16
src/routes/(app)/ccos/[device]/+page.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
405
src/routes/(app)/ccos/[device]/[version]/+page.svelte
Normal file
405
src/routes/(app)/ccos/[device]/[version]/+page.svelte
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { downloadBackup } from "$lib/backup/backup";
|
||||||
|
import { initSerial, serialPort } from "$lib/serial/connection";
|
||||||
|
import { fade, slide } from "svelte/transition";
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
|
||||||
|
let working = $state(false);
|
||||||
|
let success = $state(false);
|
||||||
|
let error = $state<Error | undefined>(undefined);
|
||||||
|
|
||||||
|
let step = $state(0);
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
working = true;
|
||||||
|
error = undefined;
|
||||||
|
success = false;
|
||||||
|
const port = $serialPort!;
|
||||||
|
$serialPort = undefined;
|
||||||
|
try {
|
||||||
|
const file = await fetch(otaUrl!).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.device : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
let uf2Url = $derived(
|
||||||
|
data.uf2
|
||||||
|
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.uf2.name}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
let otaUrl = $derived(
|
||||||
|
data.ota
|
||||||
|
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.ota.name}`
|
||||||
|
: 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 (!uf2Url) return;
|
||||||
|
const uf2Promise = fetch(uf2Url).then((it) => it.blob());
|
||||||
|
const handle = await window.showSaveFilePicker({
|
||||||
|
id: `${data.device}-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;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h2>
|
||||||
|
<a class="inline-link" href="/ccos">CCOS</a> /
|
||||||
|
<a
|
||||||
|
href="/ccos/{data.device}"
|
||||||
|
class="device inline-link"
|
||||||
|
class:correct-device={isCorrectDevice === true}
|
||||||
|
class:incorrect-device={isCorrectDevice === false}>{data.device}</a
|
||||||
|
>
|
||||||
|
/ <em class="version">{data.version}</em>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if data.ota && !data.device.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.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}
|
||||||
|
|
||||||
|
<ul class="files">
|
||||||
|
{#if data.uf2}
|
||||||
|
<li>
|
||||||
|
<a target="_blank" download href={uf2Url}
|
||||||
|
>{data.uf2.name} <span class="icon">download</span><span class="size"
|
||||||
|
>{toByteUnit(data.uf2.size)}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if data.ota}
|
||||||
|
<li>
|
||||||
|
<a target="_blank" download href={otaUrl}
|
||||||
|
>{data.ota.name} <span class="icon">download</span><span class="size"
|
||||||
|
>{toByteUnit(data.uf2.size)}</span
|
||||||
|
></a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if isCorrectDevice === false}
|
||||||
|
<div transition:slide class="incorrect-device">
|
||||||
|
These files are incompatible with your device
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>UF2 Instructions</h4>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
h2 > em {
|
||||||
|
font-style: normal;
|
||||||
|
transition: color 200ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-block-start: 4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: calc(min(100%, 16cm));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
20
src/routes/(app)/ccos/[device]/[version]/+page.ts
Normal file
20
src/routes/(app)/ccos/[device]/[version]/+page.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
import type { FileListing, Listing } from "../../listing";
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return {
|
||||||
|
uf2: data.find(
|
||||||
|
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
|
||||||
|
) as FileListing,
|
||||||
|
ota: data.find(
|
||||||
|
(entry) => entry.type === "file" && entry.name === "firmware.bin",
|
||||||
|
),
|
||||||
|
version: params.version,
|
||||||
|
device: params.device,
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
||||||
14
src/routes/(app)/ccos/listing.ts
Normal file
14
src/routes/(app)/ccos/listing.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1 +1,92 @@
|
|||||||
<h2>WIP</h2>
|
<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>
|
||||||
|
|||||||
33
src/routes/(app)/chat/Login.svelte
Normal file
33
src/routes/(app)/chat/Login.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<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}
|
||||||
0
src/routes/(app)/chat/Space.svelte
Normal file
0
src/routes/(app)/chat/Space.svelte
Normal file
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
import PageTransition from "./PageTransition.svelte";
|
||||||
import Navigation from "./Navigation.svelte";
|
import Navigation from "./Navigation.svelte";
|
||||||
|
|
||||||
let { children }: { children?: Snippet } = $props();
|
let { children }: { children?: Snippet } = $props();
|
||||||
@@ -8,5 +9,9 @@
|
|||||||
<Navigation />
|
<Navigation />
|
||||||
|
|
||||||
{#if children}
|
{#if children}
|
||||||
|
<PageTransition>
|
||||||
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</PageTransition>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
import { canShare, triggerShare } from "$lib/share";
|
import { canShare, triggerShare } from "$lib/share";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "$i18n/i18n-svelte";
|
||||||
import ConfigTabs from "./ConfigTabs.svelte";
|
|
||||||
import EditActions from "./EditActions.svelte";
|
import EditActions from "./EditActions.svelte";
|
||||||
|
import { sync, syncStatus } from "$lib/serial/connection";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
@@ -12,8 +12,6 @@
|
|||||||
<EditActions />
|
<EditActions />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfigTabs />
|
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
{#if $canShare}
|
{#if $canShare}
|
||||||
<button
|
<button
|
||||||
@@ -40,7 +38,7 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
nav {
|
nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
|
|
||||||
width: calc(min(100%, 28cm));
|
width: calc(min(100%, 28cm));
|
||||||
margin-block: 8px;
|
margin-block: 8px;
|
||||||
@@ -48,6 +46,20 @@
|
|||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes syncing {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.syncing {
|
||||||
|
transform-origin: 50% 49%;
|
||||||
|
animation: syncing 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
74
src/routes/(app)/config/PageTransition.svelte
Normal file
74
src/routes/(app)/config/PageTransition.svelte
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<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>
|
||||||
@@ -262,9 +262,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
||||||
{#if chord}
|
{#if chord}
|
||||||
<tr>
|
|
||||||
<ChordEdit {chord} onduplicate={() => (page = 0)} />
|
<ChordEdit {chord} onduplicate={() => (page = 0)} />
|
||||||
</tr>
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}</tbody
|
{/each}</tbody
|
||||||
>
|
>
|
||||||
@@ -397,7 +395,7 @@
|
|||||||
|
|
||||||
table {
|
table {
|
||||||
height: fit-content;
|
height: fit-content;
|
||||||
overflow: hidden;
|
overflow-y: hidden;
|
||||||
transition: all 1s ease;
|
transition: all 1s ease;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
id.splice(id.indexOf(0), 1);
|
id.splice(id.indexOf(0), 1);
|
||||||
id.push(0);
|
id.push(0);
|
||||||
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
|
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
|
||||||
id[id.length - 1]!++;
|
id[id.length - 1] = id[id.length - 1]! + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
@@ -89,13 +89,15 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<th>
|
<tr>
|
||||||
|
<th>
|
||||||
<ChordActionEdit {chord} onsubmit={() => {}} />
|
<ChordActionEdit {chord} onsubmit={() => {}} />
|
||||||
</th>
|
</th>
|
||||||
<td>
|
<td class="phrase-edit">
|
||||||
<ChordPhraseEdit {chord} />
|
<ChordPhraseEdit {chord} />
|
||||||
</td>
|
</td>
|
||||||
<td class="table-buttons">
|
<td>
|
||||||
|
<div class="table-buttons">
|
||||||
{#if !chord.deleted}
|
{#if !chord.deleted}
|
||||||
<button transition:slide class="icon compact" onclick={remove}
|
<button transition:slide class="icon compact" onclick={remove}
|
||||||
>delete</button
|
>delete</button
|
||||||
@@ -115,7 +117,9 @@
|
|||||||
>
|
>
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
<button class="icon compact" onclick={share}>share</button>
|
<button class="icon compact" onclick={share}>share</button>
|
||||||
</td>
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.separator {
|
.separator {
|
||||||
@@ -132,17 +136,29 @@
|
|||||||
transition: opacity 75ms ease;
|
transition: opacity 75ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
td {
|
.phrase-edit {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-buttons {
|
.table-buttons {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 75ms ease;
|
transition: opacity 75ms ease;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: translate(100%, -50%);
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(tr):focus-within > .table-buttons,
|
.icon {
|
||||||
:global(tr):hover > .table-buttons {
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover .table-buttons {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
21
src/routes/(app)/config/chords/ChordEditActions.svelte
Normal file
21
src/routes/(app)/config/chords/ChordEditActions.svelte
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<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>
|
||||||
@@ -4,6 +4,16 @@
|
|||||||
import { serialPort } from "$lib/serial/connection";
|
import { serialPort } from "$lib/serial/connection";
|
||||||
import { setting } from "$lib/setting";
|
import { setting } from "$lib/setting";
|
||||||
import ResetPopup from "./ResetPopup.svelte";
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -11,8 +21,67 @@
|
|||||||
<meta name="description" content="Change your device's settings" />
|
<meta name="description" content="Change your device's settings" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{#if $serialPort}
|
<section>
|
||||||
<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}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend
|
<legend
|
||||||
><label
|
><label
|
||||||
@@ -231,20 +300,6 @@
|
|||||||
>
|
>
|
||||||
</fieldset>
|
</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"}
|
{#if $serialPort.device === "LITE"}
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend
|
<legend
|
||||||
@@ -275,8 +330,8 @@
|
|||||||
</select>
|
</select>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
{/if}
|
||||||
{/if}
|
</section>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
section {
|
section {
|
||||||
@@ -315,18 +370,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
font-size: 12px;
|
font-size: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
|
|
||||||
&:has(> legend input:not(:checked)) > :not(legend) {
|
/*&:has(> legend input:not(:checked)) > :not(legend) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}*/
|
||||||
|
|
||||||
> label {
|
> label {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -429,4 +487,14 @@
|
|||||||
content: "•";
|
content: "•";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
margin-block: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import ConfirmChallenge from "./ConfirmChallenge.svelte";
|
import ConfirmChallenge from "./ConfirmChallenge.svelte";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
export const confirmChallenge: Action<
|
export const confirmChallenge: Action<
|
||||||
HTMLElement,
|
HTMLElement,
|
||||||
{ onConfirm: () => void; challenge: string }
|
{ onConfirm: () => void; challenge: string }
|
||||||
> = (node, { onConfirm, challenge }) => {
|
> = (node, { onConfirm, challenge }) => {
|
||||||
let component: ConfirmChallenge | undefined;
|
let component: {} | undefined;
|
||||||
let target: HTMLElement | undefined;
|
let target: HTMLElement | undefined;
|
||||||
const edit = tippy(node, {
|
const edit = tippy(node, {
|
||||||
interactive: true,
|
interactive: true,
|
||||||
@@ -15,15 +16,22 @@ export const confirmChallenge: Action<
|
|||||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||||
target.classList.add("active");
|
target.classList.add("active");
|
||||||
if (component === undefined) {
|
if (component === undefined) {
|
||||||
component = new ConfirmChallenge({ target, props: { challenge } });
|
component = mount(ConfirmChallenge, {
|
||||||
component.$on("confirm", () => {
|
target,
|
||||||
|
props: {
|
||||||
|
challenge,
|
||||||
|
onconfirm() {
|
||||||
edit.hide();
|
edit.hide();
|
||||||
onConfirm();
|
onConfirm();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHidden() {
|
onHidden() {
|
||||||
component?.$destroy();
|
if (component) {
|
||||||
|
unmount(component);
|
||||||
|
}
|
||||||
target?.classList.remove("active");
|
target?.classList.remove("active");
|
||||||
component = undefined;
|
component = undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
0
src/routes/(app)/learn/Pick.svelte
Normal file
0
src/routes/(app)/learn/Pick.svelte
Normal file
@@ -155,12 +155,13 @@
|
|||||||
doc: examplePlugin,
|
doc: examplePlugin,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
let channels = $derived(
|
|
||||||
$serialPort
|
let channels = $derived.by(() => {
|
||||||
? ({
|
if (!$serialPort) return {} as any;
|
||||||
getVersion: async (..._args: unknown[]) => $serialPort.version,
|
return {
|
||||||
getDevice: async (..._args: unknown[]) => $serialPort.device,
|
getVersion: (..._args: unknown[]) => Promise.resolve($serialPort.version),
|
||||||
commit: async (..._args: unknown[]) => {
|
getDevice: (..._args: unknown[]) => Promise.resolve($serialPort.device),
|
||||||
|
commit: (..._args: unknown[]) => {
|
||||||
if (
|
if (
|
||||||
confirm(
|
confirm(
|
||||||
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
|
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
|
||||||
@@ -168,17 +169,17 @@
|
|||||||
"Click OK to perform the commit anyways.",
|
"Click OK to perform the commit anyways.",
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return $serialPort.commit();
|
return Promise.resolve($serialPort.commit());
|
||||||
}
|
}
|
||||||
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
charaMethods.map(
|
charaMethods.map(
|
||||||
(it) => [it, $serialPort[it].bind($serialPort)] as const,
|
(it) => [it, $serialPort[it].bind($serialPort)] as const,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} satisfies Record<string, Function>)
|
} satisfies Record<string, Function>;
|
||||||
: ({} as any),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
async function onMessage(event: MessageEvent) {
|
async function onMessage(event: MessageEvent) {
|
||||||
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
|
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
|
||||||
|
|||||||
0
src/routes/(app)/stats/+page.svelte
Normal file
0
src/routes/(app)/stats/+page.svelte
Normal file
@@ -1,12 +0,0 @@
|
|||||||
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]
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// noinspection ES6PreferShortImport
|
// noinspection ES6PreferShortImport
|
||||||
import { themeColor } from "./src/lib/style/theme.server";
|
import { themeColor } from "./src/lib/style/theme";
|
||||||
import { sveltekit } from "@sveltejs/kit/vite";
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||||
@@ -22,6 +22,8 @@ process.env["VITE_BUGS_URL"] = bugs.url;
|
|||||||
process.env["VITE_LEARN_URL"] = "https://www.iq-eq.io/";
|
process.env["VITE_LEARN_URL"] = "https://www.iq-eq.io/";
|
||||||
process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
|
process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
|
||||||
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
|
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({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
@@ -32,6 +34,9 @@ export default defineConfig({
|
|||||||
external: isTauri ? [/virtual:pwa.*/] : [],
|
external: isTauri ? [/virtual:pwa.*/] : [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
define: {
|
||||||
|
global: "window",
|
||||||
|
},
|
||||||
envPrefix: ["TAURI_", "VITE_"],
|
envPrefix: ["TAURI_", "VITE_"],
|
||||||
plugins: [
|
plugins: [
|
||||||
ViteYaml(),
|
ViteYaml(),
|
||||||
@@ -42,6 +47,7 @@ export default defineConfig({
|
|||||||
SvelteKitPWA({
|
SvelteKitPWA({
|
||||||
kit: {
|
kit: {
|
||||||
trailingSlash: "always",
|
trailingSlash: "always",
|
||||||
|
adapterFallback: "404.html",
|
||||||
},
|
},
|
||||||
scope: "/",
|
scope: "/",
|
||||||
base: "/",
|
base: "/",
|
||||||
@@ -49,10 +55,12 @@ export default defineConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
// https://vite-pwa-org.netlify.app/frameworks/sveltekit.html#globpatterns
|
// https://vite-pwa-org.netlify.app/frameworks/sveltekit.html#globpatterns
|
||||||
globPatterns: [
|
globPatterns: [
|
||||||
"client/**/*.{js,map,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
||||||
"prerendered/**/*.html",
|
"prerendered/**/*.html",
|
||||||
],
|
],
|
||||||
ignoreURLParametersMatching: [/^import$/],
|
globIgnores: ["prerendered/pages/ccos/**/*"],
|
||||||
|
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
|
||||||
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: "CharaChorder Device Manager",
|
name: "CharaChorder Device Manager",
|
||||||
|
|||||||
Reference in New Issue
Block a user