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