🪻 distributed transcription service thistle.dunkirk.sh
at v0.1.0 5.9 kB view raw
1import { css, html, LitElement } from "lit"; 2import { customElement, state } from "lit/decorators.js"; 3 4interface ClassStats { 5 name: string; 6 count: number; 7 lastUpdated: number; 8} 9 10@customElement("classes-overview") 11export class ClassesOverview extends LitElement { 12 @state() classes: ClassStats[] = []; 13 @state() uncategorizedCount = 0; 14 15 static override styles = css` 16 :host { 17 display: block; 18 } 19 20 h1 { 21 color: var(--text); 22 margin-bottom: 2rem; 23 } 24 25 .classes-grid { 26 display: grid; 27 grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr)); 28 gap: 1.5rem; 29 margin-top: 2rem; 30 } 31 32 .class-card { 33 background: var(--background); 34 border: 1px solid var(--secondary); 35 border-radius: 8px; 36 padding: 1.5rem; 37 cursor: pointer; 38 transition: all 0.2s; 39 text-decoration: none; 40 color: var(--text); 41 display: block; 42 } 43 44 .class-card:hover { 45 border-color: var(--accent); 46 transform: translateY(-2px); 47 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 48 } 49 50 .class-name { 51 font-size: 1.25rem; 52 font-weight: 600; 53 margin-bottom: 0.5rem; 54 color: var(--text); 55 } 56 57 .class-stats { 58 font-size: 0.875rem; 59 color: var(--paynes-gray); 60 } 61 62 .class-count { 63 font-weight: 500; 64 color: var(--accent); 65 } 66 67 .upload-section { 68 background: color-mix(in srgb, var(--accent) 10%, transparent); 69 border: 2px dashed var(--accent); 70 border-radius: 8px; 71 padding: 2rem; 72 margin-bottom: 2rem; 73 text-align: center; 74 } 75 76 .upload-button { 77 background: var(--accent); 78 color: var(--white); 79 border: none; 80 padding: 0.75rem 1.5rem; 81 border-radius: 4px; 82 font-size: 1rem; 83 font-weight: 600; 84 cursor: pointer; 85 transition: opacity 0.2s; 86 } 87 88 .upload-button:hover { 89 opacity: 0.9; 90 } 91 92 .empty-state { 93 text-align: center; 94 padding: 4rem 2rem; 95 color: var(--paynes-gray); 96 } 97 98 .empty-state h2 { 99 color: var(--text); 100 margin-bottom: 1rem; 101 } 102 `; 103 104 override async connectedCallback() { 105 super.connectedCallback(); 106 await this.loadClasses(); 107 108 window.addEventListener("auth-changed", this.handleAuthChange); 109 } 110 111 override disconnectedCallback() { 112 super.disconnectedCallback(); 113 window.removeEventListener("auth-changed", this.handleAuthChange); 114 } 115 116 private handleAuthChange = async () => { 117 await this.loadClasses(); 118 }; 119 120 private async loadClasses() { 121 try { 122 const response = await fetch("/api/transcriptions"); 123 if (!response.ok) { 124 if (response.status === 401) { 125 this.classes = []; 126 this.uncategorizedCount = 0; 127 return; 128 } 129 throw new Error("Failed to load classes"); 130 } 131 132 const data = await response.json(); 133 const jobs = data.jobs || []; 134 135 // Group by class and count 136 const classMap = new Map< 137 string, 138 { count: number; lastUpdated: number } 139 >(); 140 let uncategorized = 0; 141 142 for (const job of jobs) { 143 const className = job.class_name; 144 if (!className) { 145 uncategorized++; 146 } else { 147 const existing = classMap.get(className); 148 if (existing) { 149 existing.count++; 150 existing.lastUpdated = Math.max( 151 existing.lastUpdated, 152 job.created_at, 153 ); 154 } else { 155 classMap.set(className, { 156 count: 1, 157 lastUpdated: job.created_at, 158 }); 159 } 160 } 161 } 162 163 this.uncategorizedCount = uncategorized; 164 this.classes = Array.from(classMap.entries()) 165 .map(([name, stats]) => ({ 166 name, 167 count: stats.count, 168 lastUpdated: stats.lastUpdated, 169 })) 170 .sort((a, b) => b.lastUpdated - a.lastUpdated); 171 } catch (error) { 172 console.error("Failed to load classes:", error); 173 } 174 } 175 176 private navigateToUpload() { 177 window.location.href = "/transcribe"; 178 } 179 180 private formatDate(timestamp: number): string { 181 const date = new Date(timestamp * 1000); 182 const now = new Date(); 183 const diffMs = now.getTime() - date.getTime(); 184 const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); 185 186 if (diffDays === 0) { 187 return "Today"; 188 } 189 if (diffDays === 1) { 190 return "Yesterday"; 191 } 192 if (diffDays < 7) { 193 return `${diffDays} days ago`; 194 } 195 return date.toLocaleDateString(); 196 } 197 198 override render() { 199 const hasClasses = this.classes.length > 0 || this.uncategorizedCount > 0; 200 201 return html` 202 <h1>Your Classes</h1> 203 204 <div class="upload-section"> 205 <button class="upload-button" @click=${this.navigateToUpload}> 206 📤 Upload New Transcription 207 </button> 208 </div> 209 210 ${ 211 hasClasses 212 ? html` 213 <div class="classes-grid"> 214 ${this.classes.map( 215 (classInfo) => html` 216 <a class="class-card" href="/class/${encodeURIComponent(classInfo.name)}"> 217 <div class="class-name">${classInfo.name}</div> 218 <div class="class-stats"> 219 <span class="class-count">${classInfo.count}</span> 220 ${classInfo.count === 1 ? "transcription" : "transcriptions"} 221${this.formatDate(classInfo.lastUpdated)} 222 </div> 223 </a> 224 `, 225 )} 226 227 ${ 228 this.uncategorizedCount > 0 229 ? html` 230 <a class="class-card" href="/class/uncategorized"> 231 <div class="class-name">Uncategorized</div> 232 <div class="class-stats"> 233 <span class="class-count">${this.uncategorizedCount}</span> 234 ${this.uncategorizedCount === 1 ? "transcription" : "transcriptions"} 235 </div> 236 </a> 237 ` 238 : "" 239 } 240 </div> 241 ` 242 : html` 243 <div class="empty-state"> 244 <h2>No transcriptions yet</h2> 245 <p>Upload your first audio file to get started!</p> 246 </div> 247 ` 248 } 249 `; 250 } 251}