🪻 distributed transcription service
thistle.dunkirk.sh
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}