A system for building static webapps
0
fork

Configure Feed

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

feat(ui): add new components

+750 -285
+9 -8
packages/cli/stubs/pwa/www/routes/settings.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 - import type { UiDataActionMethods } from '@civility/ui' 2 + import type { StoreImportMethods } from '@civility/ui' 3 3 import app from '../models/app.ts' 4 4 5 5 export class SettingsPage extends LitElement { ··· 43 43 44 44 <section> 45 45 <h2>Sync</h2> 46 - <ui-sync storage-key="timer-sync" .synced="${app.synced}"></ui-sync> 46 + <ui-sync-input storage-key="timer-sync" .synced="${app 47 + .synced}"></ui-sync-input> 47 48 </section> 48 49 49 50 <section> 50 51 <h2>Data</h2> 51 - <ui-data-actions 52 + <ui-store-import 52 53 .methods="${{ 53 - exportData: (filename?: string) => app.exportStore(filename), 54 - importData: () => app.importStore(), 55 - deleteAllData: () => app.deleteAllData(), 56 - } as UiDataActionMethods}" 57 - ></ui-data-actions> 54 + export: (filename?: string) => app.exportStore(filename), 55 + import: (opts) => app.importStore(opts), 56 + deleteAll: () => app.deleteAllData(), 57 + } as StoreImportMethods}" 58 + ></ui-store-import> 58 59 </section> 59 60 ` 60 61 }
+4 -1
packages/store/entities/document.ts
··· 246 246 } 247 247 248 248 /** Import a document from a previous export. */ 249 - import(data: DocumentExport<T>, options?: ImportOptions): Promise<ImportResult> { 249 + import( 250 + data: DocumentExport<T>, 251 + options?: ImportOptions, 252 + ): Promise<ImportResult> { 250 253 return this.#inner.import(data, options) 251 254 } 252 255
+4 -1
packages/store/entities/store.ts
··· 875 875 * dump exported as `todos` will import into `tasks` if a subsequent 876 876 * version renamed `todos → tasks`. 877 877 */ 878 - async import(data: StoreExport, options?: ImportOptions): Promise<ImportResult> { 878 + async import( 879 + data: StoreExport, 880 + options?: ImportOptions, 881 + ): Promise<ImportResult> { 879 882 const promises: Promise<ImportResult>[] = [] 880 883 881 884 for (const [name, docExport] of Object.entries(data.documents)) {
-217
packages/ui/components/ui-data-actions.ts
··· 1 - import { html, LitElement, type TemplateResult } from 'lit' 2 - 3 - export interface UiDataActionMethods { 4 - exportData(filename?: string): Promise<{ 5 - success: boolean 6 - path?: string 7 - error?: string 8 - }> 9 - importData(): Promise<{ 10 - success: boolean 11 - path?: string 12 - error?: string 13 - }> 14 - deleteAllData(): Promise<{ success: boolean; error?: string }> 15 - } 16 - 17 - export class UiDataActions extends LitElement { 18 - static override properties = { 19 - methods: { type: Object }, 20 - _isImporting: { state: true }, 21 - _showDeleteConfirm: { state: true }, 22 - _status: { state: true }, 23 - _deleteStatus: { state: true }, 24 - } 25 - 26 - declare methods: UiDataActionMethods | null 27 - declare _isImporting: boolean 28 - declare _showDeleteConfirm: boolean 29 - declare _status: string 30 - declare _deleteStatus: string 31 - 32 - constructor() { 33 - super() 34 - this.methods = null 35 - this._isImporting = false 36 - this._showDeleteConfirm = false 37 - this._status = '' 38 - this._deleteStatus = '' 39 - } 40 - 41 - override createRenderRoot() { 42 - return this 43 - } 44 - 45 - #setStatus(msg: string, isError = false): void { 46 - this._status = isError ? `Error: ${msg}` : msg 47 - } 48 - 49 - #setDeleteStatus(msg: string): void { 50 - this._deleteStatus = msg 51 - } 52 - 53 - async #handleExport(): Promise<void> { 54 - if (!this.methods) return 55 - const btn = this.querySelector<HTMLButtonElement>('[data-export-btn]') 56 - if (btn) btn.disabled = true 57 - const result = await this.methods.exportData() 58 - if (btn) btn.disabled = false 59 - if (result.success) { 60 - this.#setStatus('Data exported successfully.') 61 - this.dispatchEvent( 62 - new CustomEvent('on-exported', { bubbles: true, composed: true }), 63 - ) 64 - } else { 65 - this.#setStatus(result.error ?? 'Export failed.', true) 66 - } 67 - } 68 - 69 - #handleDeleteAllData(): void { 70 - this._showDeleteConfirm = true 71 - this._deleteStatus = '' 72 - this.requestUpdate() 73 - } 74 - 75 - #handleDeleteCancel = (): void => { 76 - this._showDeleteConfirm = false 77 - this._deleteStatus = '' 78 - this.requestUpdate() 79 - } 80 - 81 - async #handleDeleteConfirm(): Promise<void> { 82 - if (!this.methods) return 83 - const input = this.querySelector<HTMLInputElement>( 84 - '[data-delete-confirm-input]', 85 - ) 86 - if (input?.value !== 'Delete my data') { 87 - this.#setDeleteStatus('Please type "Delete my data" exactly to confirm.') 88 - return 89 - } 90 - const btn = this.querySelector<HTMLButtonElement>( 91 - '[data-delete-confirm-btn]', 92 - ) 93 - if (btn) btn.disabled = true 94 - const result = await this.methods.deleteAllData() 95 - if (result.success) { 96 - this._showDeleteConfirm = false 97 - this.#setStatus('All data deleted.') 98 - this.dispatchEvent( 99 - new CustomEvent('on-deleted', { bubbles: true, composed: true }), 100 - ) 101 - globalThis.location.reload() 102 - } else { 103 - if (btn) btn.disabled = false 104 - this.#setDeleteStatus(result.error ?? 'Delete failed.') 105 - } 106 - } 107 - 108 - async #handleImport(): Promise<void> { 109 - if (!this.methods) return 110 - this._isImporting = true 111 - this.requestUpdate() 112 - const result = await this.methods.importData() 113 - this._isImporting = false 114 - this.requestUpdate() 115 - if (result.success) { 116 - this.#setStatus('Data imported successfully.') 117 - this.dispatchEvent( 118 - new CustomEvent('on-imported', { bubbles: true, composed: true }), 119 - ) 120 - } else { 121 - this.#setStatus(result.error ?? 'Import failed.', true) 122 - } 123 - } 124 - 125 - override render(): TemplateResult { 126 - return html` 127 - <p>Export your data to a file, or import a previously exported file.</p> 128 - <div> 129 - <button 130 - class="action" 131 - data-export-btn 132 - @click="${this.#handleExport}" 133 - > 134 - Export 135 - </button> 136 - <button 137 - class="action" 138 - ?disabled="${this._isImporting}" 139 - @click="${this.#handleImport}" 140 - > 141 - ${this._isImporting ? 'Importing...' : 'Import'} 142 - </button> 143 - </div> 144 - 145 - <button class="action action--danger" @click="${this 146 - .#handleDeleteAllData}"> 147 - Delete All Data 148 - </button> 149 - 150 - ${this._status 151 - ? html` 152 - <p data-status ${this._status.startsWith('Error') 153 - ? 'data-error' 154 - : ''}> 155 - ${this._status} 156 - </p> 157 - ` 158 - : ''} 159 - 160 - <ui-dialog 161 - ?open="${this._showDeleteConfirm}" 162 - @dismiss="${this.#handleDeleteCancel}" 163 - > 164 - <dialog> 165 - <article class="bm-dialog"> 166 - <h2 class="bm-dialog-title">Delete All Data</h2> 167 - <p> 168 - This will permanently delete all your data. This cannot be undone. 169 - </p> 170 - <p>Type <strong>Delete my data</strong> to confirm:</p> 171 - <input 172 - data-delete-confirm-input 173 - type="text" 174 - placeholder="Delete my data" 175 - autocomplete="off" 176 - > 177 - ${this._deleteStatus 178 - ? html` 179 - <p data-status data-error> 180 - ${this._deleteStatus} 181 - </p> 182 - ` 183 - : ''} 184 - <div class="bm-form-actions"> 185 - <button 186 - class="bm-btn-danger" 187 - data-delete-confirm-btn 188 - @click="${this.#handleDeleteConfirm}" 189 - > 190 - Delete 191 - </button> 192 - <button type="button" @click="${this.#handleDeleteCancel}"> 193 - Cancel 194 - </button> 195 - </div> 196 - </article> 197 - </dialog> 198 - </ui-dialog> 199 - 200 - <ui-dialog ?open="${this._isImporting}"> 201 - <dialog> 202 - <article class="bm-dialog"> 203 - <h2 class="bm-dialog-title">Importing Data</h2> 204 - <p>Please wait while your data is being imported...</p> 205 - <div style="display:flex;justify-content:center;padding:16px 0"> 206 - <ui-spinner size="lg"></ui-spinner> 207 - </div> 208 - </article> 209 - </dialog> 210 - </ui-dialog> 211 - ` 212 - } 213 - } 214 - 215 - if (!customElements.get('ui-data-actions')) { 216 - customElements.define('ui-data-actions', UiDataActions) 217 - }
+129
packages/ui/components/ui-error-display.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + 3 + // Minimal shape of CivilityApiError — avoids importing @civility/errors 4 + interface DisplayError { 5 + code: string 6 + message: string 7 + httpStatus: number 8 + } 9 + 10 + /** 11 + * `<ui-error-display>` — renders a list of structured sync errors. 12 + * 13 + * Bind `errors` to an array of `CivilityApiError`-shaped objects. Each error 14 + * renders with its code and message. Dispatches `dismiss` when the user closes 15 + * an error and `action` when they click an action button. 16 + * 17 + * @example 18 + * ```ts 19 + * const el = document.querySelector('ui-error-display') 20 + * el.errors = synced.errors 21 + * el.addEventListener('dismiss', e => synced.clearError(e.detail.error)) 22 + * el.addEventListener('action', e => handleAction(e.detail.error, e.detail.action)) 23 + * ``` 24 + */ 25 + export class UiErrorDisplay extends LitElement { 26 + static override properties = { 27 + errors: { type: Array }, 28 + } 29 + 30 + declare errors: DisplayError[] 31 + 32 + constructor() { 33 + super() 34 + this.errors = [] 35 + } 36 + 37 + override createRenderRoot() { 38 + return this 39 + } 40 + 41 + #handleDismiss(error: DisplayError) { 42 + this.dispatchEvent( 43 + new CustomEvent('dismiss', { 44 + detail: { error }, 45 + bubbles: true, 46 + composed: true, 47 + }), 48 + ) 49 + } 50 + 51 + #handleAction(error: DisplayError, action: string) { 52 + this.dispatchEvent( 53 + new CustomEvent('action', { 54 + detail: { error, action }, 55 + bubbles: true, 56 + composed: true, 57 + }), 58 + ) 59 + } 60 + 61 + override render(): TemplateResult { 62 + if (!this.errors.length) { 63 + return html` 64 + 65 + ` 66 + } 67 + return html` 68 + ${this.errors.map((error) => 69 + html` 70 + <div class="ui-error-display__error" data-code="${error.code}"> 71 + <div class="ui-error-display__body"> 72 + <code class="ui-error-display__code">${error.code}</code> 73 + <p class="ui-error-display__message">${error.message}</p> 74 + </div> 75 + <div class="ui-error-display__actions"> 76 + ${this.#renderActions(error)} 77 + <button 78 + class="ui-error-display__dismiss" 79 + @click="${() => this.#handleDismiss(error)}" 80 + > 81 + Dismiss 82 + </button> 83 + </div> 84 + </div> 85 + ` 86 + )} 87 + ` 88 + } 89 + 90 + #renderActions(error: DisplayError): TemplateResult { 91 + // Surface retry for network/timeout errors; force-push/pull for sync conflicts 92 + if ( 93 + error.code === 'sync.request_timeout' || 94 + error.code === 'sync.network_error' 95 + ) { 96 + return html` 97 + <button 98 + class="action" 99 + @click="${() => this.#handleAction(error, 'retry')}" 100 + > 101 + Retry 102 + </button> 103 + ` 104 + } 105 + if (error.code === 'sync.push_conflict') { 106 + return html` 107 + <button 108 + class="action" 109 + @click="${() => this.#handleAction(error, 'force-push')}" 110 + > 111 + Force push 112 + </button> 113 + <button 114 + class="link" 115 + @click="${() => this.#handleAction(error, 'force-pull')}" 116 + > 117 + Force pull 118 + </button> 119 + ` 120 + } 121 + return html` 122 + 123 + ` 124 + } 125 + } 126 + 127 + if (!customElements.get('ui-error-display')) { 128 + customElements.define('ui-error-display', UiErrorDisplay) 129 + }
+276
packages/ui/components/ui-store-import.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + 3 + export interface StoreImportMethods { 4 + export(filename?: string): Promise<{ success: boolean; error?: string }> 5 + import(options?: { 6 + mode?: 'replace' | 'extend' 7 + }): Promise<{ success: boolean; error?: string }> 8 + deleteAll(): Promise<{ success: boolean; error?: string }> 9 + } 10 + 11 + /** 12 + * `<ui-store-import>` — import/export UI for a store or collection. 13 + * 14 + * Pass a `StoreImportMethods` object as the `.methods` property. Set 15 + * `allow-replace` to show a mode selector that lets the user choose between 16 + * extending (merge, default) and replacing (destructive overwrite) on import. 17 + * 18 + * Dispatches `on-exported`, `on-imported`, and `on-deleted` custom events on 19 + * successful operations. 20 + * 21 + * @example 22 + * ```html 23 + * <ui-store-import allow-replace></ui-store-import> 24 + * ``` 25 + * ```ts 26 + * document.querySelector('ui-store-import').methods = { 27 + * export: () => store.exportToFile(), 28 + * import: (opts) => store.importFromFile(opts), 29 + * deleteAll: () => store.clearAllData(), 30 + * } 31 + * ``` 32 + */ 33 + export class UiStoreImport extends LitElement { 34 + static override properties = { 35 + methods: { type: Object }, 36 + allowReplace: { type: Boolean, attribute: 'allow-replace' }, 37 + _importMode: { state: true }, 38 + _isImporting: { state: true }, 39 + _showDeleteConfirm: { state: true }, 40 + _status: { state: true }, 41 + _deleteStatus: { state: true }, 42 + } 43 + 44 + declare methods: StoreImportMethods | null 45 + declare allowReplace: boolean 46 + declare _importMode: 'replace' | 'extend' 47 + declare _isImporting: boolean 48 + declare _showDeleteConfirm: boolean 49 + declare _status: string 50 + declare _deleteStatus: string 51 + 52 + constructor() { 53 + super() 54 + this.methods = null 55 + this.allowReplace = false 56 + this._importMode = 'extend' 57 + this._isImporting = false 58 + this._showDeleteConfirm = false 59 + this._status = '' 60 + this._deleteStatus = '' 61 + } 62 + 63 + override createRenderRoot() { 64 + return this 65 + } 66 + 67 + #setStatus(msg: string, isError = false): void { 68 + this._status = isError ? `Error: ${msg}` : msg 69 + } 70 + 71 + async #handleExport(): Promise<void> { 72 + if (!this.methods) return 73 + const btn = this.querySelector<HTMLButtonElement>('[data-export-btn]') 74 + if (btn) btn.disabled = true 75 + const result = await this.methods.export() 76 + if (btn) btn.disabled = false 77 + if (result.success) { 78 + this.#setStatus('Data exported successfully.') 79 + this.dispatchEvent( 80 + new CustomEvent('on-exported', { bubbles: true, composed: true }), 81 + ) 82 + } else { 83 + this.#setStatus(result.error ?? 'Export failed.', true) 84 + } 85 + } 86 + 87 + async #handleImport(): Promise<void> { 88 + if (!this.methods) return 89 + this._isImporting = true 90 + this.requestUpdate() 91 + const result = await this.methods.import({ mode: this._importMode }) 92 + this._isImporting = false 93 + this.requestUpdate() 94 + if (result.success) { 95 + this.#setStatus('Data imported successfully.') 96 + this.dispatchEvent( 97 + new CustomEvent('on-imported', { bubbles: true, composed: true }), 98 + ) 99 + } else { 100 + this.#setStatus(result.error ?? 'Import failed.', true) 101 + } 102 + } 103 + 104 + #handleDeleteAll(): void { 105 + this._showDeleteConfirm = true 106 + this._deleteStatus = '' 107 + this.requestUpdate() 108 + } 109 + 110 + #handleDeleteCancel = (): void => { 111 + this._showDeleteConfirm = false 112 + this._deleteStatus = '' 113 + this.requestUpdate() 114 + } 115 + 116 + async #handleDeleteConfirm(): Promise<void> { 117 + if (!this.methods) return 118 + const input = this.querySelector<HTMLInputElement>( 119 + '[data-delete-confirm-input]', 120 + ) 121 + if (input?.value !== 'Delete my data') { 122 + this._deleteStatus = 'Please type "Delete my data" exactly to confirm.' 123 + this.requestUpdate() 124 + return 125 + } 126 + const btn = this.querySelector<HTMLButtonElement>( 127 + '[data-delete-confirm-btn]', 128 + ) 129 + if (btn) btn.disabled = true 130 + const result = await this.methods.deleteAll() 131 + if (result.success) { 132 + this._showDeleteConfirm = false 133 + this.#setStatus('All data deleted.') 134 + this.dispatchEvent( 135 + new CustomEvent('on-deleted', { bubbles: true, composed: true }), 136 + ) 137 + globalThis.location.reload() 138 + } else { 139 + if (btn) btn.disabled = false 140 + this._deleteStatus = result.error ?? 'Delete failed.' 141 + this.requestUpdate() 142 + } 143 + } 144 + 145 + override render(): TemplateResult { 146 + return html` 147 + <p>Export your data to a file, or import a previously exported file.</p> 148 + 149 + ${this.allowReplace 150 + ? html` 151 + <fieldset> 152 + <legend>Import mode</legend> 153 + <label> 154 + <input 155 + type="radio" 156 + name="import-mode" 157 + value="extend" 158 + ?checked="${this._importMode === 'extend'}" 159 + @change="${() => { 160 + this._importMode = 'extend' 161 + }}" 162 + > 163 + <div> 164 + <span>Extend</span> 165 + <small>Merge — keep local data, skip conflicts</small> 166 + </div> 167 + </label> 168 + <label> 169 + <input 170 + type="radio" 171 + name="import-mode" 172 + value="replace" 173 + ?checked="${this._importMode === 'replace'}" 174 + @change="${() => { 175 + this._importMode = 'replace' 176 + }}" 177 + > 178 + <div> 179 + <span>Replace</span> 180 + <small>Overwrite — clear all local data first</small> 181 + </div> 182 + </label> 183 + </fieldset> 184 + ` 185 + : ''} 186 + 187 + <div> 188 + <button 189 + class="action" 190 + data-export-btn 191 + @click="${() => void this.#handleExport()}" 192 + > 193 + Export 194 + </button> 195 + <button 196 + class="action" 197 + ?disabled="${this._isImporting}" 198 + @click="${() => void this.#handleImport()}" 199 + > 200 + ${this._isImporting ? 'Importing…' : 'Import'} 201 + </button> 202 + </div> 203 + 204 + <button 205 + class="action action--danger" 206 + @click="${this.#handleDeleteAll}" 207 + > 208 + Delete All Data 209 + </button> 210 + 211 + ${this._status 212 + ? html` 213 + <p data-status ${this._status.startsWith('Error') 214 + ? 'data-error' 215 + : ''}> 216 + ${this._status} 217 + </p> 218 + ` 219 + : ''} 220 + 221 + <ui-dialog 222 + ?open="${this._showDeleteConfirm}" 223 + @dismiss="${this.#handleDeleteCancel}" 224 + > 225 + <dialog> 226 + <article class="bm-dialog"> 227 + <h2 class="bm-dialog-title">Delete All Data</h2> 228 + <p> 229 + This will permanently delete all your data. This cannot be undone. 230 + </p> 231 + <p>Type <strong>Delete my data</strong> to confirm:</p> 232 + <input 233 + data-delete-confirm-input 234 + type="text" 235 + placeholder="Delete my data" 236 + autocomplete="off" 237 + > 238 + ${this._deleteStatus 239 + ? html` 240 + <p data-status data-error>${this._deleteStatus}</p> 241 + ` 242 + : ''} 243 + <div class="bm-form-actions"> 244 + <button 245 + class="bm-btn-danger" 246 + data-delete-confirm-btn 247 + @click="${() => void this.#handleDeleteConfirm()}" 248 + > 249 + Delete 250 + </button> 251 + <button type="button" @click="${this.#handleDeleteCancel}"> 252 + Cancel 253 + </button> 254 + </div> 255 + </article> 256 + </dialog> 257 + </ui-dialog> 258 + 259 + <ui-dialog ?open="${this._isImporting}"> 260 + <dialog> 261 + <article class="bm-dialog"> 262 + <h2 class="bm-dialog-title">Importing Data</h2> 263 + <p>Please wait while your data is being imported…</p> 264 + <div style="display:flex;justify-content:center;padding:16px 0"> 265 + <ui-spinner size="lg"></ui-spinner> 266 + </div> 267 + </article> 268 + </dialog> 269 + </ui-dialog> 270 + ` 271 + } 272 + } 273 + 274 + if (!customElements.get('ui-store-import')) { 275 + customElements.define('ui-store-import', UiStoreImport) 276 + }
+231
packages/ui/components/ui-sync-state.ts
··· 1 + import { html, LitElement, type TemplateResult } from 'lit' 2 + import type { SyncTarget } from './ui-sync-input.ts' 3 + 4 + type SyncStatus = 'disconnected' | 'syncing' | 'ok' | 'error' 5 + 6 + interface SyncError { 7 + code: string 8 + message: string 9 + httpStatus: number 10 + } 11 + 12 + /** 13 + * `<ui-sync-state>` — displays current sync status for a `Synced` instance. 14 + * 15 + * Pure display component — no buttons or actions. Use alongside `ui-sync-input` 16 + * when you want to show status in a different part of the UI. 17 + * 18 + * **Variants:** 19 + * - `badge` — colored dot only, for navbars / status bars 20 + * - `inline` — dot + short text label, for settings rows (default) 21 + * - `detailed` — full status block with last-sync time and error details 22 + * 23 + * **CSS classes for styling:** 24 + * - `.ui-sync-state--badge | --inline | --detailed` — variant 25 + * - `.ui-sync-state--ok | --syncing | --error | --disconnected` — status 26 + * - `.ui-sync-state__dot` — the indicator dot 27 + * - `.ui-sync-state__label` — text label (inline/detailed only) 28 + * - `.ui-sync-state__meta` — secondary text (detailed only) 29 + * - `.ui-sync-state__error` — error line (detailed only) 30 + * 31 + * @example 32 + * ```html 33 + * <ui-sync-state variant="badge"></ui-sync-state> 34 + * <ui-sync-state variant="inline"></ui-sync-state> 35 + * <ui-sync-state variant="detailed"></ui-sync-state> 36 + * ``` 37 + * ```ts 38 + * document.querySelectorAll('ui-sync-state').forEach(el => el.synced = mySynced) 39 + * ``` 40 + */ 41 + export class UiSyncState extends LitElement { 42 + static override properties = { 43 + synced: { type: Object }, 44 + variant: { type: String }, 45 + _lastError: { state: true }, 46 + } 47 + 48 + declare synced: SyncTarget | null 49 + declare variant: 'badge' | 'inline' | 'detailed' 50 + declare _lastError: SyncError | null 51 + 52 + constructor() { 53 + super() 54 + this.synced = null 55 + this.variant = 'inline' 56 + this._lastError = null 57 + } 58 + 59 + override createRenderRoot() { 60 + return this 61 + } 62 + 63 + // ── Lifecycle ────────────────────────────────── 64 + 65 + #prevSynced: SyncTarget | null = null 66 + 67 + #onStateChange = () => this.requestUpdate() 68 + 69 + #onSyncDone = () => { 70 + this._lastError = null 71 + this.requestUpdate() 72 + } 73 + 74 + #onSyncError = (e: Event) => { 75 + const err = (e as Event & { error?: unknown }).error 76 + if (err && typeof err === 'object' && 'code' in err && 'message' in err) { 77 + this._lastError = err as SyncError 78 + } else { 79 + this._lastError = { 80 + code: 'sync.network_error', 81 + message: err instanceof Error ? err.message : 'Sync failed', 82 + httpStatus: 0, 83 + } 84 + } 85 + this.requestUpdate() 86 + } 87 + 88 + override updated(changed: Map<string, unknown>) { 89 + if (!changed.has('synced')) return 90 + this.#detachListeners(this.#prevSynced) 91 + this.#prevSynced = this.synced 92 + if (!this.synced) return 93 + this.synced.addEventListener('connected', this.#onStateChange) 94 + this.synced.addEventListener('disconnected', this.#onStateChange) 95 + this.synced.addEventListener('auth', this.#onStateChange) 96 + this.synced.addEventListener('sync', this.#onSyncDone) 97 + this.synced.addEventListener('error', this.#onSyncError) 98 + } 99 + 100 + override disconnectedCallback() { 101 + super.disconnectedCallback() 102 + this.#detachListeners(this.synced) 103 + this.#prevSynced = null 104 + } 105 + 106 + #detachListeners(target: SyncTarget | null) { 107 + if (!target) return 108 + target.removeEventListener('connected', this.#onStateChange) 109 + target.removeEventListener('disconnected', this.#onStateChange) 110 + target.removeEventListener('auth', this.#onStateChange) 111 + target.removeEventListener('sync', this.#onSyncDone) 112 + target.removeEventListener('error', this.#onSyncError) 113 + } 114 + 115 + // ── Helpers ──────────────────────────────────── 116 + 117 + #getStatus(): SyncStatus { 118 + if (!this.synced) return 'disconnected' 119 + const { connected, authenticated, syncing } = this.synced 120 + if (!connected || !authenticated) return 'disconnected' 121 + if (syncing) return 'syncing' 122 + if (this._lastError) return 'error' 123 + return 'ok' 124 + } 125 + 126 + #statusLabel(status: SyncStatus): string { 127 + switch (status) { 128 + case 'disconnected': 129 + return 'Not connected' 130 + case 'syncing': 131 + return 'Syncing…' 132 + case 'error': 133 + return 'Sync error' 134 + case 'ok': { 135 + const lastSync = this.synced?.lastSync 136 + return lastSync ? `Synced · ${this.#timeAgo(lastSync)}` : 'Synced' 137 + } 138 + } 139 + } 140 + 141 + #timeAgo(iso: string): string { 142 + const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000) 143 + if (sec < 60) return 'just now' 144 + const min = Math.floor(sec / 60) 145 + if (min < 60) return `${min}m ago` 146 + const hr = Math.floor(min / 60) 147 + if (hr < 24) return `${hr}h ago` 148 + return `${Math.floor(hr / 24)}d ago` 149 + } 150 + 151 + // ── Render ───────────────────────────────────── 152 + 153 + override render(): TemplateResult { 154 + const status = this.#getStatus() 155 + switch (this.variant) { 156 + case 'badge': 157 + return this.#renderBadge(status) 158 + case 'detailed': 159 + return this.#renderDetailed(status) 160 + default: 161 + return this.#renderInline(status) 162 + } 163 + } 164 + 165 + /** 166 + * badge — a single colored dot with no text. 167 + * Style `.ui-sync-state--badge` as `display:inline-block; width/height; border-radius:50%`. 168 + */ 169 + #renderBadge(status: SyncStatus): TemplateResult { 170 + const label = this.#statusLabel(status) 171 + return html` 172 + <span 173 + class="ui-sync-state ui-sync-state--badge ui-sync-state--${status}" 174 + role="status" 175 + aria-label="${label}" 176 + title="${label}" 177 + ></span> 178 + ` 179 + } 180 + 181 + /** 182 + * inline — dot + short text label on one line. 183 + */ 184 + #renderInline(status: SyncStatus): TemplateResult { 185 + return html` 186 + <span 187 + class="ui-sync-state ui-sync-state--inline ui-sync-state--${status}" 188 + role="status" 189 + > 190 + <span class="ui-sync-state__dot" aria-hidden="true"></span> 191 + <span class="ui-sync-state__label">${this.#statusLabel(status)}</span> 192 + </span> 193 + ` 194 + } 195 + 196 + /** 197 + * detailed — full block: prominent label, last-sync meta, and structured 198 + * error display when in the error state. 199 + */ 200 + #renderDetailed(status: SyncStatus): TemplateResult { 201 + const lastSync = this.synced?.lastSync 202 + return html` 203 + <div 204 + class="ui-sync-state ui-sync-state--detailed ui-sync-state--${status}" 205 + role="status" 206 + > 207 + <div class="ui-sync-state__row"> 208 + <span class="ui-sync-state__dot" aria-hidden="true"></span> 209 + <span class="ui-sync-state__label">${this.#statusLabel(status)}</span> 210 + </div> 211 + ${lastSync && status !== 'disconnected' 212 + ? html` 213 + <small class="ui-sync-state__meta"> 214 + Last sync: ${this.#timeAgo(lastSync)} 215 + </small> 216 + ` 217 + : ''} ${this._lastError 218 + ? html` 219 + <p class="ui-sync-state__error"> 220 + <code>${this._lastError.code}</code>: ${this._lastError.message} 221 + </p> 222 + ` 223 + : ''} 224 + </div> 225 + ` 226 + } 227 + } 228 + 229 + if (!customElements.get('ui-sync-state')) { 230 + customElements.define('ui-sync-state', UiSyncState) 231 + }
+82 -49
packages/ui/components/ui-sync.ts packages/ui/components/ui-sync-input.ts
··· 1 1 import { html, LitElement, type TemplateResult } from 'lit' 2 2 3 + // Minimal shape of CivilityApiError — avoids importing @civility/errors 4 + interface SyncError { 5 + code: string 6 + message: string 7 + httpStatus: number 8 + } 9 + 3 10 /** 4 11 * Minimal interface for the sync orchestrator. Satisfied by `Synced` from 5 12 * `@civility/sync` without importing that package as a dependency. ··· 18 25 } 19 26 20 27 /** 21 - * `<ui-sync>` — a reusable sync management component. 28 + * `<ui-sync-input>` — reusable sync management component. 22 29 * 23 30 * Pass a `Synced` instance (from `@civility/sync`) as the `.synced` property. 24 31 * Set `app-id` to the server-registered app ID and (optionally) `scope` to ··· 29 36 * The server URL and token are persisted to `localStorage` under `storageKey` 30 37 * for automatic reconnect on reload. 31 38 * 39 + * Errors are stored as structured `SyncError` objects (matching the shape of 40 + * `CivilityApiError` from `@civility/errors`) and rendered with their code. 41 + * 32 42 * @example 33 43 * ```html 34 - * <ui-sync app-id="my-app" scope="read:*,write:todos"></ui-sync> 44 + * <ui-sync-input app-id="my-app" scope="read:*,write:todos"></ui-sync-input> 35 45 * ``` 36 46 * ```ts 37 - * document.querySelector('ui-sync').synced = mySyncedInstance 47 + * document.querySelector('ui-sync-input').synced = mySyncedInstance 38 48 * ``` 39 49 */ 40 - export class UiSync extends LitElement { 50 + export class UiSyncInput extends LitElement { 41 51 static override properties = { 42 52 synced: { type: Object }, 43 53 storageKey: { type: String, attribute: 'storage-key' }, ··· 55 65 56 66 declare _url: string 57 67 declare _authorizing: boolean 58 - declare _error: string 68 + declare _error: SyncError | null 59 69 60 70 constructor() { 61 71 super() ··· 65 75 this.scope = '*' 66 76 this._url = '' 67 77 this._authorizing = false 68 - this._error = '' 78 + this._error = null 69 79 } 70 80 71 81 override createRenderRoot() { ··· 86 96 this.requestUpdate() 87 97 } 88 98 #onSyncError = (e: Event) => { 89 - const err = (e as Event & { error?: Error }).error 90 - this._error = err?.message ?? 'Sync failed' 91 - console.error('Sync error:', err ?? e) 92 - // Log full cause chain and stack traces 93 - if (err) { 94 - let current: unknown = err 95 - let depth = 0 96 - while (current && depth < 5) { 97 - console.error(` Cause level ${depth}:`, current) 98 - if (current instanceof Error && current.stack) { 99 - console.error(` Stack:`, current.stack) 100 - } 101 - current = (current as Error).cause 102 - depth++ 99 + const err = (e as Event & { error?: unknown }).error 100 + if (err && typeof err === 'object' && 'code' in err && 'message' in err) { 101 + this._error = err as SyncError 102 + } else { 103 + this._error = { 104 + code: 'sync.network_error', 105 + message: err instanceof Error ? err.message : 'Sync failed', 106 + httpStatus: 0, 103 107 } 104 108 } 105 109 this.requestUpdate() ··· 172 176 #handleAuthorize(e: Event) { 173 177 e.preventDefault() 174 178 if (!this.synced || !this._url || this._authorizing) return 175 - this._error = '' 179 + this._error = null 176 180 177 181 let serverOrigin: string 178 182 try { 179 183 serverOrigin = new URL(this._url).origin 180 184 } catch { 181 - this._error = 'Invalid server URL' 185 + this._error = { 186 + code: 'sync.network_error', 187 + message: 'Invalid server URL', 188 + httpStatus: 0, 189 + } 182 190 return 183 191 } 184 192 ··· 193 201 ) 194 202 195 203 if (!popup) { 196 - this._error = 197 - 'Popup was blocked. Allow popups for this site and try again.' 204 + this._error = { 205 + code: 'sync.network_error', 206 + message: 'Popup was blocked. Allow popups for this site and try again.', 207 + httpStatus: 0, 208 + } 198 209 return 199 210 } 200 211 ··· 209 220 this._authorizing = false 210 221 211 222 if (event.data.error) { 212 - this._error = event.data.error === 'access_denied' 213 - ? 'Authorization denied' 214 - : String(event.data.error) 223 + this._error = { 224 + code: event.data.error === 'access_denied' 225 + ? 'auth.access_denied' 226 + : 'auth.unknown', 227 + message: event.data.error === 'access_denied' 228 + ? 'Authorization denied' 229 + : String(event.data.error), 230 + httpStatus: 403, 231 + } 215 232 this.requestUpdate() 216 233 return 217 234 } ··· 230 247 231 248 globalThis.addEventListener('message', this.#messageHandler) 232 249 233 - // Detect popup closed without completing 234 250 this.#popupCheckInterval = setInterval(() => { 235 251 if (popup.closed) { 236 252 this.#cleanupPopup() 237 253 if (this._authorizing) { 238 254 this._authorizing = false 239 - this._error = 'Authorization cancelled' 255 + this._error = { 256 + code: 'auth.cancelled', 257 + message: 'Authorization cancelled', 258 + httpStatus: 0, 259 + } 240 260 this.requestUpdate() 241 261 } 242 262 } ··· 249 269 localStorage.removeItem(`${this.storageKey}:url`) 250 270 localStorage.removeItem(`${this.storageKey}:token`) 251 271 localStorage.removeItem(`${this.storageKey}:hlc`) 252 - this._error = '' 272 + this._error = null 253 273 this.requestUpdate() 254 274 } 255 275 256 276 async #handleSync() { 257 277 if (!this.synced || this.synced.syncing) return 258 - this._error = '' 278 + this._error = null 259 279 try { 260 280 await this.synced.sync() 261 281 } catch (err) { 262 - this._error = err instanceof Error ? err.message : 'Sync failed' 282 + if (err && typeof err === 'object' && 'code' in err && 'message' in err) { 283 + this._error = err as SyncError 284 + } else { 285 + this._error = { 286 + code: 'sync.network_error', 287 + message: err instanceof Error ? err.message : 'Sync failed', 288 + httpStatus: 0, 289 + } 290 + } 263 291 } 264 292 } 265 293 ··· 290 318 return this.#renderSynced(syncing, lastSync) 291 319 } 292 320 321 + #renderError(): TemplateResult { 322 + if (!this._error) { 323 + return html` 324 + 325 + ` 326 + } 327 + return html` 328 + <p class="ui-sync-input__error"> 329 + <code>${this._error.code}</code>: ${this._error.message} 330 + </p> 331 + ` 332 + } 333 + 293 334 #renderConnect(): TemplateResult { 294 335 return html` 295 336 <form @submit="${(e: Event) => this.#handleAuthorize(e)}"> ··· 306 347 required 307 348 > 308 349 </label> 309 - ${this._error 310 - ? html` 311 - <p class="ui-sync__error">${this._error}</p> 312 - ` 313 - : ''} 350 + ${this.#renderError()} 314 351 <button type="submit" class="action" ?disabled="${this._authorizing}"> 315 352 ${this._authorizing ? 'Authorizing…' : 'Authorize'} 316 353 </button> ··· 320 357 321 358 #renderSynced(syncing: boolean, lastSync: string | null): TemplateResult { 322 359 return html` 323 - <div class="ui-sync__status"> 324 - <span class="ui-sync__indicator ${syncing 325 - ? 'ui-sync__indicator--syncing' 326 - : 'ui-sync__indicator--ok'}"> 360 + <div class="ui-sync-input__status"> 361 + <span class="ui-sync-input__indicator ${syncing 362 + ? 'ui-sync-input__indicator--syncing' 363 + : 'ui-sync-input__indicator--ok'}"> 327 364 ${syncing ? 'Syncing…' : 'Synced'} 328 365 </span> 329 366 ${lastSync ··· 334 371 <small>Not yet synced</small> 335 372 `} 336 373 </div> 337 - ${this._error 338 - ? html` 339 - <p class="ui-sync__error">${this._error}</p> 340 - ` 341 - : ''} 342 - <div class="ui-sync__actions"> 374 + ${this.#renderError()} 375 + <div class="ui-sync-input__actions"> 343 376 <button 344 377 class="action" 345 378 ?disabled="${syncing}" ··· 355 388 } 356 389 } 357 390 358 - if (!customElements.get('ui-sync')) { 359 - customElements.define('ui-sync', UiSync) 391 + if (!customElements.get('ui-sync-input')) { 392 + customElements.define('ui-sync-input', UiSyncInput) 360 393 }
+1 -1
packages/ui/deno.json
··· 1 1 { 2 2 "name": "@civility/ui", 3 - "version": "1.0.0-beta.7", 3 + "version": "1.0.0-beta.8", 4 4 "exports": { 5 5 ".": "./mod.ts" 6 6 },
+8 -4
packages/ui/mod.d.ts
··· 1 1 import type { UiBackHeader } from './components/ui-back-header.ts' 2 2 import type { UiCounter } from './components/ui-counter.ts' 3 - import type { UiDataActions } from './components/ui-data-actions.ts' 4 3 import type { UiDialog } from './components/ui-dialog.ts' 4 + import type { UiErrorDisplay } from './components/ui-error-display.ts' 5 5 import type { UiIcon } from './components/ui-icon.ts' 6 6 import type { UiHeaderContent } from './components/ui-header-content.ts' 7 7 import type { UiPwaInstall } from './components/ui-pwa-install.ts' 8 8 import type { UiPwaVersion } from './components/ui-pwa-version.ts' 9 - import type { UiSync } from './components/ui-sync.ts' 9 + import type { UiStoreImport } from './components/ui-store-import.ts' 10 + import type { UiSyncInput } from './components/ui-sync-input.ts' 11 + import type { UiSyncState } from './components/ui-sync-state.ts' 10 12 11 13 declare global { 12 14 interface HTMLElementTagNameMap { 13 15 'ui-back-header': UiBackHeader 14 16 'ui-counter': UiCounter 15 - 'ui-data-actions': UiDataActions 16 17 'ui-dialog': UiDialog 18 + 'ui-error-display': UiErrorDisplay 17 19 'ui-header-content': UiHeaderContent 18 20 'ui-icon': UiIcon 19 21 'ui-pwa-install': UiPwaInstall 20 22 'ui-pwa-version': UiPwaVersion 21 - 'ui-sync': UiSync 23 + 'ui-store-import': UiStoreImport 24 + 'ui-sync-input': UiSyncInput 25 + 'ui-sync-state': UiSyncState 22 26 } 23 27 }
+6 -4
packages/ui/mod.ts
··· 1 1 import './components/ui-back-header.ts' 2 2 import './components/ui-counter.ts' 3 - import './components/ui-data-actions.ts' 4 3 import './components/ui-dialog.ts' 4 + import './components/ui-error-display.ts' 5 5 import './components/ui-header-content.ts' 6 6 import './components/ui-icon.ts' 7 7 import './components/ui-pwa-install.ts' 8 8 import './components/ui-pwa-version.ts' 9 - import './components/ui-sync.ts' 9 + import './components/ui-store-import.ts' 10 + import './components/ui-sync-input.ts' 11 + import './components/ui-sync-state.ts' 10 12 11 - export type { UiDataActionMethods } from './components/ui-data-actions.ts' 12 - export type { SyncTarget } from './components/ui-sync.ts' 13 + export type { SyncTarget } from './components/ui-sync-input.ts' 14 + export type { StoreImportMethods } from './components/ui-store-import.ts' 13 15 14 16 export * from './modules/html.ts' 15 17 export * from './modules/layout_router.ts'