🪻 distributed transcription service thistle.dunkirk.sh
at main 5.6 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, property } from "lit/decorators.js"; 3 4export interface TableColumn { 5 key: string; 6 label: string; 7 sortable?: boolean; 8 render?: (value: unknown, row: unknown) => unknown; 9} 10 11@customElement("admin-data-table") 12export class AdminDataTable extends LitElement { 13 @property({ type: Array }) columns: TableColumn[] = []; 14 @property({ type: Array }) data: unknown[] = []; 15 @property({ type: String }) searchPlaceholder = "Search..."; 16 @property({ type: String }) emptyMessage = "No data available"; 17 @property({ type: Boolean }) loading = false; 18 19 @property({ type: String }) private searchTerm = ""; 20 @property({ type: String }) private sortKey = ""; 21 @property({ type: String }) private sortDirection: "asc" | "desc" = "asc"; 22 23 static override styles = css` 24 :host { 25 display: block; 26 } 27 28 .controls { 29 margin-bottom: 1rem; 30 display: flex; 31 gap: 1rem; 32 align-items: center; 33 } 34 35 .search { 36 flex: 1; 37 max-width: 20rem; 38 padding: 0.5rem 0.75rem; 39 border: 2px solid var(--secondary); 40 border-radius: 4px; 41 font-size: 1rem; 42 font-family: inherit; 43 background: var(--background); 44 color: var(--text); 45 } 46 47 .search:focus { 48 outline: none; 49 border-color: var(--primary); 50 } 51 52 table { 53 width: 100%; 54 border-collapse: collapse; 55 background: var(--background); 56 border: 2px solid var(--secondary); 57 border-radius: 8px; 58 overflow: hidden; 59 } 60 61 thead { 62 background: var(--primary); 63 color: white; 64 } 65 66 th { 67 padding: 1rem; 68 text-align: left; 69 font-weight: 600; 70 user-select: none; 71 } 72 73 th.sortable { 74 cursor: pointer; 75 position: relative; 76 } 77 78 th.sortable:hover { 79 background: var(--gunmetal); 80 } 81 82 .sort-indicator { 83 margin-left: 0.5rem; 84 opacity: 0.6; 85 } 86 87 td { 88 padding: 1rem; 89 border-top: 1px solid var(--secondary); 90 color: var(--text); 91 } 92 93 tbody tr { 94 cursor: pointer; 95 } 96 97 tbody tr:hover { 98 background: rgba(0, 0, 0, 0.02); 99 } 100 101 .empty-state, .loading { 102 text-align: center; 103 padding: 3rem; 104 color: var(--text); 105 opacity: 0.6; 106 } 107 `; 108 109 private get filteredData() { 110 let result = [...this.data]; 111 112 if (this.searchTerm) { 113 const term = this.searchTerm.toLowerCase(); 114 result = result.filter((row) => { 115 return this.columns.some((col) => { 116 const value = (row as Record<string, unknown>)[col.key]; 117 return String(value).toLowerCase().includes(term); 118 }); 119 }); 120 } 121 122 if (this.sortKey) { 123 result.sort((a, b) => { 124 const aVal = (a as Record<string, unknown>)[this.sortKey]; 125 const bVal = (b as Record<string, unknown>)[this.sortKey]; 126 127 let comparison = 0; 128 if (typeof aVal === "string" && typeof bVal === "string") { 129 comparison = aVal.localeCompare(bVal); 130 } else if (typeof aVal === "number" && typeof bVal === "number") { 131 comparison = aVal - bVal; 132 } else { 133 const aStr = String(aVal); 134 const bStr = String(bVal); 135 comparison = aStr.localeCompare(bStr); 136 } 137 138 return this.sortDirection === "asc" ? comparison : -comparison; 139 }); 140 } 141 142 return result; 143 } 144 145 private handleSearch(e: Event) { 146 this.searchTerm = (e.target as HTMLInputElement).value; 147 } 148 149 private handleSort(column: TableColumn) { 150 if (!column.sortable) return; 151 152 if (this.sortKey === column.key) { 153 this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; 154 } else { 155 this.sortKey = column.key; 156 this.sortDirection = "asc"; 157 } 158 } 159 160 private renderCell(column: TableColumn, row: unknown) { 161 const value = (row as Record<string, unknown>)[column.key]; 162 if (column.render) { 163 return column.render(value, row); 164 } 165 return value; 166 } 167 168 private handleRowClick(row: unknown) { 169 this.dispatchEvent( 170 new CustomEvent("row-click", { 171 detail: row, 172 bubbles: true, 173 composed: true, 174 }), 175 ); 176 } 177 178 override render() { 179 if (this.loading) { 180 return html`<div class="loading">Loading...</div>`; 181 } 182 183 const filtered = this.filteredData; 184 185 return html` 186 <div class="controls"> 187 <input 188 type="text" 189 class="search" 190 placeholder=${this.searchPlaceholder} 191 @input=${this.handleSearch} 192 value=${this.searchTerm} 193 /> 194 </div> 195 196 ${ 197 filtered.length === 0 198 ? html`<div class="empty-state">${this.emptyMessage}</div>` 199 : html` 200 <table> 201 <thead> 202 <tr> 203 ${this.columns.map( 204 (col) => html` 205 <th 206 class=${col.sortable ? "sortable" : ""} 207 @click=${() => this.handleSort(col)} 208 > 209 ${col.label} 210 ${ 211 col.sortable && this.sortKey === col.key 212 ? html`<span class="sort-indicator"> 213 ${this.sortDirection === "asc" ? "▲" : "▼"} 214 </span>` 215 : "" 216 } 217 </th> 218 `, 219 )} 220 </tr> 221 </thead> 222 <tbody> 223 ${filtered.map( 224 (row) => html` 225 <tr @click=${() => this.handleRowClick(row)}> 226 ${this.columns.map( 227 (col) => html`<td>${this.renderCell(col, row)}</td>`, 228 )} 229 </tr> 230 `, 231 )} 232 </tbody> 233 </table> 234 ` 235 } 236 `; 237 } 238} 239 240declare global { 241 interface HTMLElementTagNameMap { 242 "admin-data-table": AdminDataTable; 243 } 244}