An app for logging board climbs
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add civility/sync

+290 -65
+7 -6
deno.json
··· 1 1 { 2 - "version": "0.1.5", 2 + "version": "1.0.0", 3 3 "compilerOptions": { 4 4 "lib": [ 5 5 "deno.ns", ··· 18 18 "fetch:benchmarks": "deno run --allow-net --allow-read --allow-write --allow-env scripts/moon.ts" 19 19 }, 20 20 "imports": { 21 - "@civility/store": "jsr:@civility/store@^1.0.0-beta.2", 22 - "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.2/idb", 23 - "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.2", 24 - "@civility/ui": "jsr:@civility/ui@^0.2.2", 25 - "@civility/workers": "jsr:@civility/workers@^0.2.3", 21 + "@civility/blobs": "jsr:@civility/blobs@^1.0.0-beta.4", 22 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.6", 23 + "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.6/idb", 24 + "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.7", 25 + "@civility/ui": "jsr:@civility/ui@^1.0.0-beta.1", 26 + "@civility/workers": "jsr:@civility/workers@^0.2.5", 26 27 "@inro/simple-tools": "jsr:@inro/simple-tools@^0.5.2", 27 28 "@std/assert": "jsr:@std/assert@^1.0.19", 28 29 "@std/dotenv": "jsr:@std/dotenv@^0.225.6",
+100 -15
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 - "jsr:@civility/store@^1.0.0-beta.2": "1.0.0-beta.2", 5 - "jsr:@civility/ui@~0.2.2": "0.2.2", 6 - "jsr:@civility/workers@~0.2.3": "0.2.3", 4 + "jsr:@civility/blobs@^1.0.0-beta.4": "1.0.0-beta.4", 5 + "jsr:@civility/store@^1.0.0-beta.6": "1.0.0-beta.6", 6 + "jsr:@civility/sync@^1.0.0-beta.7": "1.0.0-beta.7", 7 + "jsr:@civility/ui@^1.0.0-beta.1": "1.0.0-beta.1", 8 + "jsr:@civility/workers@~0.2.5": "0.2.5", 7 9 "jsr:@inro/simple-tools@~0.5.2": "0.5.2", 10 + "jsr:@paulmillr/qr@~0.5.5": "0.5.5", 11 + "jsr:@std/assert@^1.0.17": "1.0.19", 12 + "jsr:@std/assert@^1.0.19": "1.0.19", 13 + "jsr:@std/collections@^1.1.0": "1.1.6", 14 + "jsr:@std/data-structures@^1.0.10": "1.0.10", 8 15 "jsr:@std/dotenv@~0.225.6": "0.225.6", 16 + "jsr:@std/fs@^1.0.17": "1.0.23", 17 + "jsr:@std/fs@^1.0.22": "1.0.23", 18 + "jsr:@std/fs@^1.0.23": "1.0.23", 9 19 "jsr:@std/html@^1.0.5": "1.0.5", 20 + "jsr:@std/internal@^1.0.12": "1.0.12", 21 + "jsr:@std/path@^1.1.4": "1.1.4", 10 22 "jsr:@std/semver@^1.0.8": "1.0.8", 23 + "jsr:@std/testing@^1.0.17": "1.0.17", 11 24 "jsr:@std/ulid@1": "1.0.0", 12 25 "jsr:@zod/zod@^4.3.6": "4.3.6", 26 + "npm:@tauri-apps/plugin-store@^2.2.0": "2.4.2", 13 27 "npm:fast-json-patch@^3.1.1": "3.1.1", 14 28 "npm:hammerjs@^2.0.8": "2.0.8", 15 - "npm:lit@^3.3.2": "3.3.2" 29 + "npm:lit@^3.3.2": "3.3.2", 30 + "npm:ts-fsrs@5": "5.3.1" 16 31 }, 17 32 "jsr": { 18 - "@civility/store@1.0.0-beta.2": { 19 - "integrity": "8c112db7c5e1e4d52d19a0a3a4f90eab2e3c6ffe3a11ae173bc8ed637d11df05", 33 + "@civility/blobs@1.0.0-beta.4": { 34 + "integrity": "6806eb2a5b02e9e611385107b539abe0b2fe8e17066cfc42eaf467e301a6afa0" 35 + }, 36 + "@civility/store@1.0.0-beta.6": { 37 + "integrity": "92226d6e669fd90da7dac1da9f60c63587fb194df1d59a34791f3b827c000383", 20 38 "dependencies": [ 39 + "jsr:@std/fs@^1.0.23", 40 + "jsr:@std/path", 21 41 "jsr:@std/semver", 22 42 "jsr:@std/ulid", 23 43 "npm:fast-json-patch" 24 44 ] 25 45 }, 26 - "@civility/ui@0.2.2": { 27 - "integrity": "a6ff0910c171767da70c88fcf8bc6b46dcb09c60f07c60707f398df4aca5017d", 46 + "@civility/sync@1.0.0-beta.7": { 47 + "integrity": "2997902549d7fbe6810c208efa8cd79c852192ca1c36cad90dc6fc1f27a6cf47", 48 + "dependencies": [ 49 + "jsr:@paulmillr/qr" 50 + ] 51 + }, 52 + "@civility/ui@1.0.0-beta.1": { 53 + "integrity": "34baed127597c084f0326649de1f9d0d025d5cf91ec81a35e3048c8919e01eb2", 28 54 "dependencies": [ 29 55 "jsr:@std/html", 30 56 "npm:lit" 31 57 ] 32 58 }, 33 - "@civility/workers@0.2.3": { 34 - "integrity": "84130ff9b3c5d0ee133d8ed076dd86d5ea4a3bb8f49c06c114959eb4e0c66602" 59 + "@civility/workers@0.2.5": { 60 + "integrity": "5a27340c55972cc71042d4b3ce9c6a8d508e31a77fe6133f94ccdb0d48db0e40" 35 61 }, 36 62 "@inro/simple-tools@0.5.2": { 37 - "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5" 63 + "integrity": "cc34cd0914b9e0576d9bed9a66a91994123b73f3fd87a4e8db76880181731ee5", 64 + "dependencies": [ 65 + "jsr:@std/collections", 66 + "jsr:@std/fs@^1.0.17", 67 + "npm:@tauri-apps/plugin-store", 68 + "npm:ts-fsrs" 69 + ] 70 + }, 71 + "@paulmillr/qr@0.5.5": { 72 + "integrity": "2f8ff22c8d2194f2147eac1b3093f5e85f648c0a8005d5635a617fb72bf5ae38" 73 + }, 74 + "@std/assert@1.0.19": { 75 + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", 76 + "dependencies": [ 77 + "jsr:@std/internal" 78 + ] 79 + }, 80 + "@std/collections@1.1.6": { 81 + "integrity": "b458160ce65ea5ad35da05d0a5cbee4b583677c8b443a10d7beb0c4ac63f2baa" 82 + }, 83 + "@std/data-structures@1.0.10": { 84 + "integrity": "f574f86b0e07c69b9edc555fcc814b57d29258bad39fd5a34ba8a80ecf033cfe" 38 85 }, 39 86 "@std/dotenv@0.225.6": { 40 87 "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" 41 88 }, 89 + "@std/fs@1.0.23": { 90 + "integrity": "3ecbae4ce4fee03b180fa710caff36bb5adb66631c46a6460aaad49515565a37", 91 + "dependencies": [ 92 + "jsr:@std/path" 93 + ] 94 + }, 42 95 "@std/html@1.0.5": { 43 96 "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" 44 97 }, 98 + "@std/internal@1.0.12": { 99 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 100 + }, 101 + "@std/path@1.1.4": { 102 + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", 103 + "dependencies": [ 104 + "jsr:@std/internal" 105 + ] 106 + }, 45 107 "@std/semver@1.0.8": { 46 108 "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" 47 109 }, 110 + "@std/testing@1.0.17": { 111 + "integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681", 112 + "dependencies": [ 113 + "jsr:@std/assert@^1.0.17", 114 + "jsr:@std/data-structures", 115 + "jsr:@std/fs@^1.0.22", 116 + "jsr:@std/internal", 117 + "jsr:@std/path" 118 + ] 119 + }, 48 120 "@std/ulid@1.0.0": { 49 121 "integrity": "d41c3d27a907714413649fee864b7cde8d42ee68437d22b79d5de4f81d808780" 50 122 }, ··· 62 134 "@lit-labs/ssr-dom-shim" 63 135 ] 64 136 }, 137 + "@tauri-apps/api@2.10.1": { 138 + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==" 139 + }, 140 + "@tauri-apps/plugin-store@2.4.2": { 141 + "integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==", 142 + "dependencies": [ 143 + "@tauri-apps/api" 144 + ] 145 + }, 65 146 "@types/trusted-types@2.0.7": { 66 147 "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" 67 148 }, ··· 92 173 "lit-element", 93 174 "lit-html" 94 175 ] 176 + }, 177 + "ts-fsrs@5.3.1": { 178 + "integrity": "sha512-PP3X4E014TX5QvteWxcrjqDkL8+NDiHhL4ACppejxSWansOndXpk/5EMljYlORRueDJL7ngL7YRC5RhBUhiJ3g==" 95 179 } 96 180 }, 97 181 "workspace": { 98 182 "dependencies": [ 99 - "jsr:@civility/store@^1.0.0-beta.2", 100 - "jsr:@civility/sync@^1.0.0-beta.2", 101 - "jsr:@civility/ui@~0.2.2", 102 - "jsr:@civility/workers@~0.2.3", 183 + "jsr:@civility/blobs@^1.0.0-beta.4", 184 + "jsr:@civility/store@^1.0.0-beta.6", 185 + "jsr:@civility/sync@^1.0.0-beta.7", 186 + "jsr:@civility/ui@^1.0.0-beta.1", 187 + "jsr:@civility/workers@~0.2.5", 103 188 "jsr:@inro/simple-tools@~0.5.2", 104 189 "jsr:@std/assert@^1.0.19", 105 190 "jsr:@std/dotenv@~0.225.6",
+7
www/models/app.ts
··· 7 7 } from './schema.ts' 8 8 import { LogbookStore } from './store.ts' 9 9 import type { Benchmark } from '../utils/benchmarks.ts' 10 + import { Synced } from '@civility/sync' 10 11 11 12 export interface NavState { 12 13 climbId: number ··· 52 53 export class App { 53 54 store: LogbookStore 54 55 settings: Settings 56 + synced: Synced 55 57 navState: NavState | null = null 56 58 #appState: AppState = { error: null } 57 59 #listeners = new Set<() => void>() ··· 59 61 constructor() { 60 62 this.store = new LogbookStore() 61 63 this.settings = new Settings('mb-settings') 64 + this.synced = new Synced({ 65 + stores: [this.store.syncStore], 66 + appId: 'moonboard', 67 + }) 62 68 this.store.addEventListener(() => this.notify()) 63 69 this.settings.addEventListener(() => this.notify()) 64 70 } ··· 220 226 } 221 227 222 228 async dispose(): Promise<void> { 229 + this.synced.dispose() 223 230 await this.store.dispose() 224 231 } 225 232 }
+4 -3
www/models/migrations/pre-0.js
··· 18 18 * Run in the browser console on either the old or new moonboard app version. 19 19 * Then use the app's Import function to load the downloaded JSON. 20 20 */ 21 - ; (() => { 21 + ;(() => { 22 22 // --------------------------------------------------------------------------- 23 23 // Config — adjust TARGET_VERSION to match the new app's declared version. 24 24 // --------------------------------------------------------------------------- ··· 46 46 47 47 let _counter = 0 48 48 function makeHLC() { 49 - return `${String(BASE_MS).padStart(15, '0')}-${String(_counter++).padStart(5, '0') 50 - }-${ORIGIN}` 49 + return `${String(BASE_MS).padStart(15, '0')}-${ 50 + String(_counter++).padStart(5, '0') 51 + }-${ORIGIN}` 51 52 } 52 53 53 54 function makeDocumentRecord(scopedId, data, hlc) {
+6 -6
www/models/schema.ts
··· 5 5 ClimbLogEntryJsonSchema, 6 6 ClimbSession, 7 7 defaultAppSettings, 8 + GradeScale, 9 + LogFilter, 8 10 StoreState, 9 11 } from './schema/v0.ts' 10 12 ··· 12 14 import { ClimbLogEntryJsonSchema } from './schema/v0.ts' 13 15 14 16 export const logbookSchema: SchemaConfig = { 15 - versions: [ 16 - { 17 - version: '1.0.0', 18 - schema: ClimbLogEntryJsonSchema, 19 - }, 20 - ], 17 + versions: [{ 18 + version: globalThis.__APP_VERSION__ ?? '0.0.0', 19 + schema: ClimbLogEntryJsonSchema, 20 + }], 21 21 }
+11 -5
www/models/schema/v0.ts
··· 1 1 import { z } from '@zod/zod' 2 - import type { JsonSchema } from '@civility/store' 2 + import type { JSONSchema } from '@civility/store' 3 3 4 4 export const ClimbSession = z.object({ 5 5 date: z.string(), ··· 25 25 26 26 export const ClimbLogEntryJsonSchema = z.toJSONSchema( 27 27 ClimbLogEntry, 28 - ) as JsonSchema 28 + ) as JSONSchema 29 29 30 30 // Old StoreState shape — used only for importing legacy exports 31 31 export const StoreState = z.object({ ··· 34 34 }) 35 35 export type StoreState = z.infer<typeof StoreState> 36 36 37 + export const LogFilter = z.enum(['all', 'sent', 'unsent']) 38 + export type LogFilter = z.infer<typeof LogFilter> 39 + 40 + export const GradeScale = z.enum(['french', 'v']) 41 + export type GradeScale = z.infer<typeof GradeScale> 42 + 37 43 export const AppSettings = z.object({ 38 44 syncLinkUrl: z.string().default(''), 39 45 mbType: z.number().int().min(0).max(6).default(0), 40 - gradeScale: z.enum(['french', 'v']).default('french'), 46 + gradeScale: GradeScale.default(GradeScale.enum.french), 41 47 homeGradeMin: z.number().int().min(0).max(16).default(0), 42 48 homeGradeMax: z.number().int().min(0).max(16).default(16), 43 - homeFilter: z.enum(['all', 'sent', 'unsent']).default('all'), 44 - libraryFilter: z.enum(['all', 'sent', 'unsent']).default('all'), 49 + homeFilter: LogFilter.default(LogFilter.enum.all), 50 + libraryFilter: LogFilter.default(LogFilter.enum.all), 45 51 runningTimer: z.string().nullable().default(null), 46 52 }) 47 53 export type AppSettings = z.infer<typeof AppSettings>
+9 -4
www/models/store.ts
··· 1 - import { Store, type StoreExport } from '@civility/store' 1 + import { Collection, type CollectionExport } from '@civility/store' 2 2 import { IDBStorage } from '@civility/store/idb' 3 3 import { type ClimbLogEntry, logbookSchema, type StoreState } from './schema.ts' 4 4 5 5 const backend = new IDBStorage<ClimbLogEntry>({ dbName: 'moonboard' }) 6 6 7 7 export class LogbookStore { 8 - #store: Store<ClimbLogEntry> 8 + #store: Collection<ClimbLogEntry> 9 9 #cache: Record<string, ClimbLogEntry> = {} 10 10 #ready: Promise<void> 11 11 #listeners = new Set<() => void>() 12 12 13 13 constructor() { 14 - this.#store = new Store<ClimbLogEntry>(backend, { 14 + this.#store = new Collection<ClimbLogEntry>(backend, { 15 15 name: 'logbook', 16 16 schema: logbookSchema, 17 17 }) ··· 36 36 37 37 waitUntilReady(): Promise<void> { 38 38 return this.#ready 39 + } 40 + 41 + /** Underlying collection — consumed by `@civility/sync`'s `Synced` class. */ 42 + get syncStore(): Collection<ClimbLogEntry> { 43 + return this.#store 39 44 } 40 45 41 46 // Synchronous cache access for routes ··· 115 120 116 121 if (raw.documents) { 117 122 // New StoreExport format 118 - await this.#store.import(raw as StoreExport<ClimbLogEntry>) 123 + await this.#store.import(raw as CollectionExport<ClimbLogEntry>) 119 124 } else { 120 125 const logbook = raw.logbook ?? raw.data?.logbook 121 126 if (logbook && typeof logbook === 'object') {
+13 -12
www/routes/home.ts
··· 8 8 gradeLabel, 9 9 loadBenchmarks, 10 10 } from '../utils/benchmarks.ts' 11 + import { GradeScale, LogFilter } from '../models/schema.ts' 11 12 import app from '../models/app.ts' 13 + 14 + const { all, sent, unsent } = LogFilter.enum 12 15 13 16 export const PAGE_SIZE = 50 14 - 15 - type LogFilter = 'all' | 'sent' | 'unsent' 16 17 17 18 export const state = { 18 19 search: '', 19 20 gradeMin: 0, 20 21 gradeMax: 16, 21 - logFilter: 'all' as LogFilter, 22 + logFilter: all as LogFilter, 22 23 shown: PAGE_SIZE, 23 24 } 24 25 25 26 export const emitter = new EventTarget() 26 27 27 28 export class HomeFilters extends LitElement { 28 - private gradeScale: 'french' | 'v' = 'french' 29 + private gradeScale: GradeScale = GradeScale.enum.french 29 30 30 31 protected override createRenderRoot() { 31 32 return this ··· 151 152 152 153 private logFilterHtml(): string { 153 154 const opts: { value: LogFilter; label: string }[] = [ 154 - { value: 'all', label: 'All' }, 155 - { value: 'sent', label: 'Completed' }, 156 - { value: 'unsent', label: 'Not Completed' }, 155 + { value: all, label: 'All' }, 156 + { value: sent, label: 'Completed' }, 157 + { value: unsent, label: 'Not Completed' }, 157 158 ] 158 159 return opts 159 160 .map( ··· 166 167 } 167 168 168 169 private gradeGroups(): { label: string; first: number; last: number }[] { 169 - const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 170 + const table = this.gradeScale === GradeScale.enum.v ? GRADE_V : GRADE_FRENCH 170 171 const groups: { label: string; first: number; last: number }[] = [] 171 172 for (let i = 0; i <= 16; i++) { 172 173 const label = table[i] ?? '?' ··· 198 199 private benchmarks: Benchmark[] = [] 199 200 private filtered: Benchmark[] = [] 200 201 private mbType = 0 201 - private gradeScale: 'french' | 'v' = 'french' 202 + private gradeScale: GradeScale = GradeScale.enum.french 202 203 private loading = true 203 204 private error = false 204 205 ··· 290 291 291 292 private applyFilters(): void { 292 293 const q = state.search.toLowerCase() 293 - const logbook = state.logFilter !== 'all' ? app.logbook : {} 294 + const logbook = state.logFilter !== all ? app.logbook : {} 294 295 this.filtered = this.benchmarks.filter((b) => { 295 296 if (b.grade < state.gradeMin || b.grade > state.gradeMax) return false 296 297 if ( ··· 300 301 ) { 301 302 return false 302 303 } 303 - if (state.logFilter === 'sent') return !!logbook[b.id.toString()]?.sent 304 - if (state.logFilter === 'unsent') return !logbook[b.id.toString()]?.sent 304 + if (state.logFilter === sent) return !!logbook[b.id.toString()]?.sent 305 + if (state.logFilter === unsent) return !logbook[b.id.toString()]?.sent 305 306 return true 306 307 }) 307 308 }
+11 -10
www/routes/library.ts
··· 3 3 import type { ClimbLogEntry } from '../models/schema.ts' 4 4 import { gradeLabel } from '../utils/benchmarks.ts' 5 5 import { formatDate, starsHtml } from '../utils/format.ts' 6 + import { GradeScale, LogFilter } from '../models/schema.ts' 6 7 7 - type Filter = 'all' | 'sent' | 'unsent' 8 + const { all, sent, unsent } = LogFilter.enum 8 9 9 - export const libState = { filter: 'all' as Filter } 10 + export const libState = { filter: all as LogFilter } 10 11 export const libEmitter = new EventTarget() 11 12 12 13 export class LibraryFilters extends LitElement { ··· 38 39 #handleClick = (e: Event) => { 39 40 const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 40 41 if (!btn) return 41 - libState.filter = btn.dataset.filter as Filter 42 + libState.filter = btn.dataset.filter as LogFilter 42 43 app.updateSettings({ libraryFilter: libState.filter }) 43 44 this.requestUpdate() 44 45 libEmitter.dispatchEvent(new Event('filter')) 45 46 } 46 47 47 48 override render(): TemplateResult { 48 - const opts: { value: Filter; label: string }[] = [ 49 - { value: 'all', label: 'All' }, 50 - { value: 'sent', label: 'Completed' }, 51 - { value: 'unsent', label: 'Not Completed' }, 49 + const opts: { value: LogFilter; label: string }[] = [ 50 + { value: all, label: 'All' }, 51 + { value: sent, label: 'Completed' }, 52 + { value: unsent, label: 'Not Completed' }, 52 53 ] 53 54 return html` 54 55 <ui-button-group> ··· 68 69 } 69 70 70 71 export class LibraryPage extends LitElement { 71 - private gradeScale: 'french' | 'v' = 'french' 72 + private gradeScale: GradeScale = GradeScale.enum.french 72 73 73 74 protected override createRenderRoot() { 74 75 return this ··· 126 127 override render(): TemplateResult { 127 128 const all = this.sortedEntries() 128 129 const filtered = all.filter((e) => { 129 - if (libState.filter === 'sent') return e.sent 130 - if (libState.filter === 'unsent') return !e.sent 130 + if (libState.filter === sent) return e.sent 131 + if (libState.filter === unsent) return !e.sent 131 132 return true 132 133 }) 133 134
+17 -4
www/routes/settings.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 import app from '../models/app.ts' 3 + import { GradeScale } from '../models/schema.ts' 3 4 4 5 const BOARD_OPTIONS = [ 5 6 { mbType: 0, label: '2016 40°' }, ··· 13 14 14 15 const GRADE_SCALE_OPTIONS = [ 15 16 { 16 - value: 'french', 17 + value: GradeScale.enum.french, 17 18 label: 'Font (French)', 18 19 example: '5+, 6A, 7B+, 8B+', 19 20 }, 20 21 { 21 - value: 'v', 22 + value: GradeScale.enum.v, 22 23 label: 'V-Grade', 23 24 example: 'V1, V2, V8, V14', 24 25 }, ··· 26 27 27 28 export class SettingsPage extends LitElement { 28 29 private selectedType = 0 29 - private gradeScale = 'french' 30 + private gradeScale: GradeScale = GradeScale.enum.french 30 31 31 32 protected override createRenderRoot() { 32 33 return this ··· 92 93 ?checked="${this.gradeScale === opt.value}" 93 94 @change="${() => 94 95 app.updateSettings({ 95 - gradeScale: opt.value as 'french' | 'v', 96 + gradeScale: opt.value as GradeScale, 96 97 })}" 97 98 > 98 99 <div> ··· 103 104 ` 104 105 )} 105 106 </div> 107 + </section> 108 + 109 + <section> 110 + <h2>Sync</h2> 111 + <p> 112 + Sync your logbook across devices. Connect to a Civility server and 113 + authenticate with an API token from the server dashboard. 114 + </p> 115 + <ui-sync 116 + storage-key="moonboard-sync" 117 + .synced="${app.synced}" 118 + ></ui-sync> 106 119 </section> 107 120 108 121 <section>
+105
www/static/theme.css
··· 707 707 opacity: 0.7; 708 708 } 709 709 710 + /* ── Link-style button ──────────────────────────────────────────────────── */ 711 + 712 + button.link, 713 + a.link { 714 + background: none; 715 + border: none; 716 + padding: 0; 717 + color: var(--primary, inherit); 718 + text-decoration: underline; 719 + cursor: pointer; 720 + font-size: inherit; 721 + font-family: inherit; 722 + } 723 + 724 + button.link:hover, 725 + a.link:hover { 726 + opacity: 0.7; 727 + } 728 + 729 + /* ── ui-sync component ──────────────────────────────────────────────────── */ 730 + 731 + ui-sync form { 732 + display: flex; 733 + flex-direction: column; 734 + gap: var(--s3); 735 + } 736 + 737 + ui-sync label { 738 + display: flex; 739 + flex-direction: column; 740 + gap: var(--s1); 741 + cursor: default; 742 + border: none; 743 + padding: 0; 744 + font-weight: var(--fw-normal); 745 + border-radius: 0; 746 + background: none; 747 + } 748 + 749 + ui-sync label span { 750 + font-size: var(--f6); 751 + opacity: 0.7; 752 + } 753 + 754 + ui-sync label input { 755 + width: 100%; 756 + box-sizing: border-box; 757 + } 758 + 759 + ui-sync .ui-sync__error { 760 + font-size: var(--f6); 761 + color: var(--danger, #e05); 762 + margin: 0; 763 + } 764 + 765 + ui-sync .ui-sync__server { 766 + opacity: 0.6; 767 + margin: 0 0 var(--s2); 768 + font-size: var(--f6); 769 + overflow: hidden; 770 + text-overflow: ellipsis; 771 + white-space: nowrap; 772 + } 773 + 774 + ui-sync .ui-sync__hint { 775 + opacity: 0.6; 776 + font-size: var(--f6); 777 + display: block; 778 + } 779 + 780 + ui-sync .ui-sync__status { 781 + display: flex; 782 + align-items: baseline; 783 + gap: var(--s3); 784 + margin-bottom: var(--s3); 785 + } 786 + 787 + ui-sync .ui-sync__status small { 788 + opacity: 0.6; 789 + } 790 + 791 + ui-sync .ui-sync__indicator { 792 + font-weight: var(--fw-medium); 793 + } 794 + 795 + ui-sync .ui-sync__indicator--ok { 796 + color: var(--success, #2a2); 797 + } 798 + 799 + ui-sync .ui-sync__indicator--syncing { 800 + opacity: 0.7; 801 + } 802 + 803 + ui-sync .ui-sync__actions { 804 + display: flex; 805 + align-items: center; 806 + gap: var(--s3); 807 + flex-wrap: wrap; 808 + } 809 + 810 + ui-sync .ui-sync__actions .action { 811 + width: auto; 812 + padding: var(--s2) var(--s4); 813 + } 814 + 710 815 /* ── Logbook session dialog ─────────────────────────────────────────────── */ 711 816 712 817 #cp-dialog {