compendious commited on
Commit
648d620
·
1 Parent(s): 3f80c5c

Finalized UI

Browse files
frontend/index.html CHANGED
@@ -6,7 +6,7 @@
6
  <link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
  <meta name="description"
9
- content="Précis — Summarize YouTube videos, articles, and text files with local AI models." />
10
  <title>Précis</title>
11
  </head>
12
 
 
6
  <link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
  <meta name="description"
9
+ content="Summarize YouTube videos, articles, and text files with local AI models." />
10
  <title>Précis</title>
11
  </head>
12
 
frontend/src/App.css CHANGED
@@ -1,433 +1,383 @@
1
- /* Header */
2
- .header {
 
 
 
 
 
 
3
  display: flex;
4
- align-items: center;
5
- justify-content: space-between;
6
- padding: var(--spacing-2) var(--spacing-4);
7
- background-color: var(--color-canvas-subtle);
8
- border-bottom: 1px solid var(--color-border-default);
9
- position: sticky;
10
- top: 0;
11
- z-index: 10;
12
- backdrop-filter: blur(12px);
13
- background-color: rgba(22, 27, 34, 0.85);
14
  }
15
 
16
  .logo {
17
  display: flex;
18
  align-items: center;
19
- gap: 10px;
20
- text-decoration: none;
21
  color: var(--color-fg-default);
 
 
22
  }
23
 
24
  .logo-icon {
25
- width: 28px;
26
- height: 28px;
27
- border-radius: 4px;
 
 
 
 
 
28
  }
29
 
30
- .logo-text {
31
- font-size: 18px;
32
- font-weight: 600;
33
- letter-spacing: -0.01em;
34
  }
35
 
36
- .header-actions {
37
- display: flex;
38
- align-items: center;
39
- gap: var(--spacing-2);
 
40
  }
41
 
 
42
  .model-select {
43
  appearance: none;
44
- background: var(--color-canvas-default);
45
  border: 1px solid var(--color-border-default);
46
  border-radius: 6px;
47
  color: var(--color-fg-default);
48
  font-size: 13px;
49
  font-family: inherit;
50
- padding: 6px 28px 6px 10px;
51
  cursor: pointer;
52
- transition: border-color 0.15s;
53
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
54
  background-repeat: no-repeat;
55
  background-position: right 8px center;
56
  }
57
 
58
- .model-select:hover {
59
- border-color: var(--color-accent-fg);
60
- }
61
-
62
  .model-select:focus {
63
  outline: none;
64
- border-color: var(--color-accent-fg);
65
- box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
66
- }
67
-
68
- .model-select:disabled {
69
- opacity: 0.5;
70
- cursor: not-allowed;
71
  }
 
72
 
73
- /* Model warm-up border states */
74
  .model-select--ready {
75
- border-color: rgba(46, 160, 67, 0.7) !important;
76
- box-shadow: 0 0 0 2px rgba(46, 160, 67, 0.15);
77
- transition: border-color 0.4s, box-shadow 0.4s;
78
  }
79
-
80
  .model-select--warming {
81
- border-color: rgba(234, 179, 8, 0.7) !important;
82
- box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.12);
83
- transition: border-color 0.4s, box-shadow 0.4s;
84
  }
85
 
86
- /* Main content */
87
  .main {
88
  flex: 1;
89
- padding: var(--spacing-6) 0;
90
- }
91
-
92
- .page-title {
93
- font-size: 32px;
94
- font-weight: 600;
95
- margin-bottom: var(--spacing-2);
96
  }
97
 
98
- .page-subtitle {
99
- font-size: 16px;
100
- color: var(--color-fg-muted);
101
- margin-bottom: var(--spacing-5);
102
  }
103
 
104
- /* Upload section */
105
- .upload-section {
106
- max-width: 800px;
107
- margin: 0 auto;
108
  }
109
 
110
- .upload-card {
 
111
  background-color: var(--color-canvas-subtle);
112
  border: 1px solid var(--color-border-default);
113
- border-radius: var(--radius-2);
114
  overflow: hidden;
115
- box-shadow: var(--shadow-md);
116
- transition: box-shadow 0.2s ease;
117
  }
118
 
119
- .upload-card:hover {
120
- box-shadow: var(--shadow-lg);
121
- }
122
-
123
- .upload-header {
124
- padding: var(--spacing-3) var(--spacing-4);
125
- background-color: var(--color-canvas-inset);
126
  border-bottom: 1px solid var(--color-border-muted);
127
  }
128
 
129
- .upload-title {
 
 
130
  font-size: 14px;
131
- font-weight: 600;
132
- display: flex;
133
- align-items: center;
134
- gap: var(--spacing-2);
 
 
 
 
 
135
  }
136
 
137
- .upload-title svg {
138
- width: 16px;
139
- height: 16px;
140
- color: var(--color-fg-muted);
141
  }
142
 
143
- .upload-body {
144
- padding: var(--spacing-4);
 
145
  }
146
 
147
- /* Tab content panels */
148
- .tab-panel {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  display: none;
150
  }
151
 
152
- .tab-panel.active {
153
  display: block;
154
- animation: fadeIn 0.2s ease-out;
155
  }
156
 
157
- /* Form groups */
158
- .form-group {
159
- margin-bottom: var(--spacing-4);
160
  }
161
 
162
- .form-label {
163
- display: block;
164
- font-size: 14px;
165
- font-weight: 500;
166
- color: var(--color-fg-default);
167
- margin-bottom: var(--spacing-2);
168
  }
169
 
170
- .form-hint {
 
171
  font-size: 12px;
 
 
 
172
  color: var(--color-fg-muted);
173
- margin-top: var(--spacing-1);
174
  }
175
 
176
- /* File input styling */
177
- .file-input {
178
- display: none;
 
 
179
  }
180
 
181
- .file-selected {
182
- display: flex;
183
- align-items: center;
184
- gap: var(--spacing-3);
185
- padding: var(--spacing-3);
186
- background-color: var(--color-canvas-default);
187
- border: 1px solid var(--color-border-default);
188
- border-radius: var(--radius-1);
189
- margin-top: var(--spacing-3);
190
  }
191
 
192
- .file-icon {
193
- width: 32px;
194
- height: 32px;
195
- display: flex;
196
  align-items: center;
197
- justify-content: center;
198
- background-color: var(--color-accent-emphasis);
199
- border-radius: var(--radius-1);
200
- color: white;
 
 
 
 
201
  }
202
 
203
- .file-info {
204
- flex: 1;
205
- }
206
 
207
- .file-name {
208
- font-size: 14px;
209
- font-weight: 500;
210
- color: var(--color-fg-default);
 
211
  }
212
 
213
- .file-size {
214
- font-size: 12px;
215
- color: var(--color-fg-muted);
 
216
  }
217
 
218
- .file-remove {
219
- padding: var(--spacing-1);
 
 
220
  background: transparent;
221
  border: none;
222
- color: var(--color-fg-muted);
223
  cursor: pointer;
224
- border-radius: var(--radius-1);
225
- transition: color var(--transition-fast), background-color var(--transition-fast);
 
226
  }
227
 
228
- .file-remove:hover {
229
  color: var(--color-danger-fg);
230
- background-color: rgba(248, 81, 73, 0.1);
231
  }
232
 
233
- /* Submit section */
234
- .submit-section {
235
  display: flex;
236
  align-items: center;
237
- justify-content: flex-end;
238
- gap: var(--spacing-2);
239
- padding-top: var(--spacing-4);
240
  border-top: 1px solid var(--color-border-muted);
241
  margin-top: var(--spacing-4);
242
  }
