An app for logging board climbs
0
fork

Configure Feed

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

chore: continue refactoring

+295 -235
+2
www/index.html
··· 26 26 <strong id="page-title">Moonboard</strong> 27 27 </header> 28 28 29 + <ui-sub-header id="sub-header"></ui-sub-header> 30 + 29 31 <main id="main"> 30 32 <ui-spinner></ui-spinner> 31 33 </main>
+10 -6
www/index.ts
··· 26 26 private setupRoutes(): void { 27 27 this.router.on('/', { 28 28 on: () => { 29 - this.renderPage('home-page') 29 + this.renderPage('home-page', 'home-filters') 30 30 this.updateActiveNavLink('/') 31 31 // home-page sets its own title via connectedCallback 32 32 }, ··· 34 34 35 35 this.router.on('/climb', { 36 36 on: () => { 37 - this.renderPage('climb-page') 37 + this.renderPage('climb-page', 'climb-header') 38 38 this.updateActiveNavLink('/climb') 39 39 }, 40 40 }) 41 41 42 42 this.router.on('/library', { 43 43 on: () => { 44 - this.renderPage('library-page') 44 + this.renderPage('library-page', 'library-filters') 45 45 this.updateActiveNavLink('/library') 46 46 this.updatePageTitle('Library') 47 47 }, ··· 92 92 } 93 93 } 94 94 95 - private renderPage(componentTag: string): void { 95 + private renderPage(componentTag: string, subHeaderTag?: string): void { 96 + const subHeader = document.querySelector<HTMLElement>('#sub-header') 97 + if (subHeader) { 98 + subHeader.innerHTML = '' 99 + if (subHeaderTag) subHeader.appendChild(document.createElement(subHeaderTag)) 100 + } 96 101 this.mainElement = document.querySelector('main') 97 102 if (!this.mainElement) return 98 103 this.mainElement.innerHTML = '' 99 - const component = document.createElement(componentTag) 100 - this.mainElement.appendChild(component) 104 + this.mainElement.appendChild(document.createElement(componentTag)) 101 105 } 102 106 103 107 private updateActiveNavLink(hash: string): void {
+56 -42
www/routes/climb.ts
··· 14 14 import { getClimbNav, setClimbNav } from '../utils/climb-nav.ts' 15 15 import { getClimbLog, logSession } from '../utils/logbook.ts' 16 16 17 + let activeClimbHeader: ClimbHeader | null = null 18 + 19 + export class ClimbHeader extends HTMLElement { 20 + connectedCallback() { 21 + activeClimbHeader = this 22 + } 23 + 24 + disconnectedCallback() { 25 + if (activeClimbHeader === this) activeClimbHeader = null 26 + } 27 + 28 + update(climb: Benchmark): void { 29 + const nav = getClimbNav()! 30 + this.innerHTML = ` 31 + <button class="back" aria-label="Back"> 32 + <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 33 + </button> 34 + <div class="title-info"> 35 + <strong class="name">${escapeHtml(climb.name)}</strong> 36 + <small class="subtitle">${GRADE_FULL[climb.grade] ?? ''} ${ 37 + sandbagLabel(climb.sandbag_score) 38 + } · ${escapeHtml(climb.setter)}</small> 39 + </div> 40 + <div class="meta"> 41 + ${this.metaHtml(climb)} 42 + </div> 43 + ` 44 + this.querySelector('.back')?.addEventListener('click', () => { 45 + globalThis.location.hash = nav.backRoute 46 + }) 47 + } 48 + 49 + metaHtml(climb: Benchmark): string { 50 + const entry = getClimbLog(climb.id) 51 + if (entry) { 52 + const badge = entry.sent 53 + ? '<ui-badge variant="success">Sent</ui-badge>' 54 + : '<ui-badge>Project</ui-badge>' 55 + const stars = entry.rating ? '★'.repeat(entry.rating) : '' 56 + return ` 57 + <span> ${badge} ${stars ? `${stars}` : ''}</span> 58 + <span> ${entry.totalAttempts} attempts</span> 59 + ` 60 + } 61 + return ` 62 + <span>★ ${climb.avg_user_stars.toFixed(1)}</span> 63 + <span>${climb.repeats.toLocaleString()} sends</span> 64 + ` 65 + } 66 + } 67 + 17 68 export class ClimbPage extends HTMLElement { 18 69 private hammer: HammerManager | null = null 19 70 private currentDialogClimb: Benchmark | null = null ··· 43 94 } 44 95 45 96 this.innerHTML = ` 46 - <ui-main-header id="cp-header"></ui-main-header> 47 97 <div id="cp-body"></div> 48 98 <div id="cp-dialog" class="lb-overlay" hidden> 49 99 <div class="lb-dialog" id="lb-dialog-box"></div> ··· 99 149 const config = BOARD_CONFIGS[nav.mbType] ?? BOARD_CONFIGS[0] 100 150 const height = canvasHeight(config.rows) 101 151 102 - const headerEl = this.querySelector<HTMLElement>('#cp-header') 103 - if (headerEl) { 104 - headerEl.innerHTML = ` 105 - <button class="back" aria-label="Back"> 106 - <img src="/static/icons/chevron-right.svg" alt="" aria-hidden="true"> 107 - </button> 108 - <div class="title-info"> 109 - <strong class="name">${escapeHtml(climb.name)}</strong> 110 - <small class="subtitle">${GRADE_FULL[climb.grade] ?? ''} ${ 111 - sandbagLabel(climb.sandbag_score) 112 - } · ${escapeHtml(climb.setter)}</small> 113 - </div> 114 - <div class="meta"> 115 - ${this.headerMetaHtml(climb)} 116 - </div> 117 - ` 118 - headerEl.querySelector('.back')?.addEventListener('click', () => { 119 - globalThis.location.hash = nav.backRoute 120 - }) 121 - } 152 + activeClimbHeader?.update(climb) 122 153 123 154 const bodyEl = this.querySelector<HTMLElement>('#cp-body') 124 155 if (!bodyEl) return ··· 158 189 () => this.showLogDialog(climb), 159 190 ) 160 191 161 - bodyEl.scrollTop = 0 192 + const mainEl = document.querySelector<HTMLElement>('main') 193 + if (mainEl) mainEl.scrollTop = 0 162 194 163 195 requestAnimationFrame(() => { 164 196 const canvas = this.querySelector<HTMLCanvasElement>('#bm-canvas') ··· 166 198 }) 167 199 } 168 200 169 - private headerMetaHtml(climb: Benchmark): string { 170 - const entry = getClimbLog(climb.id) 171 - if (entry) { 172 - const badge = entry.sent 173 - ? '<ui-badge variant="success">Sent</ui-badge>' 174 - : '<ui-badge>Project</ui-badge>' 175 - const stars = entry.rating ? '★'.repeat(entry.rating) : '' 176 - return ` 177 - <span> ${badge} ${stars ? `${stars}` : ''}</span> 178 - <span> ${entry.totalAttempts} attempts</span> 179 - ` 180 - } 181 - return ` 182 - <span>★ ${climb.avg_user_stars.toFixed(1)}</span> 183 - <span>${climb.repeats.toLocaleString()} sends</span> 184 - ` 185 - } 186 - 187 201 private logSectionHtml(climb: Benchmark): string { 188 202 const entry = getClimbLog(climb.id) 189 203 if (!entry) { ··· 201 215 () => this.showLogDialog(climb), 202 216 ) 203 217 } 204 - const metaEl = this.querySelector<HTMLElement>('.meta') 205 - if (metaEl) metaEl.innerHTML = this.headerMetaHtml(climb) 218 + activeClimbHeader?.update(climb) 206 219 } 207 220 208 221 private showLogDialog(climb: Benchmark): void { ··· 312 325 } 313 326 } 314 327 328 + customElements.define('climb-header', ClimbHeader) 315 329 customElements.define('climb-page', ClimbPage)
+143 -122
www/routes/home.ts
··· 13 13 14 14 type LogFilter = 'all' | 'sent' | 'unsent' 15 15 16 - export class HomePage extends HTMLElement { 17 - private benchmarks: Benchmark[] = [] 18 - private filtered: Benchmark[] = [] 19 - private search = '' 20 - private gradeMin = 0 21 - private gradeMax = 16 22 - private shown = PAGE_SIZE 23 - private mbType = 0 24 - private gradeScale: 'french' | 'v' = 'french' 25 - private logFilter: LogFilter = 'all' 16 + const state = { 17 + search: '', 18 + gradeMin: 0, 19 + gradeMax: 16, 20 + logFilter: 'all' as LogFilter, 21 + shown: PAGE_SIZE, 22 + } 26 23 27 - private gradeLabel(grade: number): string { 28 - const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 29 - return table[grade] ?? '?' 30 - } 24 + const emitter = new EventTarget() 31 25 32 - async connectedCallback() { 33 - this.mbType = parseInt(localStorage.getItem('mb_type') ?? '0', 10) 34 - this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 35 - 'french' 36 - this.logFilter = (localStorage.getItem('home_filter') as LogFilter) ?? 'all' 37 - this.gradeMin = parseInt(localStorage.getItem('home_grade_min') ?? '0', 10) 38 - this.gradeMax = parseInt(localStorage.getItem('home_grade_max') ?? '16', 10) 26 + export class HomeFilters extends HTMLElement { 27 + private gradeScale: 'french' | 'v' = 'french' 39 28 40 - const titleEl = document.querySelector('#page-title') 41 - if (titleEl) { 42 - titleEl.textContent = BOARD_CONFIGS[this.mbType]?.label ?? 'Moonboard' 43 - } 44 - 29 + connectedCallback() { 30 + state.search = '' 31 + state.gradeMin = parseInt(localStorage.getItem('home_grade_min') ?? '0', 10) 32 + state.gradeMax = parseInt(localStorage.getItem('home_grade_max') ?? '16', 10) 33 + state.logFilter = (localStorage.getItem('home_filter') as LogFilter) ?? 'all' 34 + state.shown = PAGE_SIZE 35 + this.gradeScale = 36 + (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 45 37 this.render() 46 - this.bindListEvents() 47 - 48 - try { 49 - const all = await loadBenchmarks() 50 - this.benchmarks = all 51 - .filter((b) => b.mb_type === this.mbType) 52 - .sort((a, b) => b.repeats - a.repeats) 53 - this.applyFilters() 54 - this.renderList() 55 - } catch { 56 - const listEl = this.querySelector('#bm-list') 57 - if (listEl) { 58 - listEl.innerHTML = 59 - `<p class="empty-message">Failed to load benchmarks.</p>` 60 - } 61 - } 38 + this.bindEvents() 62 39 } 63 40 64 - private render(): void { 41 + private render() { 65 42 this.innerHTML = ` 66 - <ui-sub-header> 67 - <input 68 - type="search" 69 - id="bm-search" 70 - placeholder="Search by name or setter..." 71 - autocomplete="off" 72 - > 73 - <ui-button-group id="bm-log-filter"> 74 - ${this.logFilterHtml()} 75 - </ui-button-group> 76 - <div class="grade-filter"> 77 - <label for="bm-grade-min">Grade</label> 78 - <select id="bm-grade-min"> 79 - ${this.gradeOptions('min')} 80 - </select> 81 - <span aria-hidden="true">–</span> 82 - <select id="bm-grade-max"> 83 - ${this.gradeOptions('max')} 84 - </select> 85 - </div> 86 - </ui-sub-header> 87 - <div id="bm-list" role="list"> 88 - <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 43 + <input 44 + type="search" 45 + id="bm-search" 46 + placeholder="Search by name or setter..." 47 + autocomplete="off" 48 + > 49 + <ui-button-group id="bm-log-filter"> 50 + ${this.logFilterHtml()} 51 + </ui-button-group> 52 + <div class="grade-filter"> 53 + <label for="bm-grade-min">Grade</label> 54 + <select id="bm-grade-min"> 55 + ${this.gradeOptions('min')} 56 + </select> 57 + <span aria-hidden="true">–</span> 58 + <select id="bm-grade-max"> 59 + ${this.gradeOptions('max')} 60 + </select> 89 61 </div> 90 62 ` 91 63 } ··· 99 71 return opts 100 72 .map( 101 73 (o) => 102 - `<button aria-pressed="${ 103 - this.logFilter === o.value 104 - }" data-log-filter="${o.value}">${o.label}</button>`, 74 + `<button aria-pressed="${state.logFilter === o.value}" data-log-filter="${o.value}">${o.label}</button>`, 105 75 ) 106 76 .join('') 107 77 } ··· 122 92 } 123 93 124 94 private gradeOptions(role: 'min' | 'max'): string { 125 - const current = role === 'min' ? this.gradeMin : this.gradeMax 95 + const current = role === 'min' ? state.gradeMin : state.gradeMax 126 96 return this.gradeGroups() 127 97 .map((g) => { 128 98 const value = role === 'min' ? g.first : g.last 129 99 const selected = current >= g.first && current <= g.last 130 - return `<option value="${value}" ${ 131 - selected ? 'selected' : '' 132 - }>${g.label}</option>` 100 + return `<option value="${value}" ${selected ? 'selected' : ''}>${g.label}</option>` 133 101 }) 134 102 .join('') 135 103 } 136 104 137 - private bindListEvents(): void { 105 + private bindEvents() { 138 106 const searchEl = this.querySelector<HTMLInputElement>('#bm-search') 139 107 searchEl?.addEventListener('input', (e) => { 140 - this.search = (e.target as HTMLInputElement).value 141 - this.shown = PAGE_SIZE 142 - this.applyFilters() 143 - this.renderList() 108 + state.search = (e.target as HTMLInputElement).value 109 + state.shown = PAGE_SIZE 110 + emitter.dispatchEvent(new Event('filter')) 144 111 }) 145 112 146 113 const gradeMinEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 147 114 gradeMinEl?.addEventListener('change', (e) => { 148 115 const val = parseInt((e.target as HTMLSelectElement).value, 10) 149 - this.gradeMin = val 150 - if (this.gradeMax < val) { 116 + state.gradeMin = val 117 + if (state.gradeMax < val) { 151 118 const group = this.gradeGroups().find( 152 119 (g) => g.first <= val && val <= g.last, 153 120 ) 154 - this.gradeMax = group?.last ?? val 121 + state.gradeMax = group?.last ?? val 155 122 const maxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 156 - if (maxEl) maxEl.value = this.gradeMax.toString() 123 + if (maxEl) maxEl.value = state.gradeMax.toString() 157 124 } 158 - localStorage.setItem('home_grade_min', this.gradeMin.toString()) 159 - localStorage.setItem('home_grade_max', this.gradeMax.toString()) 160 - this.shown = PAGE_SIZE 161 - this.applyFilters() 162 - this.renderList() 125 + localStorage.setItem('home_grade_min', state.gradeMin.toString()) 126 + localStorage.setItem('home_grade_max', state.gradeMax.toString()) 127 + state.shown = PAGE_SIZE 128 + emitter.dispatchEvent(new Event('filter')) 163 129 }) 164 130 165 131 const gradeMaxEl = this.querySelector<HTMLSelectElement>('#bm-grade-max') 166 132 gradeMaxEl?.addEventListener('change', (e) => { 167 133 const val = parseInt((e.target as HTMLSelectElement).value, 10) 168 - this.gradeMax = val 169 - if (this.gradeMin > val) { 134 + state.gradeMax = val 135 + if (state.gradeMin > val) { 170 136 const group = this.gradeGroups().find( 171 137 (g) => g.first <= val && val <= g.last, 172 138 ) 173 - this.gradeMin = group?.first ?? val 139 + state.gradeMin = group?.first ?? val 174 140 const minEl = this.querySelector<HTMLSelectElement>('#bm-grade-min') 175 - if (minEl) minEl.value = this.gradeMin.toString() 141 + if (minEl) minEl.value = state.gradeMin.toString() 176 142 } 177 - localStorage.setItem('home_grade_min', this.gradeMin.toString()) 178 - localStorage.setItem('home_grade_max', this.gradeMax.toString()) 179 - this.shown = PAGE_SIZE 180 - this.applyFilters() 181 - this.renderList() 143 + localStorage.setItem('home_grade_min', state.gradeMin.toString()) 144 + localStorage.setItem('home_grade_max', state.gradeMax.toString()) 145 + state.shown = PAGE_SIZE 146 + emitter.dispatchEvent(new Event('filter')) 182 147 }) 183 148 184 149 this.querySelector('#bm-log-filter')?.addEventListener('click', (e) => { ··· 186 151 '[data-log-filter]', 187 152 ) 188 153 if (!btn) return 189 - this.logFilter = btn.dataset.logFilter as LogFilter 190 - localStorage.setItem('home_filter', this.logFilter) 154 + state.logFilter = btn.dataset.logFilter as LogFilter 155 + localStorage.setItem('home_filter', state.logFilter) 191 156 const bar = this.querySelector('#bm-log-filter') 192 157 if (bar) bar.innerHTML = this.logFilterHtml() 193 - this.shown = PAGE_SIZE 194 - this.applyFilters() 195 - this.renderList() 158 + state.shown = PAGE_SIZE 159 + emitter.dispatchEvent(new Event('filter')) 196 160 }) 161 + } 162 + } 197 163 198 - const listEl = this.querySelector('#bm-list') 199 - listEl?.addEventListener('click', (e) => { 200 - const target = e.target as HTMLElement 201 - const item = target.closest<HTMLElement>('[data-bm-id]') 202 - if (item) { 203 - const id = parseInt(item.dataset.bmId ?? '0', 10) 204 - const idx = this.filtered.findIndex((b) => b.id === id) 205 - if (idx !== -1) this.openClimb(idx) 206 - return 207 - } 208 - if (target.closest('#bm-load-more')) { 209 - this.shown += PAGE_SIZE 210 - this.renderList() 164 + export class HomePage extends HTMLElement { 165 + private benchmarks: Benchmark[] = [] 166 + private filtered: Benchmark[] = [] 167 + private mbType = 0 168 + private gradeScale: 'french' | 'v' = 'french' 169 + 170 + private gradeLabel(grade: number): string { 171 + const table = this.gradeScale === 'v' ? GRADE_V : GRADE_FRENCH 172 + return table[grade] ?? '?' 173 + } 174 + 175 + async connectedCallback() { 176 + this.mbType = parseInt(localStorage.getItem('mb_type') ?? '0', 10) 177 + this.gradeScale = 178 + (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 179 + 180 + const titleEl = document.querySelector('#page-title') 181 + if (titleEl) { 182 + titleEl.textContent = BOARD_CONFIGS[this.mbType]?.label ?? 'Moonboard' 183 + } 184 + 185 + this.innerHTML = ` 186 + <div id="bm-list" role="list"> 187 + <div class="empty-message"><ui-spinner size="lg"></ui-spinner></div> 188 + </div> 189 + ` 190 + 191 + this.addEventListener('click', this.#handleClick) 192 + emitter.addEventListener('filter', this.#onFilter) 193 + 194 + try { 195 + const all = await loadBenchmarks() 196 + this.benchmarks = all 197 + .filter((b) => b.mb_type === this.mbType) 198 + .sort((a, b) => b.repeats - a.repeats) 199 + this.applyFilters() 200 + this.renderList() 201 + } catch { 202 + const listEl = this.querySelector('#bm-list') 203 + if (listEl) { 204 + listEl.innerHTML = 205 + `<p class="empty-message">Failed to load benchmarks.</p>` 211 206 } 212 - }) 207 + } 208 + } 209 + 210 + disconnectedCallback() { 211 + this.removeEventListener('click', this.#handleClick) 212 + emitter.removeEventListener('filter', this.#onFilter) 213 + } 214 + 215 + #onFilter = () => { 216 + this.applyFilters() 217 + this.renderList() 218 + } 219 + 220 + #handleClick = (e: Event) => { 221 + const target = e.target as HTMLElement 222 + const item = target.closest<HTMLElement>('[data-bm-id]') 223 + if (item) { 224 + const id = parseInt(item.dataset.bmId ?? '0', 10) 225 + const idx = this.filtered.findIndex((b) => b.id === id) 226 + if (idx !== -1) this.openClimb(idx) 227 + return 228 + } 229 + if (target.closest('#bm-load-more')) { 230 + state.shown += PAGE_SIZE 231 + this.renderList() 232 + } 213 233 } 214 234 215 235 private applyFilters(): void { 216 - const q = this.search.toLowerCase() 217 - const logbook = this.logFilter !== 'all' ? loadLogbook() : null 236 + const q = state.search.toLowerCase() 237 + const logbook = state.logFilter !== 'all' ? loadLogbook() : null 218 238 this.filtered = this.benchmarks.filter((b) => { 219 - if (b.grade < this.gradeMin || b.grade > this.gradeMax) return false 239 + if (b.grade < state.gradeMin || b.grade > state.gradeMax) return false 220 240 if ( 221 241 q && 222 242 !b.name.toLowerCase().includes(q) && ··· 224 244 ) { 225 245 return false 226 246 } 227 - if (this.logFilter === 'sent') return !!logbook![b.id.toString()]?.sent 228 - if (this.logFilter === 'unsent') return !logbook![b.id.toString()]?.sent 247 + if (state.logFilter === 'sent') return !!logbook![b.id.toString()]?.sent 248 + if (state.logFilter === 'unsent') return !logbook![b.id.toString()]?.sent 229 249 return true 230 250 }) 231 251 } ··· 246 266 return 247 267 } 248 268 249 - const visible = this.filtered.slice(0, this.shown) 250 - const remaining = this.filtered.length - this.shown 269 + const visible = this.filtered.slice(0, state.shown) 270 + const remaining = this.filtered.length - state.shown 251 271 const logbook = loadLogbook() 252 272 253 273 listEl.innerHTML = ` ··· 301 321 } 302 322 } 303 323 324 + customElements.define('home-filters', HomeFilters) 304 325 customElements.define('home-page', HomePage)
+63 -44
www/routes/library.ts
··· 21 21 22 22 type Filter = 'all' | 'sent' | 'unsent' 23 23 24 + const libState = { filter: 'all' as Filter } 25 + const libEmitter = new EventTarget() 26 + 27 + export class LibraryFilters extends HTMLElement { 28 + connectedCallback() { 29 + libState.filter = 30 + (localStorage.getItem('library_filter') as Filter) ?? 'all' 31 + this.render() 32 + this.addEventListener('click', this.#handleClick) 33 + } 34 + 35 + disconnectedCallback() { 36 + this.removeEventListener('click', this.#handleClick) 37 + } 38 + 39 + private render() { 40 + this.innerHTML = ` 41 + <ui-button-group> 42 + ${this.filterBarHtml()} 43 + </ui-button-group> 44 + ` 45 + } 46 + 47 + private filterBarHtml(): string { 48 + const opts: { value: Filter; label: string }[] = [ 49 + { value: 'all', label: 'All' }, 50 + { value: 'sent', label: 'Completed' }, 51 + { value: 'unsent', label: 'Not Completed' }, 52 + ] 53 + return opts 54 + .map( 55 + (o) => 56 + `<button aria-pressed="${libState.filter === o.value}" data-filter="${o.value}">${o.label}</button>`, 57 + ) 58 + .join('') 59 + } 60 + 61 + #handleClick = (e: Event) => { 62 + const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-filter]') 63 + if (!btn) return 64 + libState.filter = btn.dataset.filter as Filter 65 + localStorage.setItem('library_filter', libState.filter) 66 + const group = this.querySelector('ui-button-group') 67 + if (group) group.innerHTML = this.filterBarHtml() 68 + libEmitter.dispatchEvent(new Event('filter')) 69 + } 70 + } 71 + 24 72 export class LibraryPage extends HTMLElement { 25 - private filter: Filter = 'all' 26 73 private gradeScale: 'french' | 'v' = 'french' 27 74 28 75 connectedCallback() { 29 - this.gradeScale = (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 30 - 'french' 31 - this.filter = (localStorage.getItem('library_filter') as Filter) ?? 'all' 32 - this.innerHTML = ` 33 - <ui-sub-header id="lb-filter-bar"> 34 - <ui-button-group> 35 - ${this.filterBarHtml()} 36 - </ui-button-group> 37 - </ui-sub-header> 38 - <div id="lb-list"></div> 39 - ` 76 + this.gradeScale = 77 + (localStorage.getItem('grade_scale') as 'french' | 'v') ?? 'french' 78 + this.innerHTML = `<div id="lb-list"></div>` 40 79 this.renderList() 41 - this.addEventListener('click', this.handleClick) 80 + libEmitter.addEventListener('filter', this.#onFilter) 81 + this.addEventListener('click', this.#handleClick) 42 82 } 43 83 44 84 disconnectedCallback() { 45 - this.removeEventListener('click', this.handleClick) 85 + libEmitter.removeEventListener('filter', this.#onFilter) 86 + this.removeEventListener('click', this.#handleClick) 87 + } 88 + 89 + #onFilter = () => { 90 + this.renderList() 46 91 } 47 92 48 - private handleClick = (e: Event) => { 93 + #handleClick = (e: Event) => { 49 94 const target = e.target as HTMLElement 50 - 51 - const filterBtn = target.closest<HTMLElement>('[data-filter]') 52 - if (filterBtn) { 53 - this.filter = filterBtn.dataset.filter as Filter 54 - localStorage.setItem('library_filter', this.filter) 55 - const filterBar = this.querySelector('#lb-filter-bar ui-button-group') 56 - if (filterBar) filterBar.innerHTML = this.filterBarHtml() 57 - this.renderList() 58 - return 59 - } 60 - 61 95 const entry = target.closest<HTMLElement>('[data-climb-id]') 62 96 if (entry) { 63 97 const climbId = parseInt(entry.dataset.climbId ?? '0') ··· 75 109 ) 76 110 } 77 111 78 - private filterBarHtml(): string { 79 - const opts: { value: Filter; label: string }[] = [ 80 - { value: 'all', label: 'All' }, 81 - { value: 'sent', label: 'Completed' }, 82 - { value: 'unsent', label: 'Not Completed' }, 83 - ] 84 - return opts 85 - .map( 86 - (o) => 87 - `<button aria-pressed="${ 88 - this.filter === o.value 89 - }" data-filter="${o.value}">${o.label}</button>`, 90 - ) 91 - .join('') 92 - } 93 - 94 112 private renderList(): void { 95 113 const listEl = this.querySelector('#lb-list') 96 114 if (!listEl) return 97 115 98 116 const all = this.sortedEntries() 99 117 const filtered = all.filter((e) => { 100 - if (this.filter === 'sent') return e.sent 101 - if (this.filter === 'unsent') return !e.sent 118 + if (libState.filter === 'sent') return e.sent 119 + if (libState.filter === 'unsent') return !e.sent 102 120 return true 103 121 }) 104 122 ··· 143 161 } 144 162 } 145 163 164 + customElements.define('library-filters', LibraryFilters) 146 165 customElements.define('library-page', LibraryPage)
+21 -21
www/static/theme.css
··· 191 191 192 192 /* ── Civility component overrides ─────────────────────────────────────── */ 193 193 194 + /* Sub-header hides itself when empty (settings, stopwatch pages) */ 195 + ui-sub-header:not(:has(*)) { 196 + display: none; 197 + } 198 + 194 199 /* Filter bars: sticky below the header, flex column for search/filters */ 195 200 ui-sub-header { 196 201 display: flex; ··· 198 203 gap: var(--s2); 199 204 padding: var(--s3); 200 205 border-bottom: 1px solid currentColor; 206 + } 207 + 208 + /* Filter components are transparent to layout — children flow into ui-sub-header's flex */ 209 + home-filters, 210 + library-filters { 211 + display: contents; 212 + } 213 + 214 + /* Climb header: full-width flex row inside ui-sub-header */ 215 + climb-header { 216 + display: flex; 217 + align-items: center; 218 + gap: var(--s2); 219 + width: 100%; 201 220 } 202 221 203 222 ui-sub-header input[type='search'], ··· 254 273 library-page { 255 274 display: flex; 256 275 flex-direction: column; 257 - min-height: calc(100vh - var(--header-height) - var(--footer-height)); 258 - } 259 - 260 - climb-page { 261 - display: flex; 262 - flex-direction: column; 263 - height: calc(100vh - var(--header-height) - var(--footer-height)); 264 - position: relative; 276 + min-height: 100%; 265 277 } 266 278 267 - /* Climb detail sub-header (back button + title + meta) */ 268 - climb-page ui-main-header { 269 - display: flex; 270 - align-items: center; 271 - gap: var(--s2); 272 - padding: var(--s2) var(--s3); 273 - border-bottom: 1px solid currentColor; 274 - flex-shrink: 0; 275 - } 276 - 277 - climb-page #cp-body { 278 - flex: 1; 279 - overflow-y: auto; 279 + #cp-body { 280 280 padding: var(--s3); 281 281 } 282 282