wepee commited on
Commit
b7231b5
·
1 Parent(s): 909b61e

add: doc for iframe integration

Browse files
README.md CHANGED
@@ -9,3 +9,375 @@
9
  ---
10
 
11
  # CAPS04 FE REFACTOR
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  ---
10
 
11
  # CAPS04 FE REFACTOR
12
+
13
+ ---
14
+
15
+ ## Embed Widget — Integration Guide
16
+
17
+ RAG Hub menyediakan widget chat berbasis `<iframe>` yang dapat diintegrasikan ke situs web pihak ketiga manapun. Widget ini mendukung autentikasi berbasis API Key, kustomisasi tampilan penuh melalui query string, serta komunikasi dua arah dengan halaman induk melalui `postMessage`.
18
+
19
+ ---
20
+
21
+ ### Table of Contents
22
+
23
+ 1. [Quick Start](#1-quick-start)
24
+ 2. [Authentication](#2-authentication)
25
+ 3. [URL Parameters Reference](#3-url-parameters-reference)
26
+ 4. [Theming & Customization](#4-theming--customization)
27
+ 5. [Chat Mode](#5-chat-mode)
28
+ 6. [postMessage Events](#6-postmessage-events)
29
+ 7. [Session Persistence](#7-session-persistence)
30
+ 8. [Full Integration Examples](#8-full-integration-examples)
31
+ 9. [Troubleshooting](#9-troubleshooting)
32
+
33
+ ---
34
+
35
+ ### 1. Quick Start
36
+
37
+ Tambahkan `<iframe>` berikut ke halaman HTML Anda:
38
+
39
+ ```html
40
+ <iframe
41
+ src="https://your-raghub-domain.com/embed?api_key=YOUR_API_KEY"
42
+ width="420"
43
+ height="680"
44
+ style="border: none; border-radius: 16px;"
45
+ allow="clipboard-write"
46
+ ></iframe>
47
+ ```
48
+
49
+ Ganti `YOUR_API_KEY` dengan API Key yang digenerate dari halaman **Admin → API Keys**.
50
+
51
+ ---
52
+
53
+ ### 2. Authentication
54
+
55
+ Widget mendukung dua metode autentikasi. Pilih salah satu sesuai kebutuhan.
56
+
57
+ #### 2.1 API Key (Direkomendasikan)
58
+
59
+ Gunakan parameter `api_key`. Widget akan secara otomatis menukar API Key dengan bearer token melalui endpoint `/auth/login/api-key` saat pertama kali dimuat.
60
+
61
+ ```
62
+ /embed?api_key=sk-xxxxxxxxxxxxxxxx
63
+ ```
64
+
65
+ Token hasil login disimpan di `sessionStorage` browser sehingga tidak perlu login ulang selama sesi tab masih aktif.
66
+
67
+ > **Keamanan:** API Key sebaiknya tidak di-hardcode langsung di HTML publik. Untuk lingkungan produksi, generate token di sisi server lalu teruskan lewat metode **Direct Token** (lihat 2.2).
68
+
69
+ #### 2.2 Direct Token
70
+
71
+ Jika server Anda sudah memiliki bearer token yang valid, teruskan langsung melalui parameter `token`:
72
+
73
+ ```
74
+ /embed?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
75
+ ```
76
+
77
+ Token akan langsung disimpan ke `sessionStorage` dan digunakan untuk semua request selanjutnya.
78
+
79
+ #### Token Resolution Priority
80
+
81
+ Widget menyelesaikan autentikasi dengan urutan berikut:
82
+
83
+ 1. Cek `sessionStorage` — jika token sudah tersimpan dari sesi sebelumnya, langsung digunakan.
84
+ 2. Cek parameter `?token` di URL.
85
+ 3. Cek parameter `?api_key` di URL, lalu tukar dengan token via API.
86
+ 4. Jika tidak ada ketiganya → widget menampilkan pesan error autentikasi.
87
+
88
+ ---
89
+
90
+ ### 3. URL Parameters Reference
91
+
92
+ Semua konfigurasi dilakukan melalui query string pada URL `src` iframe.
93
+
94
+ #### 3.1 Authentication
95
+
96
+ | Parameter | Tipe | Keterangan |
97
+ |-----------|----------|------------|
98
+ | `api_key` | `string` | API Key dari Admin Panel. Widget otomatis login dan menyimpan token. |
99
+ | `token` | `string` | Bearer token langsung. Digunakan jika server sudah generate token sendiri. |
100
+
101
+ #### 3.2 Behavior
102
+
103
+ | Parameter | Tipe | Default | Keterangan |
104
+ |--------------|----------|---------|------------|
105
+ | `course_id` | `string` | — | Pra-pilih mata kuliah tertentu. Widget langsung menggunakan course ini tanpa perlu user memilih. |
106
+ | `session_id` | `string` | — | Buka riwayat sesi chat tertentu langsung saat widget dimuat. |
107
+ | `user_name` | `string` | `User` | Nama yang ditampilkan di greeting ("Good Morning, ..."). Jika tidak diisi, diambil dari data user saat login via `api_key`. |
108
+
109
+ #### 3.3 Appearance
110
+
111
+ | Parameter | Tipe | Default | Keterangan |
112
+ |--------------|----------|---------|------------|
113
+ | `title` | `string` | `RAG Hub` | Judul widget (tab browser & empty state). |
114
+ | `subtitle` | `string` | *(default text)* | Teks deskripsi di bawah judul pada empty state. |
115
+
116
+ ---
117
+
118
+ ### 4. Theming & Customization
119
+
120
+ Semua warna dikustomisasi via query string. Format nilai warna: hex 3-digit (`#fff`) atau 6-digit (`#ffffff`), dengan atau tanpa `#` di depan.
121
+
122
+ Format radius: nilai numerik diikuti unit `px` atau `rem` (maks. 2 digit), contoh: `20px`, `1rem`.
123
+
124
+ | Parameter | Default | Keterangan |
125
+ |----------------------|-------------|------------|
126
+ | `primary` | `#2764eb` | Warna utama — tombol kirim, avatar asisten, border composer. |
127
+ | `primary_gradient` | `#ccdcff` | Warna lembut primary — background avatar user & aksen gradient. |
128
+ | `background` | `#ffffff` | Background utama widget. |
129
+ | `background_profile` | `#ffffff` | Background bubble chat asisten dan composer. |
130
+ | `text_primary` | `#0a0a0a` | Warna teks utama. |
131
+ | `text_secondary` | `#737373` | Warna teks sekunder / placeholder. |
132
+ | `border` | `#739af1` | Warna border composer dan bubble. |
133
+ | `logout` | `#ef4444` | Warna aksi destruktif (tombol hapus sesi). |
134
+ | `radius` | `20px` | Border radius bubble chat dan composer. |
135
+
136
+ **Contoh URL dengan tema gelap:**
137
+
138
+ ```
139
+ /embed?api_key=YOUR_KEY
140
+ &background=1a1a2e
141
+ &background_profile=16213e
142
+ &text_primary=e0e0e0
143
+ &text_secondary=8892b0
144
+ &primary=4f8ef7
145
+ &primary_gradient=1e3a6e
146
+ &border=2a3f6f
147
+ &logout=ff6b6b
148
+ &radius=16px
149
+ ```
150
+
151
+ ---
152
+
153
+ ### 5. Chat Mode
154
+
155
+ Chat mode dikonfigurasi di sisi **server** (bukan via URL), melalui environment variable `RAG_CHAT_MODE`.
156
+
157
+ | Mode | Keterangan |
158
+ |------------|------------|
159
+ | `standard` | *(default)* Chat berbasis sesi. Riwayat percakapan tersimpan dan bisa diakses kembali via tab Riwayat. |
160
+ | `direct` | Setiap pesan dikirim langsung ke endpoint `/ai/query` tanpa membuat sesi. Cocok untuk one-shot Q&A tanpa history. |
161
+
162
+ Atur di file `.env`:
163
+
164
+ ```env
165
+ RAG_CHAT_MODE=direct
166
+ ```
167
+
168
+ ---
169
+
170
+ ### 6. postMessage Events
171
+
172
+ Widget berkomunikasi dengan halaman induk melalui `window.postMessage`. Dengarkan event ini di halaman yang meng-embed iframe untuk mendapat notifikasi dari widget.
173
+
174
+ #### 6.1 Events yang Dikirim Widget (Outbound)
175
+
176
+ | Event type | Kapan dikirim |
177
+ |-----------------------------|---------------|
178
+ | `sevima-raghub:close` | User menekan tombol **✕** di sudut kanan atas widget. |
179
+
180
+ **Contoh listener di halaman induk:**
181
+
182
+ ```javascript
183
+ window.addEventListener('message', (event) => {
184
+ // Selalu validasi origin di produksi
185
+ if (event.origin !== 'https://your-raghub-domain.com') return;
186
+
187
+ if (event.data?.type === 'sevima-raghub:close') {
188
+ // Sembunyikan atau hancurkan iframe
189
+ document.getElementById('raghub-iframe').style.display = 'none';
190
+ }
191
+ });
192
+ ```
193
+
194
+ ---
195
+
196
+ ### 7. Session Persistence
197
+
198
+ Widget menyimpan data sesi di `sessionStorage` browser (bukan `localStorage`), sehingga **otomatis terhapus saat tab ditutup**.
199
+
200
+ | Key | Isi |
201
+ |------------------------------|-----|
202
+ | `sevima_raghub_embed_token` | Bearer token aktif. |
203
+ | `sevima_raghub_embed_user` | Objek user JSON (`id`, `name`, `email`, `role`). |
204
+
205
+ Karena berbasis `sessionStorage` pada iframe, data ini terisolasi dan tidak dapat diakses oleh halaman induk.
206
+
207
+ Untuk memaksa sesi baru (logout), reload iframe atau hapus `sessionStorage` dari dalam iframe.
208
+
209
+ ---
210
+
211
+ ### 8. Full Integration Examples
212
+
213
+ #### 8.1 Embed Sederhana (API Key)
214
+
215
+ ```html
216
+ <!DOCTYPE html>
217
+ <html lang="id">
218
+ <head>
219
+ <meta charset="UTF-8" />
220
+ <title>My App</title>
221
+ </head>
222
+ <body>
223
+
224
+ <iframe
225
+ id="raghub-widget"
226
+ src="https://your-raghub-domain.com/embed?api_key=sk-xxxx&user_name=Budi&title=Asisten+Akademik"
227
+ width="420"
228
+ height="680"
229
+ style="border: none; border-radius: 20px; box-shadow: 0 8px 32px rgba(0,0,0,0.15);"
230
+ allow="clipboard-write"
231
+ ></iframe>
232
+
233
+ <script>
234
+ window.addEventListener('message', (event) => {
235
+ if (event.origin !== 'https://your-raghub-domain.com') return;
236
+ if (event.data?.type === 'sevima-raghub:close') {
237
+ document.getElementById('raghub-widget').style.display = 'none';
238
+ }
239
+ });
240
+ </script>
241
+
242
+ </body>
243
+ </html>
244
+ ```
245
+
246
+ #### 8.2 Floating Chat Button
247
+
248
+ ```html
249
+ <style>
250
+ #raghub-fab {
251
+ position: fixed;
252
+ bottom: 24px;
253
+ right: 24px;
254
+ width: 56px;
255
+ height: 56px;
256
+ border-radius: 50%;
257
+ background: #2764eb;
258
+ border: none;
259
+ cursor: pointer;
260
+ color: #fff;
261
+ font-size: 24px;
262
+ box-shadow: 0 4px 16px rgba(39,100,235,0.4);
263
+ z-index: 1000;
264
+ }
265
+
266
+ #raghub-container {
267
+ display: none;
268
+ position: fixed;
269
+ bottom: 96px;
270
+ right: 24px;
271
+ width: 420px;
272
+ height: 680px;
273
+ z-index: 1000;
274
+ border-radius: 20px;
275
+ overflow: hidden;
276
+ box-shadow: 0 12px 48px rgba(0,0,0,0.2);
277
+ }
278
+
279
+ #raghub-container iframe {
280
+ width: 100%;
281
+ height: 100%;
282
+ border: none;
283
+ }
284
+ </style>
285
+
286
+ <button id="raghub-fab" onclick="toggleWidget()">💬</button>
287
+
288
+ <div id="raghub-container">
289
+ <iframe
290
+ id="raghub-iframe"
291
+ src="https://your-raghub-domain.com/embed?api_key=sk-xxxx&primary=2764eb&radius=16px"
292
+ allow="clipboard-write"
293
+ ></iframe>
294
+ </div>
295
+
296
+ <script>
297
+ function toggleWidget() {
298
+ const container = document.getElementById('raghub-container');
299
+ container.style.display = container.style.display === 'none' ? 'block' : 'none';
300
+ }
301
+
302
+ window.addEventListener('message', (event) => {
303
+ if (event.origin !== 'https://your-raghub-domain.com') return;
304
+ if (event.data?.type === 'sevima-raghub:close') {
305
+ document.getElementById('raghub-container').style.display = 'none';
306
+ }
307
+ });
308
+ </script>
309
+ ```
310
+
311
+ #### 8.3 Pra-pilih Mata Kuliah & Langsung ke Sesi Tertentu
312
+
313
+ ```html
314
+ <iframe
315
+ src="https://your-raghub-domain.com/embed
316
+ ?api_key=sk-xxxx
317
+ &course_id=COURSE_UUID
318
+ &session_id=SESSION_UUID
319
+ &user_name=Mahasiswa+A"
320
+ width="420"
321
+ height="680"
322
+ style="border: none; border-radius: 20px;"
323
+ ></iframe>
324
+ ```
325
+
326
+ #### 8.4 Token dari Server (Lebih Aman untuk Produksi)
327
+
328
+ Alur yang direkomendasikan untuk produksi: server Anda meminta token ke RAG Hub API menggunakan API Key, lalu meneruskan token tersebut ke frontend.
329
+
330
+ ```javascript
331
+ // server-side (Node.js / PHP / Python, dll.)
332
+ const response = await fetch('https://your-raghub-domain.com/api/auth/login/api-key', {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify({ api_key: process.env.RAG_API_KEY }),
336
+ });
337
+ const { token } = await response.json();
338
+
339
+ // Render ke template HTML
340
+ const iframeSrc = `https://your-raghub-domain.com/embed?token=${token}&user_name=${userName}`;
341
+ ```
342
+
343
+ ```html
344
+ <!-- HTML yang dirender server -->
345
+ <iframe src="{{ iframeSrc }}" width="420" height="680" style="border:none;"></iframe>
346
+ ```
347
+
348
+ ---
349
+
350
+ ### 9. Troubleshooting
351
+
352
+ #### Widget menampilkan "Token iframe tidak tersedia"
353
+
354
+ Pastikan salah satu dari berikut ada di URL:
355
+ - `?api_key=` — API Key valid dari Admin Panel.
356
+ - `?token=` — Bearer token yang belum expired.
357
+
358
+ Jika menggunakan `api_key`, pastikan key tersebut masih aktif dan belum direvoke.
359
+
360
+ #### Mata kuliah tidak muncul di dropdown
361
+
362
+ Pastikan akun yang terkait dengan `api_key` memiliki akses ke setidaknya satu course. Periksa di Admin Panel bahwa course sudah berstatus **active**.
363
+
364
+ #### Widget tidak merespons tombol ✕ di halaman induk
365
+
366
+ Pastikan listener `message` event sudah terpasang sebelum iframe dimuat, dan validasi `event.origin` sesuai domain RAG Hub.
367
+
368
+ #### Token expired di tengah sesi
369
+
370
+ Widget tidak melakukan refresh token otomatis. Jika token expired, reload iframe dengan token baru:
371
+
372
+ ```javascript
373
+ const iframe = document.getElementById('raghub-iframe');
374
+ iframe.src = `https://your-raghub-domain.com/embed?token=${newToken}`;
375
+ ```
376
+
377
+ Reload iframe akan menghapus `sessionStorage`-nya dan memulai sesi baru.
378
+
379
+ #### Warna tidak berubah setelah mengubah query string
380
+
381
+ Pastikan nilai warna dalam format hex yang valid: 3 atau 6 digit, dengan atau tanpa `#`. Karakter selain `0-9` dan `a-f` akan diabaikan dan diganti nilai default.
382
+
383
+ ---
app/Http/Controllers/Controller.php ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ <?php
2
+
3
+ namespace App\Http\Controllers;
4
+
5
+ abstract class Controller {}
pnpm-lock.yaml ADDED
The diff for this file is too large to render. See raw diff
 
resources/css/app.css CHANGED
@@ -1214,7 +1214,7 @@ @layer components {
1214
  }
1215
 
1216
  .embed-shell {
1217
- @apply relative flex min-h-svh flex-col overflow-hidden px-6 py-5;
1218
  background-color: var(--embed-bg);
1219
  color: var(--embed-text);
1220
  }
@@ -1233,7 +1233,7 @@ @layer components {
1233
  }
1234
 
1235
  .embed-tabs button {
1236
- @apply h-6 rounded-full px-6 text-xs font-medium transition;
1237
  color: var(--embed-text);
1238
  }
1239
 
@@ -1296,17 +1296,19 @@ @layer components {
1296
  }
1297
 
1298
  .embed-empty-state p {
1299
- @apply mt-2 text-xs;
1300
  color: var(--embed-muted);
1301
  }
1302
 
1303
  .embed-status {
1304
- @apply mt-4 text-center text-xs font-medium;
1305
  color: var(--embed-danger);
1306
  }
1307
 
1308
  .embed-thread {
1309
- @apply min-h-0 flex-1 space-y-5 overflow-y-auto px-0 pt-10;
 
 
1310
  padding-bottom: 11rem;
1311
  }
1312
 
@@ -1337,7 +1339,7 @@ @layer components {
1337
  }
1338
 
1339
  .embed-message-bubble {
1340
- @apply max-w-[min(290px,75%)] rounded-[10px] border px-4 py-3 text-xs shadow-none;
1341
  background-color: var(--embed-surface);
1342
  border-color: var(--embed-border);
1343
  color: var(--embed-text);
@@ -1358,7 +1360,7 @@ @layer components {
1358
  }
1359
 
1360
  .embed-message-time {
1361
- @apply mt-2 block text-[10px];
1362
  color: color-mix(in oklab, currentColor 68%, transparent);
1363
  }
1364
 
@@ -1389,7 +1391,7 @@ @layer components {
1389
  }
1390
 
1391
  .embed-course-row {
1392
- @apply flex flex-wrap items-center gap-2 text-[11px] font-bold;
1393
  color: var(--embed-text);
1394
  }
1395
 
@@ -1399,7 +1401,7 @@ @layer components {
1399
  }
1400
 
1401
  .embed-course-row select {
1402
- @apply h-6 max-w-[210px] rounded-[5px] border px-2 text-[10px] outline-none;
1403
  background-color: var(--embed-surface);
1404
  border-color: var(--embed-border);
1405
  color: var(--embed-text);
@@ -1423,7 +1425,7 @@ @layer components {
1423
  }
1424
 
1425
  .embed-question-row textarea {
1426
- @apply min-h-[72px] flex-1 resize-none bg-transparent text-xs outline-none;
1427
  color: var(--embed-text);
1428
  }
1429
 
@@ -1438,12 +1440,14 @@ @layer components {
1438
  }
1439
 
1440
  .embed-updated-at {
1441
- @apply mt-2 text-center text-[10px];
1442
  color: var(--embed-muted);
1443
  }
1444
 
1445
  .embed-history {
1446
- @apply flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto pt-7;
 
 
1447
  }
1448
 
1449
  .embed-history-item {
@@ -1460,6 +1464,7 @@ @layer components {
1460
  );
1461
  }
1462
 
 
1463
  .embed-history-item strong {
1464
  @apply block text-base font-semibold;
1465
  }
@@ -1484,6 +1489,52 @@ @layer components {
1484
  color: var(--embed-muted);
1485
  }
1486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1487
  .lecturer-dashboard-shell {
1488
  @apply min-h-svh;
1489
  background-color: var(--lecturer-bg);
 
1214
  }
1215
 
1216
  .embed-shell {
1217
+ @apply relative flex h-svh flex-col overflow-hidden px-6 py-5;
1218
  background-color: var(--embed-bg);
1219
  color: var(--embed-text);
1220
  }
 
1233
  }
1234
 
1235
  .embed-tabs button {
1236
+ @apply h-6 rounded-full px-6 text-sm font-medium transition;
1237
  color: var(--embed-text);
1238
  }
1239
 
 
1296
  }
1297
 
1298
  .embed-empty-state p {
1299
+ @apply mt-2 text-sm;
1300
  color: var(--embed-muted);
1301
  }
1302
 
1303
  .embed-status {
1304
+ @apply mt-4 text-center text-sm font-medium;
1305
  color: var(--embed-danger);
1306
  }
1307
 
1308
  .embed-thread {
1309
+ @apply min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain px-0 pt-10;
1310
+ margin-right: -1.5rem;
1311
+ padding-right: 1.5rem;
1312
  padding-bottom: 11rem;
1313
  }
1314
 
 
1339
  }
1340
 
1341
  .embed-message-bubble {
1342
+ @apply max-w-[min(290px,75%)] rounded-[10px] border px-4 py-3 text-sm shadow-none;
1343
  background-color: var(--embed-surface);
1344
  border-color: var(--embed-border);
1345
  color: var(--embed-text);
 
1360
  }
1361
 
1362
  .embed-message-time {
1363
+ @apply mt-2 block text-xs;
1364
  color: color-mix(in oklab, currentColor 68%, transparent);
1365
  }
1366
 
 
1391
  }
1392
 
1393
  .embed-course-row {
1394
+ @apply flex flex-wrap items-center gap-2 text-sm font-bold;
1395
  color: var(--embed-text);
1396
  }
1397
 
 
1401
  }
1402
 
1403
  .embed-course-row select {
1404
+ @apply h-6 max-w-[210px] rounded-[5px] border px-2 text-xs outline-none;
1405
  background-color: var(--embed-surface);
1406
  border-color: var(--embed-border);
1407
  color: var(--embed-text);
 
1425
  }
1426
 
1427
  .embed-question-row textarea {
1428
+ @apply min-h-[72px] flex-1 resize-none bg-transparent text-sm outline-none;
1429
  color: var(--embed-text);
1430
  }
1431
 
 
1440
  }
1441
 
1442
  .embed-updated-at {
1443
+ @apply mt-2 text-center text-xs;
1444
  color: var(--embed-muted);
1445
  }
1446
 
1447
  .embed-history {
1448
+ @apply flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto overscroll-contain pt-7;
1449
+ margin-right: -1.5rem;
1450
+ padding-right: 1.5rem;
1451
  }
1452
 
1453
  .embed-history-item {
 
1464
  );
1465
  }
1466
 
1467
+
1468
  .embed-history-item strong {
1469
  @apply block text-base font-semibold;
1470
  }
 
1489
  color: var(--embed-muted);
1490
  }
1491
 
1492
+ .embed-modal-backdrop {
1493
+ @apply absolute inset-0 z-50 flex items-center justify-center p-6;
1494
+ background-color: color-mix(in oklab, var(--embed-text) 30%, transparent);
1495
+ }
1496
+
1497
+ .embed-modal {
1498
+ @apply w-full max-w-xs rounded-2xl p-5 shadow-xl;
1499
+ background-color: var(--embed-surface);
1500
+ color: var(--embed-text);
1501
+ }
1502
+
1503
+ .embed-modal-title {
1504
+ @apply mb-1 text-sm font-semibold;
1505
+ }
1506
+
1507
+ .embed-modal-body {
1508
+ @apply mb-4 text-sm;
1509
+ color: var(--embed-muted);
1510
+ }
1511
+
1512
+ .embed-modal-actions {
1513
+ @apply flex justify-end gap-2;
1514
+ }
1515
+
1516
+ .embed-modal-btn {
1517
+ @apply rounded-full px-4 py-1.5 text-xs font-medium transition;
1518
+ }
1519
+
1520
+ .embed-modal-btn--cancel {
1521
+ background-color: color-mix(in oklab, var(--embed-muted) 15%, transparent);
1522
+ color: var(--embed-text);
1523
+ }
1524
+
1525
+ .embed-modal-btn--cancel:hover {
1526
+ background-color: color-mix(in oklab, var(--embed-muted) 25%, transparent);
1527
+ }
1528
+
1529
+ .embed-modal-btn--danger {
1530
+ background-color: var(--embed-danger);
1531
+ color: #fff;
1532
+ }
1533
+
1534
+ .embed-modal-btn--danger:hover {
1535
+ opacity: 0.88;
1536
+ }
1537
+
1538
  .lecturer-dashboard-shell {
1539
  @apply min-h-svh;
1540
  background-color: var(--lecturer-bg);
resources/js/pages/embed.tsx CHANGED
@@ -35,16 +35,16 @@ import {
35
 
36
  type EmbedConfig = {
37
  background: string;
 
38
  border: string;
39
  courseId?: string | null;
40
- danger: string;
41
- muted: string;
42
  primary: string;
43
- primarySoft: string;
44
  radius: string;
45
  subtitle: string;
46
- surface: string;
47
- text: string;
48
  title: string;
49
  userName: string;
50
  };
@@ -55,16 +55,22 @@ type EmbedPageProps = {
55
 
56
  const COLOR_DEFAULTS = {
57
  background: '#ffffff',
 
58
  border: '#739af1',
59
- danger: '#ef4444',
60
- muted: '#737373',
61
  primary: '#2764eb',
62
- primarySoft: '#ccdcff',
63
- surface: '#ffffff',
64
- text: '#0a0a0a',
65
  } as const;
66
 
67
  const EMBED_TOKEN_KEY = 'sevima_raghub_embed_token';
 
 
 
 
 
 
68
 
69
  function parseColor(value: string | null, fallback: string): string {
70
  if (!value) {
@@ -89,16 +95,16 @@ function parseRadius(value: string | null): string {
89
  function parseEmbedConfig(params: URLSearchParams): EmbedConfig {
90
  return {
91
  background: parseColor(params.get('background'), COLOR_DEFAULTS.background),
 
92
  border: parseColor(params.get('border'), COLOR_DEFAULTS.border),
93
  courseId: params.get('course_id') ?? params.get('courseId') ?? undefined,
94
- danger: parseColor(params.get('danger'), COLOR_DEFAULTS.danger),
95
- muted: parseColor(params.get('muted'), COLOR_DEFAULTS.muted),
96
  primary: parseColor(params.get('primary'), COLOR_DEFAULTS.primary),
97
- primarySoft: parseColor(params.get('primary_soft'), COLOR_DEFAULTS.primarySoft),
98
  radius: parseRadius(params.get('radius')),
99
  subtitle: params.get('subtitle') ?? 'Pilih mata kuliah, lalu kirim pertanyaan pertama untuk membuat sesi baru',
100
- surface: parseColor(params.get('surface'), COLOR_DEFAULTS.surface),
101
- text: parseColor(params.get('text'), COLOR_DEFAULTS.text),
102
  title: params.get('title') ?? 'RAG Hub',
103
  userName: params.get('user_name') ?? 'User',
104
  };
@@ -112,35 +118,50 @@ function getStoredEmbedToken(): string | null {
112
  }
113
  }
114
 
115
- function storeEmbedToken(token: string): void {
 
 
 
 
 
 
 
 
 
116
  try {
117
  sessionStorage.setItem(EMBED_TOKEN_KEY, token);
 
 
 
 
118
  } catch {}
119
  }
120
 
121
- async function resolveEmbedToken(params: URLSearchParams): Promise<string | null> {
 
 
122
  const stored = getStoredEmbedToken();
123
 
124
  if (stored) {
125
- return stored;
126
  }
127
 
128
  const tokenParam = params.get('token');
129
 
130
  if (tokenParam) {
131
- storeEmbedToken(tokenParam);
132
 
133
- return tokenParam;
134
  }
135
 
136
  const apiKey = params.get('api_key');
137
 
138
  if (apiKey) {
139
  try {
140
- const { token } = await loginWithEmbedApiKey(apiKey);
141
- storeEmbedToken(token);
142
 
143
- return token;
144
  } catch {
145
  return null;
146
  }
@@ -347,6 +368,44 @@ function EmbedComposer({
347
  );
348
  }
349
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  function EmbedHistory({
351
  activeSessionId,
352
  chatSessions,
@@ -426,6 +485,9 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
426
  );
427
  const [question, setQuestion] = useState('');
428
  const [isProcessing, setIsProcessing] = useState(false);
 
 
 
429
  const [backendMessage, setBackendMessage] = useState<string | undefined>();
430
  const [activeTab, setActiveTab] = useState<'chat' | 'history'>('chat');
431
  const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
@@ -440,13 +502,13 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
440
  const rootStyle = {
441
  '--embed-bg': config.background,
442
  '--embed-border': config.border,
443
- '--embed-danger': config.danger,
444
- '--embed-muted': config.muted,
445
  '--embed-primary': config.primary,
446
- '--embed-primary-soft': config.primarySoft,
447
  '--embed-radius': config.radius,
448
- '--embed-surface': config.surface,
449
- '--embed-text': config.text,
450
  } as CSSProperties;
451
 
452
  useEffect(() => {
@@ -458,22 +520,28 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
458
  setSelectedCourseId(parsedConfig.courseId ?? '');
459
 
460
  void (async () => {
461
- const resolvedToken = await resolveEmbedToken(params);
 
462
 
463
- if (!resolvedToken) {
464
- setBackendMessage('Token iframe tidak tersedia.');
465
 
466
- return;
467
- }
468
 
469
- setToken(resolvedToken);
470
- setIsAuthorized(true);
 
 
 
 
 
 
471
 
472
- try {
473
  const [coursesResult, sessionsResult] = await Promise.all([
474
- listCoursesEmbed(resolvedToken),
475
  listChatSessionsEmbed(
476
- resolvedToken,
477
  parsedConfig.courseId ?? undefined,
478
  ),
479
  ]);
@@ -490,7 +558,7 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
490
  setSessionId(parsedSessionId);
491
  const historyResult = await getChatHistoryEmbed(
492
  parsedSessionId,
493
- resolvedToken,
494
  );
495
  setMessages(historyResult.data);
496
  }
@@ -498,6 +566,8 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
498
  setBackendMessage(
499
  err instanceof Error ? err.message : 'Gagal memuat data.',
500
  );
 
 
501
  }
502
  })();
503
  }, []);
@@ -600,6 +670,7 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
600
  setSessionId(targetSessionId);
601
  setMessages([]);
602
  setOptimisticMessages([]);
 
603
 
604
  try {
605
  const result = await getChatHistoryEmbed(targetSessionId, token);
@@ -608,21 +679,34 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
608
  setBackendMessage(
609
  err instanceof Error ? err.message : 'Gagal memuat riwayat.',
610
  );
 
 
611
  }
612
  }
613
 
614
- async function handleDeleteSession(targetSessionId: string): Promise<void> {
615
- if (!token || !window.confirm('Hapus riwayat chat ini?')) {
 
 
 
 
 
 
 
 
616
  return;
617
  }
618
 
 
 
 
619
  try {
620
- await deleteChatSessionEmbed(targetSessionId, token);
621
  setChatSessions((prev) =>
622
- prev.filter((s) => s.uuid_id !== targetSessionId),
623
  );
624
 
625
- if (targetSessionId === sessionId) {
626
  handleNewSession();
627
  }
628
  } catch (err) {
@@ -641,6 +725,13 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
641
  <Head title={config.title} />
642
 
643
  <main className="embed-shell" style={rootStyle}>
 
 
 
 
 
 
 
644
  <header className="embed-toolbar">
645
  <div className="embed-tabs" role="tablist">
646
  <button
@@ -681,7 +772,13 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
681
  </div>
682
  </header>
683
 
684
- {activeTab === 'history' ? (
 
 
 
 
 
 
685
  <EmbedHistory
686
  activeSessionId={sessionId}
687
  chatSessions={chatSessions}
@@ -717,32 +814,40 @@ export default function Embed({ isDirectChatMode = false }: EmbedPageProps) {
717
  </p>
718
  )}
719
 
720
- {allMessages.map((message) => (
721
- <EmbedMessage
722
- key={message.uuid_id}
723
- message={message}
724
- shouldAnimateTyping={
725
- message.uuid_id ===
726
- animatedAssistantMessageId
727
- }
728
- />
729
- ))}
730
-
731
- {isProcessing && (
732
- <article className="embed-message embed-message--assistant">
733
- <span className="embed-message-avatar embed-message-avatar--assistant">
734
- <Bot className="size-4" />
735
- </span>
736
- <div className="embed-message-bubble embed-message-bubble--typing">
737
- <LoadingIndicator
738
- label="Thinking"
739
- showSpinner={false}
740
  />
741
- </div>
742
- </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
743
  )}
744
-
745
- <div ref={messagesEndRef} />
746
  </div>
747
  ) : (
748
  <div className="embed-empty-state">
 
35
 
36
  type EmbedConfig = {
37
  background: string;
38
+ backgroundProfile: string;
39
  border: string;
40
  courseId?: string | null;
41
+ logout: string;
 
42
  primary: string;
43
+ primaryGradient: string;
44
  radius: string;
45
  subtitle: string;
46
+ textPrimary: string;
47
+ textSecondary: string;
48
  title: string;
49
  userName: string;
50
  };
 
55
 
56
  const COLOR_DEFAULTS = {
57
  background: '#ffffff',
58
+ backgroundProfile: '#ffffff',
59
  border: '#739af1',
60
+ logout: '#ef4444',
 
61
  primary: '#2764eb',
62
+ primaryGradient: '#ccdcff',
63
+ textPrimary: '#0a0a0a',
64
+ textSecondary: '#737373',
65
  } as const;
66
 
67
  const EMBED_TOKEN_KEY = 'sevima_raghub_embed_token';
68
+ const EMBED_USER_KEY = 'sevima_raghub_embed_user';
69
+
70
+ type EmbedTokenResult = {
71
+ token: string;
72
+ user?: AuthUser;
73
+ };
74
 
75
  function parseColor(value: string | null, fallback: string): string {
76
  if (!value) {
 
95
  function parseEmbedConfig(params: URLSearchParams): EmbedConfig {
96
  return {
97
  background: parseColor(params.get('background'), COLOR_DEFAULTS.background),
98
+ backgroundProfile: parseColor(params.get('background_profile'), COLOR_DEFAULTS.backgroundProfile),
99
  border: parseColor(params.get('border'), COLOR_DEFAULTS.border),
100
  courseId: params.get('course_id') ?? params.get('courseId') ?? undefined,
101
+ logout: parseColor(params.get('logout'), COLOR_DEFAULTS.logout),
 
102
  primary: parseColor(params.get('primary'), COLOR_DEFAULTS.primary),
103
+ primaryGradient: parseColor(params.get('primary_gradient'), COLOR_DEFAULTS.primaryGradient),
104
  radius: parseRadius(params.get('radius')),
105
  subtitle: params.get('subtitle') ?? 'Pilih mata kuliah, lalu kirim pertanyaan pertama untuk membuat sesi baru',
106
+ textPrimary: parseColor(params.get('text_primary'), COLOR_DEFAULTS.textPrimary),
107
+ textSecondary: parseColor(params.get('text_secondary'), COLOR_DEFAULTS.textSecondary),
108
  title: params.get('title') ?? 'RAG Hub',
109
  userName: params.get('user_name') ?? 'User',
110
  };
 
118
  }
119
  }
120
 
121
+ function getStoredEmbedUser(): AuthUser | null {
122
+ try {
123
+ const raw = sessionStorage.getItem(EMBED_USER_KEY);
124
+ return raw ? (JSON.parse(raw) as AuthUser) : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function storeEmbedSession(token: string, user?: AuthUser): void {
131
  try {
132
  sessionStorage.setItem(EMBED_TOKEN_KEY, token);
133
+
134
+ if (user) {
135
+ sessionStorage.setItem(EMBED_USER_KEY, JSON.stringify(user));
136
+ }
137
  } catch {}
138
  }
139
 
140
+ async function resolveEmbedToken(
141
+ params: URLSearchParams,
142
+ ): Promise<EmbedTokenResult | null> {
143
  const stored = getStoredEmbedToken();
144
 
145
  if (stored) {
146
+ return { token: stored, user: getStoredEmbedUser() ?? undefined };
147
  }
148
 
149
  const tokenParam = params.get('token');
150
 
151
  if (tokenParam) {
152
+ storeEmbedSession(tokenParam);
153
 
154
+ return { token: tokenParam };
155
  }
156
 
157
  const apiKey = params.get('api_key');
158
 
159
  if (apiKey) {
160
  try {
161
+ const { token, user } = await loginWithEmbedApiKey(apiKey);
162
+ storeEmbedSession(token, user);
163
 
164
+ return { token, user };
165
  } catch {
166
  return null;
167
  }
 
368
  );
369
  }
370
 
371
+ function EmbedConfirmModal({
372
+ message,
373
+ onCancel,
374
+ onConfirm,
375
+ }: {
376
+ message: string;
377
+ onCancel: () => void;
378
+ onConfirm: () => void;
379
+ }) {
380
+ return (
381
+ <div className="embed-modal-backdrop" onClick={onCancel} role="dialog">
382
+ <div
383
+ className="embed-modal"
384
+ onClick={(e) => e.stopPropagation()}
385
+ >
386
+ <p className="embed-modal-title">Konfirmasi</p>
387
+ <p className="embed-modal-body">{message}</p>
388
+ <div className="embed-modal-actions">
389
+ <button
390
+ className="embed-modal-btn embed-modal-btn--cancel"
391
+ onClick={onCancel}
392
+ type="button"
393
+ >
394
+ Batal
395
+ </button>
396
+ <button
397
+ className="embed-modal-btn embed-modal-btn--danger"
398
+ onClick={onConfirm}
399
+ type="button"
400
+ >
401
+ Hapus
402
+ </button>
403
+ </div>
404
+ </div>
405
+ </div>
406
+ );
407
+ }
408
+
409
  function EmbedHistory({
410
  activeSessionId,
411
  chatSessions,
 
485
  );
486
  const [question, setQuestion] = useState('');
487
  const [isProcessing, setIsProcessing] = useState(false);
488
+ const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
489
+ const [isLoading, setIsLoading] = useState(true);
490
+ const [isLoadingHistory, setIsLoadingHistory] = useState(false);
491
  const [backendMessage, setBackendMessage] = useState<string | undefined>();
492
  const [activeTab, setActiveTab] = useState<'chat' | 'history'>('chat');
493
  const [animatedAssistantMessageId, setAnimatedAssistantMessageId] =
 
502
  const rootStyle = {
503
  '--embed-bg': config.background,
504
  '--embed-border': config.border,
505
+ '--embed-danger': config.logout,
506
+ '--embed-muted': config.textSecondary,
507
  '--embed-primary': config.primary,
508
+ '--embed-primary-soft': config.primaryGradient,
509
  '--embed-radius': config.radius,
510
+ '--embed-surface': config.backgroundProfile,
511
+ '--embed-text': config.textPrimary,
512
  } as CSSProperties;
513
 
514
  useEffect(() => {
 
520
  setSelectedCourseId(parsedConfig.courseId ?? '');
521
 
522
  void (async () => {
523
+ try {
524
+ const resolvedToken = await resolveEmbedToken(params);
525
 
526
+ if (!resolvedToken) {
527
+ setBackendMessage('Token iframe tidak tersedia.');
528
 
529
+ return;
530
+ }
531
 
532
+ const { token, user } = resolvedToken;
533
+
534
+ setToken(token);
535
+ setIsAuthorized(true);
536
+
537
+ if (user?.name && !params.get('user_name')) {
538
+ setConfig((prev: EmbedConfig) => ({ ...prev, userName: user.name as string }));
539
+ }
540
 
 
541
  const [coursesResult, sessionsResult] = await Promise.all([
542
+ listCoursesEmbed(token),
543
  listChatSessionsEmbed(
544
+ token,
545
  parsedConfig.courseId ?? undefined,
546
  ),
547
  ]);
 
558
  setSessionId(parsedSessionId);
559
  const historyResult = await getChatHistoryEmbed(
560
  parsedSessionId,
561
+ token,
562
  );
563
  setMessages(historyResult.data);
564
  }
 
566
  setBackendMessage(
567
  err instanceof Error ? err.message : 'Gagal memuat data.',
568
  );
569
+ } finally {
570
+ setIsLoading(false);
571
  }
572
  })();
573
  }, []);
 
670
  setSessionId(targetSessionId);
671
  setMessages([]);
672
  setOptimisticMessages([]);
673
+ setIsLoadingHistory(true);
674
 
675
  try {
676
  const result = await getChatHistoryEmbed(targetSessionId, token);
 
679
  setBackendMessage(
680
  err instanceof Error ? err.message : 'Gagal memuat riwayat.',
681
  );
682
+ } finally {
683
+ setIsLoadingHistory(false);
684
  }
685
  }
686
 
687
+ function handleDeleteSession(targetSessionId: string): void {
688
+ if (!token) {
689
+ return;
690
+ }
691
+
692
+ setPendingDeleteId(targetSessionId);
693
+ }
694
+
695
+ async function handleConfirmDelete(): Promise<void> {
696
+ if (!token || !pendingDeleteId) {
697
  return;
698
  }
699
 
700
+ const targetId = pendingDeleteId;
701
+ setPendingDeleteId(null);
702
+
703
  try {
704
+ await deleteChatSessionEmbed(targetId, token);
705
  setChatSessions((prev) =>
706
+ prev.filter((s) => s.uuid_id !== targetId),
707
  );
708
 
709
+ if (targetId === sessionId) {
710
  handleNewSession();
711
  }
712
  } catch (err) {
 
725
  <Head title={config.title} />
726
 
727
  <main className="embed-shell" style={rootStyle}>
728
+ {pendingDeleteId && (
729
+ <EmbedConfirmModal
730
+ message="Sesi chat ini akan dihapus permanen."
731
+ onCancel={() => setPendingDeleteId(null)}
732
+ onConfirm={() => { void handleConfirmDelete(); }}
733
+ />
734
+ )}
735
  <header className="embed-toolbar">
736
  <div className="embed-tabs" role="tablist">
737
  <button
 
772
  </div>
773
  </header>
774
 
775
+ {isLoading ? (
776
+ <section className="embed-chat">
777
+ <div className="embed-empty-state">
778
+ <Spinner className="size-6" />
779
+ </div>
780
+ </section>
781
+ ) : activeTab === 'history' ? (
782
  <EmbedHistory
783
  activeSessionId={sessionId}
784
  chatSessions={chatSessions}
 
814
  </p>
815
  )}
816
 
817
+ {isLoadingHistory ? (
818
+ <div className="embed-empty-state">
819
+ <Spinner className="size-6" />
820
+ </div>
821
+ ) : (
822
+ <>
823
+ {allMessages.map((message) => (
824
+ <EmbedMessage
825
+ key={message.uuid_id}
826
+ message={message}
827
+ shouldAnimateTyping={
828
+ message.uuid_id ===
829
+ animatedAssistantMessageId
830
+ }
 
 
 
 
 
 
831
  />
832
+ ))}
833
+
834
+ {isProcessing && (
835
+ <article className="embed-message embed-message--assistant">
836
+ <span className="embed-message-avatar embed-message-avatar--assistant">
837
+ <Bot className="size-4" />
838
+ </span>
839
+ <div className="embed-message-bubble embed-message-bubble--typing">
840
+ <LoadingIndicator
841
+ label="Thinking"
842
+ showSpinner={false}
843
+ />
844
+ </div>
845
+ </article>
846
+ )}
847
+
848
+ <div ref={messagesEndRef} />
849
+ </>
850
  )}
 
 
851
  </div>
852
  ) : (
853
  <div className="embed-empty-state">