243
 
244
- /* Cancel / stop button */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  .btn-cancel {
246
  display: inline-flex;
247
  align-items: center;
248
- gap: 6px;
249
- padding: 8px 16px;
250
  font-size: 13px;
251
  font-weight: 500;
252
- border-radius: 6px;
253
- border: 1px solid rgba(248, 81, 73, 0.4);
254
- background-color: rgba(248, 81, 73, 0.08);
255
  color: var(--color-danger-fg);
256
  cursor: pointer;
257
  transition: background-color 0.15s, border-color 0.15s;
258
  }
259
 
260
  .btn-cancel:hover {
261
- background-color: rgba(248, 81, 73, 0.16);
262
- border-color: rgba(248, 81, 73, 0.6);
263
- }
264
-
265
- /* Response section */
266
- .response-section {
267
- margin-top: var(--spacing-5);
268
- }
269
-
270
- .response-card {
271
- background-color: var(--color-canvas-subtle);
272
- border: 1px solid var(--color-border-default);
273
- border-radius: var(--radius-2);
274
- overflow: hidden;
275
- }
276
-
277
- .response-header {
278
- display: flex;
279
- align-items: center;
280
- justify-content: space-between;
281
- padding: var(--spacing-3) var(--spacing-4);
282
- background-color: var(--color-canvas-inset);
283
- border-bottom: 1px solid var(--color-border-muted);
284
- }
285
-
286
- .response-title {
287
- font-size: 14px;
288
- font-weight: 600;
289
- display: flex;
290
- align-items: center;
291
- gap: var(--spacing-2);
292
- }
293
-
294
- .response-badge {
295
- font-size: 12px;
296
- padding: 2px 8px;
297
- border-radius: var(--radius-full);
298
- background-color: var(--color-btn-primary-bg);
299
- color: white;
300
- }
301
-
302
- .response-body {
303
- padding: var(--spacing-4);
304
- }
305
-
306
- .response-text {
307
- font-size: 14px;
308
- line-height: 1.7;
309
- color: var(--color-fg-default);
310
- white-space: pre-wrap;
311
- }
312
-
313
- /* Loading state */
314
- .loading {
315
- display: flex;
316
- flex-direction: column;
317
- align-items: center;
318
- gap: var(--spacing-3);
319
- padding: var(--spacing-6);
320
- color: var(--color-fg-muted);
321
  }
322
 
 
323
  .loading-spinner {
324
- width: 24px;
325
- height: 24px;
326
  border: 2px solid var(--color-border-default);
327
  border-top-color: var(--color-accent-fg);
328
  border-radius: 50%;
329
- animation: spin 0.8s linear infinite;
 
330
  }
331
 
332
- @keyframes spin {
333
- to {
334
- transform: rotate(360deg);
335
- }
336
- }
337
-
338
- /* Footer */
339
- .footer {
340
- padding: var(--spacing-4);
341
- text-align: center;
342
- font-size: 12px;
343
- color: var(--color-fg-subtle);
344
- border-top: 1px solid var(--color-border-muted);
345
- }
346
 
347
- .footer a {
348
- color: var(--color-accent-fg);
349
- text-decoration: none;
350
- }
351
-
352
- .footer a:hover {
353
- text-decoration: underline;
354
- }
355
-
356
- /* Inline result */
357
  .inline-result {
358
- margin-top: var(--spacing-3);
359
- padding: var(--spacing-3) var(--spacing-4);
360
- border-radius: var(--radius-1);
361
  font-size: 13px;
362
  line-height: 1.6;
363
- display: flex;
364
- align-items: flex-start;
365
- gap: var(--spacing-2);
366
  border: 1px solid transparent;
367
- }
368
-
369
- .inline-result--success {
370
- background-color: rgba(46, 160, 67, 0.08);
371
- border-color: rgba(46, 160, 67, 0.3);
372
- color: var(--color-fg-default);
373
- flex-direction: column;
374
- gap: var(--spacing-2);
375
  }
376
 
377
  .inline-result--error {
378
- background-color: rgba(248, 81, 73, 0.08);
379
- border-color: rgba(248, 81, 73, 0.3);
380
- color: var(--color-danger-fg);
381
  align-items: center;
 
 
 
 
 
382
  }
383
 
384
- .inline-result--loading {
 
385
  background-color: var(--color-canvas-inset);
386
  border-color: var(--color-border-muted);
387
- color: var(--color-fg-muted);
388
- align-items: center;
389
  }
390
 
391
  .inline-result--streaming {
392
- background-color: var(--color-canvas-inset);
393
- border-color: var(--color-accent-emphasis);
394
- color: var(--color-fg-default);
395
- flex-direction: column;
396
- gap: var(--spacing-2);
397
  }
398
 
399
- .inline-result--streaming .inline-result__label {
400
- color: var(--color-accent-fg);
 
 
401
  }
402
 
403
  .inline-result__label {
404
  display: flex;
405
  align-items: center;
406
- gap: var(--spacing-1);
407
- font-size: 12px;
 
408
  font-weight: 600;
409
- color: rgba(46, 160, 67, 0.9);
410
- width: 100%;
 
 
 
 
 
 
 
 
 
411
  }
412
 
413
- /* ── Prose styles for ReactMarkdown output ── */
414
  .inline-result__text {
 
415
  font-size: 14px;
416
- line-height: 1.7;
417
  color: var(--color-fg-default);
418
- margin: 0;
419
- width: 100%;
420
  }
421
 
422
- /* Paragraphs */
423
- .inline-result__text p {
424
- margin: 0 0 0.6em;
425
- }
426
- .inline-result__text p:last-child {
427
- margin-bottom: 0;
428
- }
429
 
430
- /* Headings */
431
  .inline-result__text h1,
432
  .inline-result__text h2,
433
  .inline-result__text h3,
@@ -449,49 +399,21 @@
449
  .inline-result__text h2:first-child,
450
  .inline-result__text h3:first-child { margin-top: 0; }
451
 
452
- /* Lists — top-level */
453
  .inline-result__text ul,
454
- .inline-result__text ol {
455
- margin: 0.4em 0 0.6em;
456
- padding-left: 1.5em;
457
- }
458
  .inline-result__text ul { list-style-type: disc; }
459
  .inline-result__text ol { list-style-type: decimal; }
460
-
461
- /* Nested lists level 2 */
462
- .inline-result__text ul ul,
463
- .inline-result__text ol ul { list-style-type: circle; }
464
- .inline-result__text ul ol,
465
- .inline-result__text ol ol { list-style-type: lower-alpha; }
466
- .inline-result__text ul ul,
467
- .inline-result__text ol ul,
468
- .inline-result__text ul ol,
469
- .inline-result__text ol ol {
470
- margin: 0.15em 0;
471
- padding-left: 1.4em;
472
- }
473
-
474
- /* Nested lists — level 3+ */
475
- .inline-result__text ul ul ul,
476
- .inline-result__text ol ul ul { list-style-type: square; }
477
- .inline-result__text ul ul ul,
478
- .inline-result__text ol ol ol,
479
- .inline-result__text ul ol ul,
480
- .inline-result__text ol ul ol {
481
- margin: 0.1em 0;
482
- padding-left: 1.3em;
483
- }
484
-
485
- .inline-result__text li {
486
- margin: 0.2em 0;
487
- }
488
  .inline-result__text li > p { margin: 0; }
489
 
490
- /* Inline emphasis */
491
  .inline-result__text strong { font-weight: 600; }
492
  .inline-result__text em { font-style: italic; }
493
 
494
- /* Blockquote */
495
  .inline-result__text blockquote {
496
  margin: 0.6em 0;
497
  padding: 0.3em 0 0.3em 0.9em;
@@ -500,9 +422,8 @@
500
  font-style: italic;
501
  }
502
 
503
- /* Inline code */
504
  .inline-result__text code {
505
- font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
506
  font-size: 12px;
507
  background-color: var(--color-canvas-inset);
508
  border: 1px solid var(--color-border-muted);
@@ -510,7 +431,6 @@
510
  padding: 0.1em 0.35em;
511
  }
512
 
513
- /* Fenced code blocks */
514
  .inline-result__text pre {
515
  background-color: var(--color-canvas-inset);
516
  border: 1px solid var(--color-border-muted);
@@ -521,34 +441,132 @@
521
  font-size: 12px;
522
  line-height: 1.6;
523
  }
524
- .inline-result__text pre code {
525
- background: none;
526
- border: none;
527
- padding: 0;
528
- font-size: inherit;
529
- }
530
 
531
- /* Horizontal rule */
532
- .inline-result__text hr {
533
- border: none;
534
- border-top: 1px solid var(--color-border-muted);
535
- margin: 0.8em 0;
 
 
 
 
 
 
536
  }
537
 
538
- /* Streaming cursor blink */
539
  .streaming-cursor {
540
- animation: blink 0.7s step-end infinite;
 
541
  color: var(--color-accent-fg);
542
- font-weight: 300;
543
  }
544
 
545
- @keyframes blink {
546
- 50% {
547
- opacity: 0;
548
- }
549
- }
550
 
551
  .streaming-placeholder {
552
- color: var(--color-fg-muted);
553
  font-style: italic;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
554
  }
 
1
+ .content-layout {
2
+ max-width: 860px;
3
+ margin: 0 auto;
4
+ padding: 0 var(--spacing-3);
5
+ box-sizing: border-box;
6
+ }
7
+
8
+ .model-selector-wrap {
9
  display: flex;
10
+ justify-content: flex-end;
11
+ margin-top: 14px;
 
 
 
 
 
 
 
 
12
  }
13
 
14
  .logo {
15
  display: flex;
16
  align-items: center;
17
+ gap: 24px;
 
18
  color: var(--color-fg-default);
19
+ cursor: default;
20
+ user-select: none;
21
  }
22
 
23
  .logo-icon {
24
+ width: 96px;
25
+ height: 96px;
26
+ border-radius: 50%;
27
+ background-color: #ffffff;
28
+ padding: 12px;
29
+ box-sizing: border-box;
30
+ animation: logo-spin 10s linear infinite;
31
+ display: block;
32
  }
33
 
34
+ @keyframes logo-spin {
35
+ from { transform: rotate(0deg); }
36
+ to { transform: rotate(360deg); }
 
37
  }
38
 
39
+ .logo-text {
40
+ font-size: 96px;
41
+ font-weight: 600;
42
+ letter-spacing: -0.03em;
43
+ color: var(--color-fg-default);
44
  }
45
 
46
+ /* ─── Model selector ─────────────────────────────────────── */
47
  .model-select {
48
  appearance: none;
49
+ background: var(--color-canvas-subtle);
50
  border: 1px solid var(--color-border-default);
51
  border-radius: 6px;
52
  color: var(--color-fg-default);
53
  font-size: 13px;
54
  font-family: inherit;
55
+ padding: 5px 26px 5px 10px;
56
  cursor: pointer;
57
+ transition: border-color 0.2s, box-shadow 0.2s;
58
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
59
  background-repeat: no-repeat;
60
  background-position: right 8px center;
61
  }
62
 
63
+ .model-select:hover { border-color: var(--color-border-default); }
 
 
 
64
  .model-select:focus {
65
  outline: none;
66
+ border-color: var(--color-accent-emphasis);
67
+ box-shadow: 0 0 0 2px rgba(31, 111, 235, 0.2);
 
 
 
 
 
68
  }
69
+ .model-select:disabled { opacity: 0.4; cursor: not-allowed; }
70
 
 
71
  .model-select--ready {
72
+ border-color: rgba(46, 160, 67, 0.6) !important;
73
+ box-shadow: 0 0 0 2px rgba(46, 160, 67, 0.1);
 
74
  }
 
75
  .model-select--warming {
76
+ border-color: rgba(210, 153, 34, 0.6) !important;
77
+ box-shadow: 0 0 0 2px rgba(210, 153, 34, 0.08);
 
78
  }
79
 
80
+ /* Hero */
81
  .main {
82
  flex: 1;
83
+ padding: var(--spacing-6) 0 var(--spacing-5);
 
 
 
 
 
 
84
  }
85
 
86
+ /* Hero */
87
+ .hero {
88
+ margin-bottom: var(--spacing-4);
89
+ text-align: left;
90
  }
91
 
92
+ .hero-logo {
93
+ justify-content: flex-start;
 
 
94
  }
95
 
96
+ /* ─── Card ───────────────────────────────────────────────── */
97
+ .card-wrap {
98
  background-color: var(--color-canvas-subtle);
99
  border: 1px solid var(--color-border-default);
100
+ border-radius: 14px;
101
  overflow: hidden;
102
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
 
103
  }
104
 
105
+ /* ─── Segmented control ──────────────────────────────────── */
106
+ .seg-control {
107
+ display: flex;
108
+ gap: 6px;
109
+ padding: 18px 32px 0;
 
 
110
  border-bottom: 1px solid var(--color-border-muted);
111
  }
112
 
113
+ .seg-btn {
114
+ position: relative;
115
+ padding: 8px 24px;
116
  font-size: 14px;
117
+ font-weight: 500;
118
+ color: var(--color-fg-muted);
119
+ background: transparent;
120
+ border: none;
121
+ border-radius: 6px 6px 0 0;
122
+ cursor: pointer;
123
+ transition: color 0.15s;
124
+ margin-bottom: -1px;
125
+ border-bottom: 2px solid transparent;
126
  }
127
 
128
+ .seg-btn:hover {
129
+ color: var(--color-fg-default);
 
 
130
  }
131
 
132
+ .seg-btn--active {
133
+ color: var(--color-fg-default);
134
+ border-bottom-color: var(--color-accent-fg);
135
  }
136
 
137
+ /* ─── Dropzone ───────────────────────────────────────────── */
138
+ /* (styles in index.css for .dropzone base) */
139
+
140
+ .dropzone-label {
141
+ font-size: 11px;
142
+ font-weight: 600;
143
+ letter-spacing: 0.08em;
144
+ text-transform: uppercase;
145
+ color: var(--color-fg-subtle);
146
+ background: var(--color-canvas-default);
147
+ border: 1px solid var(--color-border-muted);
148
+ border-radius: var(--radius-full);
149
+ padding: 2px 10px;
150
+ pointer-events: none;
151
+ user-select: none;
152
+ }
153
+ .panels {
154
+ padding: 32px 32px 0;
155
+ }
156
+
157
+ .panel {
158
  display: none;
159
  }
160
 
161
+ .panel--active {
162
  display: block;
163
+ animation: fadeUp 0.18s ease-out both;
164
  }
165
 
166
+ @keyframes fadeUp {
167
+ from { opacity: 0; transform: translateY(6px); }
168
+ to { opacity: 1; transform: translateY(0); }
169
  }
170
 
171
+ /* ─── Fields ─────────────────────────────────────────────── */
172
+ .field {
173
+ margin-bottom: var(--spacing-3);
 
 
 
174
  }
175
 
176
+ .field-label {
177
+ display: block;
178
  font-size: 12px;
179
+ font-weight: 600;
180
+ letter-spacing: 0.04em;
181
+ text-transform: uppercase;
182
  color: var(--color-fg-muted);
183
+ margin-bottom: 8px;
184
  }
185
 
186
+ .field-label-hint {
187
+ font-weight: 400;
188
+ text-transform: none;
189
+ letter-spacing: 0;
190
+ color: var(--color-fg-subtle);
191
  }
192
 
193
+ /* ─── Input overrides ────────────────────────────────────── */
194
+ .input--lg {
195
+ padding: 14px 16px;
196
+ font-size: 16px;
197
+ border-radius: 8px;
 
 
 
 
198
  }
199
 
200
+ /* ─── File chip ──────────────────────────────────────────── */
201
+ .file-chip {
202
+ display: inline-flex;
 
203
  align-items: center;
204
+ gap: 6px;
205
+ margin-top: 10px;
206
+ padding: 5px 10px 5px 8px;
207
+ background: var(--color-canvas-inset);
208
+ border: 1px solid var(--color-border-default);
209
+ border-radius: var(--radius-full);
210
+ font-size: 13px;
211
+ color: var(--color-fg-default);
212
  }
213
 
214
+ .file-chip svg { color: var(--color-fg-muted); flex-shrink: 0; }
 
 
215
 
216
+ .file-chip__name {
217
+ max-width: 260px;
218
+ overflow: hidden;
219
+ text-overflow: ellipsis;
220
+ white-space: nowrap;
221
  }
222
 
223
+ .file-chip__size {
224
+ color: var(--color-fg-subtle);
225
+ font-size: 11px;
226
+ flex-shrink: 0;
227
  }
228
 
229
+ .file-chip__remove {
230
+ display: flex;
231
+ align-items: center;
232
+ padding: 2px;
233
  background: transparent;
234
  border: none;
235
+ color: var(--color-fg-subtle);
236
  cursor: pointer;
237
+ border-radius: 50%;
238
+ transition: color 0.12s, background-color 0.12s;
239
+ margin-left: 2px;
240
  }
241
 
242
+ .file-chip__remove:hover {
243
  color: var(--color-danger-fg);
244
+ background-color: rgba(248, 81, 73, 0.12);
245
  }
246
 
247
+ /* ─── Action row ─────────────────────────────────��───────── */
248
+ .action-row {
249
  display: flex;
250
  align-items: center;
251
+ gap: 12px;
252
+ padding: 20px 32px;
 
253
  border-top: 1px solid var(--color-border-muted);
254
  margin-top: var(--spacing-4);
255
  }
256
 
257
+ .btn-generate {
258
+ padding: 8px 20px;
259
+ font-size: 14px;
260
+ border-radius: 8px;
261
+ gap: 7px;
262
+ }
263
+
264
+ .action-hint {
265
+ font-size: 12px;
266
+ color: var(--color-fg-subtle);
267
+ margin-left: auto;
268
+ }
269
+
270
+ .action-hint kbd {
271
+ font-family: inherit;
272
+ font-size: 11px;
273
+ background: var(--color-canvas-inset);
274
+ border: 1px solid var(--color-border-muted);
275
+ border-radius: 4px;
276
+ padding: 0 5px;
277
+ color: var(--color-fg-muted);
278
+ }
279
+
280
+ /* ─── Cancel button ──────────────────────────────────────── */
281
  .btn-cancel {
282
  display: inline-flex;
283
  align-items: center;
284
+ gap: 5px;
285
+ padding: 7px 14px;
286
  font-size: 13px;
287
  font-weight: 500;
288
+ border-radius: 8px;
289
+ border: 1px solid rgba(248, 81, 73, 0.35);
290
+ background-color: rgba(248, 81, 73, 0.07);
291
  color: var(--color-danger-fg);
292
  cursor: pointer;
293
  transition: background-color 0.15s, border-color 0.15s;
294
  }
295
 
296
  .btn-cancel:hover {
297
+ background-color: rgba(248, 81, 73, 0.14);
298
+ border-color: rgba(248, 81, 73, 0.55);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
  }
300
 
301
+ /* ─── Loading ────────────────────────────────────────────── */
302
  .loading-spinner {
303
+ width: 20px;
304
+ height: 20px;
305
  border: 2px solid var(--color-border-default);
306
  border-top-color: var(--color-accent-fg);
307
  border-radius: 50%;
308
+ animation: spin 0.7s linear infinite;
309
+ flex-shrink: 0;
310
  }
311
 
312
+ @keyframes spin { to { transform: rotate(360deg); } }
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
+ /* ─── Inline result ──────────────────────────────────────── */
 
 
 
 
 
 
 
 
 
315
  .inline-result {
316
+ margin: var(--spacing-2) 0 var(--spacing-3);
317
+ border-radius: 8px;
 
318
  font-size: 13px;
319
  line-height: 1.6;
 
 
 
320
  border: 1px solid transparent;
321
+ overflow: hidden;
 
 
 
 
 
 
 
322
  }
323
 
324
  .inline-result--error {
325
+ display: flex;
 
 
326
  align-items: center;
327
+ gap: 8px;
328
+ padding: 10px 14px;
329
+ background-color: rgba(248, 81, 73, 0.07);
330
+ border-color: rgba(248, 81, 73, 0.25);
331
+ color: var(--color-danger-fg);
332
  }
333
 
334
+ .inline-result--loading,
335
+ .inline-result--streaming {
336
  background-color: var(--color-canvas-inset);
337
  border-color: var(--color-border-muted);
 
 
338
  }
339
 
340
  .inline-result--streaming {
341
+ border-color: rgba(31, 111, 235, 0.35);
 
 
 
 
342
  }
343
 
344
+ .inline-result--success {
345
+ background-color: rgba(22, 27, 34, 0.6);
346
+ border-color: var(--color-border-muted);
347
+ border-left: 3px solid var(--color-accent-emphasis);
348
  }
349
 
350
  .inline-result__label {
351
  display: flex;
352
  align-items: center;
353
+ gap: 6px;
354
+ padding: 8px 12px;
355
+ font-size: 11px;
356
  font-weight: 600;
357
+ letter-spacing: 0.04em;
358
+ text-transform: uppercase;
359
+ border-bottom: 1px solid var(--color-border-muted);
360
+ }
361
+
362
+ .inline-result--success .inline-result__label {
363
+ color: var(--color-accent-fg);
364
+ }
365
+
366
+ .inline-result--streaming .inline-result__label {
367
+ color: var(--color-fg-muted);
368
  }
369
 
 
370
  .inline-result__text {
371
+ padding: 12px 14px;
372
  font-size: 14px;
373
+ line-height: 1.75;
374
  color: var(--color-fg-default);
 
 
375
  }
376
 
377
+ /* ── Prose ──────────────────────────────────────────────── */
378
+ .inline-result__text p { margin: 0 0 0.6em; }
379
+ .inline-result__text p:last-child { margin-bottom: 0; }
 
 
 
 
380
 
 
381
  .inline-result__text h1,
382
  .inline-result__text h2,
383
  .inline-result__text h3,
 
399
  .inline-result__text h2:first-child,
400
  .inline-result__text h3:first-child { margin-top: 0; }
401
 
 
402
  .inline-result__text ul,
403
+ .inline-result__text ol { margin: 0.4em 0 0.6em; padding-left: 1.5em; }
 
 
 
404
  .inline-result__text ul { list-style-type: disc; }
405
  .inline-result__text ol { list-style-type: decimal; }
406
+ .inline-result__text ul ul, .inline-result__text ol ul { list-style-type: circle; }
407
+ .inline-result__text ul ol, .inline-result__text ol ol { list-style-type: lower-alpha; }
408
+ .inline-result__text ul ul, .inline-result__text ol ul,
409
+ .inline-result__text ul ol, .inline-result__text ol ol { margin: 0.15em 0; padding-left: 1.4em; }
410
+ .inline-result__text ul ul ul, .inline-result__text ol ul ul { list-style-type: square; }
411
+ .inline-result__text li { margin: 0.2em 0; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
412
  .inline-result__text li > p { margin: 0; }
413
 
 
414
  .inline-result__text strong { font-weight: 600; }
415
  .inline-result__text em { font-style: italic; }
416
 
 
417
  .inline-result__text blockquote {
418
  margin: 0.6em 0;
419
  padding: 0.3em 0 0.3em 0.9em;
 
422
  font-style: italic;
423
  }
424
 
 
425
  .inline-result__text code {
426
+ font-family: var(--font-mono);
427
  font-size: 12px;
428
  background-color: var(--color-canvas-inset);
429
  border: 1px solid var(--color-border-muted);
 
431
  padding: 0.1em 0.35em;
432
  }
433
 
 
434
  .inline-result__text pre {
435
  background-color: var(--color-canvas-inset);
436
  border: 1px solid var(--color-border-muted);
 
441
  font-size: 12px;
442
  line-height: 1.6;
443
  }
444
+ .inline-result__text pre code { background: none; border: none; padding: 0; font-size: inherit; }
445
+ .inline-result__text hr { border: none; border-top: 1px solid var(--color-border-muted); margin: 0.8em 0; }
 
 
 
 
446
 
447
+ /* ─── Response badge ─────────────────────────────────────── */
448
+ .response-badge {
449
+ font-size: 10px;
450
+ font-weight: 500;
451
+ padding: 1px 7px;
452
+ border-radius: var(--radius-full);
453
+ background-color: var(--color-canvas-default);
454
+ border: 1px solid var(--color-border-muted);
455
+ color: var(--color-fg-subtle);
456
+ letter-spacing: 0;
457
+ text-transform: none;
458
  }
459
 
460
+ /* ─── Streaming cursor ───────────────────────────────────── */
461
  .streaming-cursor {
462
+ display: inline-block;
463
+ animation: blink 0.75s step-end infinite;
464
  color: var(--color-accent-fg);
465
+ margin-left: 1px;
466
  }
467
 
468
+ @keyframes blink { 50% { opacity: 0; } }
 
 
 
 
469
 
470
  .streaming-placeholder {
471
+ color: var(--color-fg-subtle);
472
  font-style: italic;
473
+ font-size: 13px;
474
+ }
475
+
476
+ /* ─── Footer ─────────────────────────────────────────────── */
477
+ .footer {
478
+ display: flex;
479
+ align-items: center;
480
+ justify-content: center;
481
+ gap: 10px;
482
+ padding: var(--spacing-3);
483
+ font-size: 12px;
484
+ color: var(--color-fg-subtle);
485
+ border-top: 1px solid var(--color-border-muted);
486
+ }
487
+
488
+ .footer-sep { opacity: 0.4; }
489
+
490
+ .footer a {
491
+ color: var(--color-fg-subtle);
492
+ text-decoration: none;
493
+ transition: color 0.12s;
494
+ }
495
+
496
+ .footer a:hover { color: var(--color-fg-muted); }
497
+
498
+ /* ─── Fade-in ────────────────────────────────────────────── */
499
+ .fade-in { animation: fadeUp 0.25s ease-out both; }
500
+
501
+ /* ─── Media Queries (Mobile Friendly) ────────────────────── */
502
+ @media (max-width: 768px) {
503
+ .logo {
504
+ gap: 16px;
505
+ }
506
+ .logo-icon {
507
+ width: 64px;
508
+ height: 64px;
509
+ padding: 8px;
510
+ }
511
+ .logo-text {
512
+ font-size: 56px;
513
+ }
514
+ .seg-control {
515
+ padding: 14px 20px 0;
516
+ }
517
+ .seg-btn {
518
+ padding: 8px 16px;
519
+ font-size: 13px;
520
+ }
521
+ .panels {
522
+ padding: 24px 20px 0;
523
+ }
524
+ .action-row {
525
+ padding: 16px 20px;
526
+ }
527
+ .action-hint {
528
+ display: none;
529
+ }
530
+ }
531
+
532
+ @media (max-width: 480px) {
533
+ .logo {
534
+ gap: 12px;
535
+ }
536
+ .logo-icon {
537
+ width: 48px;
538
+ height: 48px;
539
+ padding: 6px;
540
+ }
541
+ .logo-text {
542
+ font-size: 36px;
543
+ }
544
+ .seg-control {
545
+ padding: 12px 12px 0;
546
+ justify-content: space-between;
547
+ }
548
+ .seg-btn {
549
+ flex: 1;
550
+ text-align: center;
551
+ padding: 8px 8px;
552
+ }
553
+ .panels {
554
+ padding: 20px 12px 0;
555
+ }
556
+ .action-row {
557
+ flex-direction: column;
558
+ gap: 10px;
559
+ padding: 16px 12px;
560
+ }
561
+ .action-row .btn {
562
+ width: 100%;
563
+ justify-content: center;
564
+ }
565
+ .model-selector-wrap {
566
+ justify-content: center;
567
+ margin-top: 16px;
568
+ }
569
+ .model-select {
570
+ width: 100%;
571
+ }
572
  }
frontend/src/App.jsx CHANGED
@@ -12,11 +12,10 @@ function App() {
12
  const [selectedFile, setSelectedFile] = useState(null)
13
  const [models, setModels] = useState([])
14
  const [selectedModel, setSelectedModel] = useState('')
15
- const [modelReady, setModelReady] = useState(null) // null=unknown, false=warming, true=ready
16
  const fileInputRef = useRef(null)
17
  const warmupAbortRef = useRef(null)
18
 
19
- // One streaming instance per tab — state is fully independent and persists across tab switches
20
  const ytStreaming = useStreaming()
21
  const textStreaming = useStreaming()
22
  const fileStreaming = useStreaming()
@@ -32,33 +31,22 @@ function App() {
32
  if (!res.ok) return
33
  const data = await res.json()
34
  if (cancelled) return
35
-
36
  const available = Array.isArray(data.available) ? data.available : []
37
  setModels(available)
38
-
39
  const serverDefault = typeof data.default === 'string' ? data.default : ''
40
  setSelectedModel((prev) => prev || serverDefault || available[0] || '')
41
- } catch {
42
- // Non-fatal: model list stays empty; backend will still pick default if model omitted.
43
- }
44
  })()
45
  return () => { cancelled = true }
46
  }, [])
47
 
48
- // Warm up the selected model whenever the selection changes.
49
- // 1. POST /warmup — tells Ollama to load the model; only resolves when done.
50
- // 2. While waiting, poll GET /warmup/status every 2 s to update the indicator sooner.
51
  useEffect(() => {
52
  if (!selectedModel) return
53
-
54
- // Cancel any in-flight warmup for the previous model
55
  if (warmupAbortRef.current) warmupAbortRef.current.abort()
56
  const controller = new AbortController()
57
  warmupAbortRef.current = controller
58
-
59
  setModelReady(false)
60
 
61
- // Poll /warmup/status every 2 s until loaded or aborted
62
  let pollTimer = null
63
  const poll = async () => {
64
  if (controller.signal.aborted) return
@@ -69,39 +57,26 @@ function App() {
69
  )
70
  if (r.ok) {
71
  const data = await r.json()
72
- if (data.loaded) {
73
- setModelReady(true)
74
- return
75
- }
76
  }
77
  } catch { /* ignore */ }
78
- if (!controller.signal.aborted) {
79
- pollTimer = setTimeout(poll, 2000)
80
- }
81
  }
82
  poll()
83
 
84
- // POST /warmup — blocks until Ollama has the model in VRAM
85
  fetch(`${API_BASE}/warmup`, {
86
  method: 'POST',
87
  headers: { 'Content-Type': 'application/json' },
88
  body: JSON.stringify({ model: selectedModel }),
89
  signal: controller.signal,
90
- })
91
- .then(() => { setModelReady(true) })
92
- .catch(() => { /* warmup errors are non-fatal */ })
93
 
94
- return () => {
95
- controller.abort()
96
- clearTimeout(pollTimer)
97
- }
98
  }, [selectedModel])
99
 
100
  const handleSubmit = () =>
101
  active.submit(activeTab, {
102
- youtubeUrl,
103
- transcript,
104
- selectedFile,
105
  selectedModel: selectedModel || undefined,
106
  })
107
 
@@ -123,70 +98,58 @@ function App() {
123
  if (e.key === 'Enter' && e.ctrlKey && !active.loading) handleSubmit()
124
  }
125
 
 
 
 
 
 
 
126
  return (
127
  <>
128
- <header className="header">
129
- <a href="/" className="logo">
130
- <img src={logoSvg} alt="Précis" className="logo-icon" />
131
- <span className="logo-text">Précis</span>
132
- </a>
133
- <div className="header-actions">
134
- <select
135
- className={`model-select${modelReady === true ? ' model-select--ready' : modelReady === false ? ' model-select--warming' : ''}`}
136
- value={selectedModel}
137
- onChange={(e) => setSelectedModel(e.target.value)}
138
- disabled={active.loading || models.length === 0}
139
- >
140
- {models.map((m) => <option key={m} value={m}>{m}</option>)}
141
- </select>
142
-
143
- <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
144
- API Docs
145
- </a>
146
- </div>
147
- </header>
148
-
149
  <main className="main">
150
  <div className="container">
151
- <div className="upload-section fade-in">
152
- <h1 className="page-title">Summarize Content</h1>
153
- <p className="page-subtitle">
154
- Upload a YouTube video, paste a transcript, or drop a text file to generate a summary.
155
- </p>
156
 
157
- <div className="upload-card">
158
- <div className="upload-header">
159
- <div className="upload-title">
160
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
161
- <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
162
- <polyline points="17 8 12 3 7 8" />
163
- <line x1="12" y1="3" x2="12" y2="15" />
164
- </svg>
165
- Upload Content
166
- </div>
167
  </div>
 
168
 
169
- <div className="upload-body">
170
- <div className="tabs">
171
- {[['youtube', 'YouTube Video'], ['transcript', 'Article / Transcript'], ['file', 'Text File']].map(([key, label]) => (
172
- <button key={key} className={`tab ${activeTab === key ? 'active' : ''}`} onClick={() => setActiveTab(key)}>
173
- {label}
174
- </button>
175
- ))}
176
- </div>
 
 
 
 
 
 
 
 
 
 
177
 
178
  {/* YouTube */}
179
- <div className={`tab-panel ${activeTab === 'youtube' ? 'active' : ''}`}>
180
- <div className="form-group">
181
- <label className="form-label">YouTube URL</label>
182
  <input
183
- type="url" className="input"
184
- placeholder="https://www.youtube.com/watch?v=..."
 
185
  value={youtubeUrl}
186
  onChange={(e) => setYoutubeUrl(e.target.value)}
187
  onKeyDown={ctrlEnter}
188
  />
189
- <p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
190
  </div>
191
  <InlineResult
192
  error={ytStreaming.error}
@@ -199,24 +162,17 @@ function App() {
199
  />
200
  </div>
201
 
202
- {/* Transcript */}
203
- <div className={`tab-panel ${activeTab === 'transcript' ? 'active' : ''}`}>
204
- <div className="form-group">
205
- <label className="form-label">Article or Transcript Text</label>
206
  <textarea
207
  className="textarea"
208
- placeholder="Paste your article or transcript here..."
209
  value={transcript}
210
  onChange={(e) => setTranscript(e.target.value)}
211
  onKeyDown={ctrlEnter}
212
  />
213
- <p className="form-hint">
214
- Paste any text you want to summarize.{' '}
215
- <kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Ctrl</kbd>
216
- {' + '}
217
- <kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Enter</kbd>
218
- {' '}to generate.
219
- </p>
220
  </div>
221
  <InlineResult
222
  error={textStreaming.error}
@@ -229,35 +185,37 @@ function App() {
229
  />
230
  </div>
231
 
232
- {/* File upload */}
233
- <div className={`tab-panel ${activeTab === 'file' ? 'active' : ''}`}>
234
- <div className="form-group">
235
- <label className="form-label">Text File (.txt)</label>
236
- <div className="dropzone" onClick={() => fileInputRef.current?.click()} onDrop={handleFileDrop} onDragOver={(e) => e.preventDefault()}>
237
- <svg className="dropzone-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
 
 
 
 
 
238
  <path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
239
  </svg>
240
- <p className="dropzone-text">Drag and drop a <strong>.txt</strong> file here, or click to browse</p>
241
- <p className="dropzone-hint">Maximum file size: 10 MB</p>
242
  </div>
243
- <input ref={fileInputRef} type="file" className="file-input" accept=".txt" onChange={handleFileDrop} />
244
 
245
  {selectedFile && (
246
- <div className="file-selected">
247
- <div className="file-icon">
248
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
249
- <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
250
- <polyline points="14 2 14 8 20 8" />
251
- <line x1="16" y1="13" x2="8" y2="13" />
252
- <line x1="16" y1="17" x2="8" y2="17" />
253
- </svg>
254
- </div>
255
- <div className="file-info">
256
- <div className="file-name">{selectedFile.name}</div>
257
- <div className="file-size">{formatFileSize(selectedFile.size)}</div>
258
- </div>
259
- <button className="file-remove" onClick={(e) => { e.stopPropagation(); setSelectedFile(null) }}>
260
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
261
  <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
262
  </svg>
263
  </button>
@@ -274,37 +232,58 @@ function App() {
274
  placeholderText="Reading file…"
275
  />
276
  </div>
 
277
 
278
- <div className="submit-section">
279
- {active.loading && (
280
- <button className="btn btn-cancel" onClick={active.cancel}>
281
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
282
- <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  </svg>
284
- Cancel
285
- </button>
286
  )}
287
- <button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={active.loading}>
288
- {active.loading ? (
289
- <><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
290
- ) : (
291
- <>
292
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
293
- <path d="M22 2L11 13" /><path d="M22 2L15 22l-4-9-9-4L22 2z" />
294
- </svg>
295
- Generate Summary
296
- </>
297
- )}
298
- </button>
299
- </div>
300
  </div>
 
301
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  </div>
303
  </div>
304
  </main>
305
 
306
  <footer className="footer">
307
- <p>Précis © 2026 · <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer">API Documentation</a></p>
 
308
  </footer>
309
  </>
310
  )
 
12
  const [selectedFile, setSelectedFile] = useState(null)
13
  const [models, setModels] = useState([])
14
  const [selectedModel, setSelectedModel] = useState('')
15
+ const [modelReady, setModelReady] = useState(null)
16
  const fileInputRef = useRef(null)
17
  const warmupAbortRef = useRef(null)
18
 
 
19
  const ytStreaming = useStreaming()
20
  const textStreaming = useStreaming()
21
  const fileStreaming = useStreaming()
 
31
  if (!res.ok) return
32
  const data = await res.json()
33
  if (cancelled) return
 
34
  const available = Array.isArray(data.available) ? data.available : []
35
  setModels(available)
 
36
  const serverDefault = typeof data.default === 'string' ? data.default : ''
37
  setSelectedModel((prev) => prev || serverDefault || available[0] || '')
38
+ } catch { /* non-fatal */ }
 
 
39
  })()
40
  return () => { cancelled = true }
41
  }, [])
42
 
 
 
 
43
  useEffect(() => {
44
  if (!selectedModel) return
 
 
45
  if (warmupAbortRef.current) warmupAbortRef.current.abort()
46
  const controller = new AbortController()
47
  warmupAbortRef.current = controller
 
48
  setModelReady(false)
49
 
 
50
  let pollTimer = null
51
  const poll = async () => {
52
  if (controller.signal.aborted) return
 
57
  )
58
  if (r.ok) {
59
  const data = await r.json()
60
+ if (data.loaded) { setModelReady(true); return }
 
 
 
61
  }
62
  } catch { /* ignore */ }
63
+ if (!controller.signal.aborted) pollTimer = setTimeout(poll, 2000)
 
 
64
  }
65
  poll()
66
 
 
67
  fetch(`${API_BASE}/warmup`, {
68
  method: 'POST',
69
  headers: { 'Content-Type': 'application/json' },
70
  body: JSON.stringify({ model: selectedModel }),
71
  signal: controller.signal,
72
+ }).then(() => setModelReady(true)).catch(() => {})
 
 
73
 
74
+ return () => { controller.abort(); clearTimeout(pollTimer) }
 
 
 
75
  }, [selectedModel])
76
 
77
  const handleSubmit = () =>
78
  active.submit(activeTab, {
79
+ youtubeUrl, transcript, selectedFile,
 
 
80
  selectedModel: selectedModel || undefined,
81
  })
82
 
 
98
  if (e.key === 'Enter' && e.ctrlKey && !active.loading) handleSubmit()
99
  }
100
 
101
+ const TABS = [
102
+ { key: 'youtube', label: 'YouTube' },
103
+ { key: 'transcript', label: 'Text' },
104
+ { key: 'file', label: 'File' },
105
+ ]
106
+
107
  return (
108
  <>
109
+ {/* ── Main ── */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  <main className="main">
111
  <div className="container">
112
+ <div className="content-layout">
 
 
 
 
113
 
114
+ {/* Hero strip */}
115
+ <div className="hero">
116
+ <div className="logo hero-logo">
117
+ <img src={logoSvg} alt="" className="logo-icon" />
118
+ <span className="logo-text">Précis</span>
 
 
 
 
 
119
  </div>
120
+ </div>
121
 
122
+ {/* Card */}
123
+ <div className="card-wrap fade-in">
124
+
125
+ {/* Segmented tab control */}
126
+ <div className="seg-control">
127
+ {TABS.map(({ key, label }) => (
128
+ <button
129
+ key={key}
130
+ className={`seg-btn${activeTab === key ? ' seg-btn--active' : ''}`}
131
+ onClick={() => setActiveTab(key)}
132
+ >
133
+ {label}
134
+ </button>
135
+ ))}
136
+ </div>
137
+
138
+ {/* Panels */}
139
+ <div className="panels">
140
 
141
  {/* YouTube */}
142
+ <div className={`panel${activeTab === 'youtube' ? ' panel--active' : ''}`}>
143
+ <div className="field">
144
+ <label className="field-label">YouTube URL</label>
145
  <input
146
+ type="url"
147
+ className="input input--lg"
148
+ placeholder="https://www.youtube.com/watch?v=…"
149
  value={youtubeUrl}
150
  onChange={(e) => setYoutubeUrl(e.target.value)}
151
  onKeyDown={ctrlEnter}
152
  />
 
153
  </div>
154
  <InlineResult
155
  error={ytStreaming.error}
 
162
  />
163
  </div>
164
 
165
+ {/* Text / Transcript */}
166
+ <div className={`panel${activeTab === 'transcript' ? ' panel--active' : ''}`}>
167
+ <div className="field">
168
+ <label className="field-label">Article or transcript</label>
169
  <textarea
170
  className="textarea"
171
+ placeholder="Paste your article or transcript here"
172
  value={transcript}
173
  onChange={(e) => setTranscript(e.target.value)}
174
  onKeyDown={ctrlEnter}
175
  />
 
 
 
 
 
 
 
176
  </div>
177
  <InlineResult
178
  error={textStreaming.error}
 
185
  />
186
  </div>
187
 
188
+ {/* File */}
189
+ <div className={`panel${activeTab === 'file' ? ' panel--active' : ''}`}>
190
+ <div className="field">
191
+ <label className="field-label">Text file <span className="field-label-hint">(.txt)</span></label>
192
+ <div
193
+ className="dropzone"
194
+ onClick={() => fileInputRef.current?.click()}
195
+ onDrop={handleFileDrop}
196
+ onDragOver={(e) => e.preventDefault()}
197
+ >
198
+ <svg className="dropzone-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.3">
199
  <path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
200
  </svg>
201
+ <span className="dropzone-label">.txt</span>
 
202
  </div>
203
+ <input ref={fileInputRef} type="file" style={{ display: 'none' }} accept=".txt" onChange={handleFileDrop} />
204
 
205
  {selectedFile && (
206
+ <div className="file-chip">
207
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
208
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
209
+ <polyline points="14 2 14 8 20 8" />
210
+ </svg>
211
+ <span className="file-chip__name">{selectedFile.name}</span>
212
+ <span className="file-chip__size">{formatFileSize(selectedFile.size)}</span>
213
+ <button
214
+ className="file-chip__remove"
215
+ onClick={(e) => { e.stopPropagation(); setSelectedFile(null) }}
216
+ aria-label="Remove file"
217
+ >
218
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
 
 
219
  <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
220
  </svg>
221
  </button>
 
232
  placeholderText="Reading file…"
233
  />
234
  </div>
235
+ </div>
236
 
237
+ {/* Action row */}
238
+ <div className="action-row">
239
+ {active.loading && (
240
+ <button className="btn btn-cancel" onClick={active.cancel}>
241
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
242
+ <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
243
+ </svg>
244
+ Cancel
245
+ </button>
246
+ )}
247
+ <button
248
+ className="btn btn-primary btn-generate"
249
+ onClick={handleSubmit}
250
+ disabled={active.loading}
251
+ >
252
+ {active.loading ? (
253
+ <><span className="loading-spinner" style={{ width: 15, height: 15 }} /> Processing…</>
254
+ ) : (
255
+ <>
256
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
257
+ <path d="M22 2L11 13" /><path d="M22 2L15 22l-4-9-9-4L22 2z" />
258
  </svg>
259
+ Generate
260
+ </>
261
  )}
262
+ </button>
263
+ <span className="action-hint">or <kbd>Ctrl</kbd>+<kbd>Enter</kbd></span>
 
 
 
 
 
 
 
 
 
 
 
264
  </div>
265
+
266
  </div>
267
+
268
+ {/* Model Selector at bottom right, outside card-wrap */}
269
+ <div className="model-selector-wrap">
270
+ <select
271
+ className={`model-select${modelReady === true ? ' model-select--ready' : modelReady === false ? ' model-select--warming' : ''}`}
272
+ value={selectedModel}
273
+ onChange={(e) => setSelectedModel(e.target.value)}
274
+ disabled={active.loading || models.length === 0}
275
+ >
276
+ {models.map((m) => <option key={m} value={m}>{m}</option>)}
277
+ </select>
278
+ </div>
279
+
280
  </div>
281
  </div>
282
  </main>
283
 
284
  <footer className="footer">
285
+ <span className="footer-sep">·</span>
286
+ <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer">API Docs</a>
287
  </footer>
288
  </>
289
  )
frontend/src/components/InlineResult.jsx CHANGED
@@ -5,39 +5,42 @@ export default function InlineResult({ error, loading, response, streamingText,
5
  <>
6
  {error && (
7
  <div className="inline-result inline-result--error fade-in">
8
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
9
  <circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
10
  </svg>
11
  {error}
12
  </div>
13
  )}
 
14
  {loading && (
15
  <div className="inline-result inline-result--streaming fade-in">
16
  <div className="inline-result__label">
17
- <span className="loading-spinner" style={{ width: 12, height: 12 }} />
18
- {streamingText ? 'Generating' : (loadingLabel || 'Processing')}
19
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
20
  </div>
21
- <div className="inline-result__text inline-result__text--streaming">
22
  {streamingText ? (
23
  <ReactMarkdown>{streamingText}</ReactMarkdown>
24
  ) : (
25
- <span className="streaming-placeholder">
26
- {placeholderText || loadingLabel || 'Waiting for model…'}
27
- </span>
28
  )}
29
  <span className="streaming-cursor">▌</span>
30
  </div>
31
  </div>
32
  )}
 
33
  {response && !loading && (
34
  <div className="inline-result inline-result--success fade-in">
35
  <div className="inline-result__label">
36
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
37
  <polyline points="20 6 9 17 4 12" />
38
  </svg>
39
  Summary
40
- <span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
 
 
 
41
  </div>
42
  <div className="inline-result__text">
43
  <ReactMarkdown>{response.summary}</ReactMarkdown>
 
5
  <>
6
  {error && (
7
  <div className="inline-result inline-result--error fade-in">
8
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ flexShrink: 0 }}>
9
  <circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
10
  </svg>
11
  {error}
12
  </div>
13
  )}
14
+
15
  {loading && (
16
  <div className="inline-result inline-result--streaming fade-in">
17
  <div className="inline-result__label">
18
+ <span className="loading-spinner" style={{ width: 11, height: 11, borderWidth: 1.5 }} />
19
+ {streamingText ? 'Generating' : (loadingLabel || 'Processing')}
20
  <span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
21
  </div>
22
+ <div className="inline-result__text">
23
  {streamingText ? (
24
  <ReactMarkdown>{streamingText}</ReactMarkdown>
25
  ) : (
26
+ <span className="streaming-placeholder">{placeholderText || loadingLabel || 'Waiting…'}</span>
 
 
27
  )}
28
  <span className="streaming-cursor">▌</span>
29
  </div>
30
  </div>
31
  )}
32
+
33
  {response && !loading && (
34
  <div className="inline-result inline-result--success fade-in">
35
  <div className="inline-result__label">
36
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
37
  <polyline points="20 6 9 17 4 12" />
38
  </svg>
39
  Summary
40
+ {response.cancelled && (
41
+ <span style={{ fontWeight: 400, opacity: 0.6, textTransform: 'none', letterSpacing: 0, marginLeft: 4 }}>· partial</span>
42
+ )}
43
+ <span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? selectedModel}</span>
44
  </div>
45
  <div className="inline-result__text">
46
  <ReactMarkdown>{response.summary}</ReactMarkdown>
frontend/src/index.css CHANGED
@@ -1,6 +1,8 @@
 
 
1
  :root {
2
  /* GitHub-y colours for now */
3
- --color-canvas-default: #0d1117;
4
  --color-canvas-subtle: #161b22;
5
  --color-canvas-inset: #010409;
6
  --color-border-default: #30363d;
@@ -23,7 +25,7 @@
23
  --color-btn-primary-hover-bg: #2ea043;
24
 
25
  /* Typography */
26
- --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
27
  --font-mono: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
28
 
29
  /* Spacing */
@@ -158,11 +160,11 @@ body {
158
  /* Textarea styles */
159
  .textarea {
160
  width: 100%;
161
- min-height: 150px;
162
- padding: 12px;
163
- font-size: 14px;
164
- font-family: var(--font-mono);
165
- line-height: 1.6;
166
  color: var(--color-fg-default);
167
  background-color: var(--color-canvas-default);
168
  border: 1px solid var(--color-border-default);
@@ -184,6 +186,7 @@ body {
184
  align-items: center;
185
  justify-content: center;
186
  gap: var(--spacing-3);
 
187
  padding: var(--spacing-6);
188
  border: 2px dashed var(--color-border-default);
189
  border-radius: var(--radius-2);
@@ -215,35 +218,6 @@ body {
215
  color: var(--color-fg-subtle);
216
  }
217
 
218
- /* Tabs */
219
- .tabs {
220
- display: flex;
221
- border-bottom: 1px solid var(--color-border-default);
222
- margin-bottom: var(--spacing-4);
223
- }
224
-
225
- .tab {
226
- padding: var(--spacing-2) var(--spacing-3);
227
- font-size: 14px;
228
- font-weight: 500;
229
- color: var(--color-fg-muted);
230
- background: transparent;
231
- border: none;
232
- border-bottom: 2px solid transparent;
233
- cursor: pointer;
234
- transition: color var(--transition-fast), border-color var(--transition-fast);
235
- margin-bottom: -1px;
236
- }
237
-
238
- .tab:hover {
239
- color: var(--color-fg-default);
240
- }
241
-
242
- .tab.active {
243
- color: var(--color-fg-default);
244
- border-bottom-color: var(--color-accent-fg);
245
- }
246
-
247
  /* Animations */
248
  @keyframes fadeIn {
249
  from {
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
2
+
3
  :root {
4
  /* GitHub-y colours for now */
5
+ --color-canvas-default: #0b0f15;
6
  --color-canvas-subtle: #161b22;
7
  --color-canvas-inset: #010409;
8
  --color-border-default: #30363d;
 
25
  --color-btn-primary-hover-bg: #2ea043;
26
 
27
  /* Typography */
28
+ --font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
29
  --font-mono: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
30
 
31
  /* Spacing */
 
160
  /* Textarea styles */
161
  .textarea {
162
  width: 100%;
163
+ min-height: 280px;
164
+ padding: 16px;
165
+ font-size: 15px;
166
+ font-family: inherit;
167
+ line-height: 1.7;
168
  color: var(--color-fg-default);
169
  background-color: var(--color-canvas-default);
170
  border: 1px solid var(--color-border-default);
 
186
  align-items: center;
187
  justify-content: center;
188
  gap: var(--spacing-3);
189
+ min-height: 200px;
190
  padding: var(--spacing-6);
191
  border: 2px dashed var(--color-border-default);
192
  border-radius: var(--radius-2);
 
218
  color: var(--color-fg-subtle);
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  /* Animations */
222
  @keyframes fadeIn {
223
  from {