Spaces:
Paused
Paused
| <script> | |
| import { onMount, onDestroy } from 'svelte'; | |
| import { | |
| sendWhatsAppMessage, | |
| fetchInstances, | |
| createInstance, | |
| connectInstance, | |
| logoutInstance, | |
| fetchOverdueBooks, | |
| getCachedQR, | |
| deleteInstance, | |
| requestPairingCode | |
| } from '../lib/api'; | |
| let username = ""; | |
| let password = ""; | |
| let isLoggedIn = false; | |
| let error = ""; | |
| // WhatsApp Connection State | |
| let connStatus = "checking"; // checking | login | dashboard | |
| let qrCode = null; | |
| let pairingCode = null; | |
| let phoneNumber = ""; | |
| let isPairingLoading = false; | |
| let instances = []; | |
| let selectedInstance = ""; | |
| let newInstanceName = ""; | |
| let isCreatingInstance = false; | |
| // Messaging state | |
| let recipient = "91"; | |
| let dueDate = ""; | |
| let daysLimit = "7"; | |
| let message = ""; | |
| let infoMsg = ""; | |
| let isSending = false; | |
| let overdueBooks = []; | |
| let isLoadingOverdue = false; | |
| let pollInterval; | |
| let isPolling = false; | |
| async function checkSetup() { | |
| try { | |
| const raw = await fetchInstances(); | |
| instances = Array.isArray(raw) ? raw : raw?.instances ?? []; | |
| if (!selectedInstance) { | |
| console.log("[admin] No instance selected, prompting for creation"); | |
| connStatus = "login"; | |
| return; | |
| } | |
| const inst = instances.find((i) => i.name === selectedInstance); | |
| if (inst) { | |
| console.log(`[admin] ${selectedInstance} status: ${inst.connectionStatus}`); | |
| if (inst.connectionStatus === "open") { | |
| connStatus = "dashboard"; | |
| } else { | |
| connStatus = "login"; | |
| } | |
| } else { | |
| console.log(`[admin] ${selectedInstance} instance not found`); | |
| connStatus = "login"; | |
| } | |
| } catch (e) { | |
| console.error("[admin] checkSetup error:", e); | |
| error = "WhatsApp API sync failed: " + e.message; | |
| connStatus = "login"; | |
| } | |
| } | |
| async function fetchConnData(num = null) { | |
| if (num) { | |
| isPairingLoading = true; | |
| try { await logoutInstance(selectedInstance); } catch(e) {} | |
| await new Promise(r => setTimeout(r, 1000)); | |
| try { | |
| const res = await requestPairingCode(selectedInstance, num); | |
| pairingCode = res?.pairingCode ?? res?.data?.pairingCode ?? null; | |
| if (pairingCode) console.log("[admin] Pairing Code received:", pairingCode); | |
| } catch (e) { | |
| console.error("[admin] pairing code error:", e); | |
| error = "Pairing code request failed: " + e.message; | |
| } | |
| isPairingLoading = false; | |
| return; | |
| } | |
| try { | |
| console.log(`[admin] Fetching connection data for ${selectedInstance} (QR/Pairing)...`); | |
| // NEW: Try cached QR first for instant ready state | |
| if (!num) { | |
| const cached = await getCachedQR(selectedInstance); | |
| if (cached && (cached.code || cached.base64)) { | |
| console.log(`[admin] Using cached QR for ${selectedInstance}`); | |
| if (cached.base64) { | |
| qrCode = cached.base64.startsWith("data:") ? cached.base64 : `data:image/png;base64,${cached.base64}`; | |
| } else if (cached.code) { | |
| try { | |
| const QRCode = (await import("qrcode")).default; | |
| qrCode = await QRCode.toDataURL(cached.code, { margin: 2, width: 280, errorCorrectionLevel: "M" }); | |
| } catch (e) { | |
| console.error("[admin] client-side QR render failed (cached):", e); | |
| } | |
| } | |
| if (qrCode) return; // Exit early if we got a good cached QR for THIS instance | |
| } | |
| } | |
| const res = await connectInstance(selectedInstance, num); | |
| if (res?.error) { | |
| console.error("[admin] connect API error:", res.message); | |
| return; | |
| } | |
| // Extract pairing code from root or nested data | |
| pairingCode = res?.pairingCode ?? res?.data?.pairingCode ?? null; | |
| // Evolution may return the QR payload at top level, under `qrcode`, or under `data` | |
| const qrData = res?.qrcode ?? res?.data?.qrcode ?? res; | |
| let imgSrc = null; | |
| if (qrData?.base64) { | |
| imgSrc = qrData.base64.startsWith("data:") | |
| ? qrData.base64 | |
| : `data:image/png;base64,${qrData.base64}`; | |
| } else if (qrData?.code && typeof qrData.code === "string") { | |
| // Baileys often exposes raw QR text before async base64 is ready on the server | |
| try { | |
| const QRCode = (await import("qrcode")).default; | |
| imgSrc = await QRCode.toDataURL(qrData.code, { | |
| margin: 2, | |
| width: 280, | |
| errorCorrectionLevel: "M", | |
| }); | |
| } catch (e) { | |
| console.error("[admin] client-side QR render failed:", e); | |
| } | |
| } | |
| if (imgSrc) { | |
| qrCode = imgSrc; | |
| console.log("[admin] QR Code received"); | |
| } | |
| if (qrData?.pairingCode) { | |
| pairingCode = qrData.pairingCode; | |
| console.log("[admin] Pairing Code received:", pairingCode); | |
| } | |
| } catch (e) { | |
| console.error("[admin] fetchConnData error:", e); | |
| } | |
| isPairingLoading = false; | |
| } | |
| async function handleCreateInstance() { | |
| if (!newInstanceName) return; | |
| isCreatingInstance = true; | |
| console.log(`[admin] Attempting to create instance: ${newInstanceName}`); | |
| try { | |
| const res = await createInstance(newInstanceName); | |
| console.log("[admin] Create instance response:", res); | |
| // 1. Update selected instance | |
| selectedInstance = newInstanceName; | |
| newInstanceName = ""; | |
| // 2. Wait for API to propagate the new instance (crucial for some versions) | |
| await new Promise(r => setTimeout(r, 2000)); | |
| // 3. Force a fresh sync of the instances list | |
| await checkSetup(); | |
| // 4. Immediately fetch the QR code for this new instance | |
| await fetchConnData(); | |
| infoMsg = `Instance ${selectedInstance} created successfully!`; | |
| } catch (e) { | |
| console.error("[admin] Create instance failed:", e); | |
| error = "Failed to create instance: " + e.message; | |
| } finally { | |
| isCreatingInstance = false; | |
| } | |
| } | |
| async function handleDeleteInstance() { | |
| if (!selectedInstance) return; | |
| if (!confirm(`Are you sure you want to delete instance ${selectedInstance}? This cannot be undone.`)) return; | |
| try { | |
| await deleteInstance(selectedInstance); | |
| selectedInstance = ""; // Reset to empty instead of fallback to halo | |
| await checkSetup(); | |
| infoMsg = `Instance deleted successfully.`; | |
| } catch (e) { | |
| error = "Failed to delete instance: " + e.message; | |
| } | |
| } | |
| function handleLogin() { | |
| if (username === "hisernbug" && password === "pounds") { | |
| isLoggedIn = true; | |
| error = ""; | |
| startPolling(); | |
| } else { | |
| error = "Invalid username or password"; | |
| } | |
| } | |
| async function handleInstanceChange() { | |
| await checkSetup(); | |
| await fetchConnData(); | |
| } | |
| async function runPoll() { | |
| if (isPolling) return; | |
| isPolling = true; | |
| try { | |
| await checkSetup(); | |
| if (selectedInstance && connStatus === "login" && !isPairingLoading) { | |
| await fetchConnData(); | |
| } | |
| } finally { | |
| isPolling = false; | |
| } | |
| } | |
| async function forceReset() { | |
| if (!confirm(`This will disconnect ${selectedInstance} and clear the session. Continue?`)) return; | |
| try { | |
| connStatus = "checking"; | |
| await logoutInstance(selectedInstance); | |
| qrCode = null; | |
| pairingCode = null; | |
| infoMsg = `Instance ${selectedInstance} reset successfully. Generating new QR...`; | |
| setTimeout(runPoll, 2000); | |
| } catch (e) { | |
| error = "Reset failed: " + e.message; | |
| connStatus = "login"; | |
| } | |
| } | |
| function startPolling() { | |
| void runPoll(); | |
| // Slightly faster while linking WhatsApp so QR appears soon after the server generates it | |
| pollInterval = setInterval(runPoll, 3000); | |
| } | |
| onDestroy(() => { | |
| if (pollInterval) clearInterval(pollInterval); | |
| }); | |
| function handleLogout() { | |
| isLoggedIn = false; | |
| username = ""; | |
| password = ""; | |
| if (pollInterval) clearInterval(pollInterval); | |
| } | |
| async function sendMessage() { | |
| if (recipient.length < 10 || !message) { | |
| infoMsg = "⚠️ Please enter a valid number and message."; | |
| return; | |
| } | |
| isSending = true; | |
| infoMsg = "🤫 Sending secure message (anti-ban active)..."; | |
| let fullMessage = message; | |
| if (dueDate) { | |
| fullMessage += `\n\n📅 Due Date: ${dueDate}\n⏳ Days Limit: ${daysLimit}`; | |
| } | |
| try { | |
| const res = await sendWhatsAppMessage(selectedInstance, recipient, fullMessage); | |
| if (res.status === "success") { | |
| infoMsg = "✅ " + res.message; | |
| message = ""; | |
| } else { | |
| infoMsg = "❌ " + res.message; | |
| } | |
| } catch (e) { | |
| infoMsg = "❌ Error: " + e.message; | |
| } finally { | |
| isSending = false; | |
| } | |
| } | |
| async function fetchOverdue() { | |
| isLoadingOverdue = true; | |
| try { | |
| overdueBooks = await fetchOverdueBooks(); | |
| } catch (e) { | |
| infoMsg = "❌ Error fetching overdue: " + e.message; | |
| } finally { | |
| isLoadingOverdue = false; | |
| } | |
| } | |
| </script> | |
| <div class="admin-container"> | |
| {#if !isLoggedIn} | |
| <div class="login-card"> | |
| <h2>Admin Portal</h2> | |
| <div class="input-group"> | |
| <label for="username">Username</label> | |
| <input id="username" type="text" bind:value={username} placeholder="Enter admin username" /> | |
| </div> | |
| <div class="input-group"> | |
| <label for="password">Password</label> | |
| <input id="password" type="password" bind:value={password} placeholder="••••••••" /> | |
| </div> | |
| {#if error} | |
| <p class="error-msg">{error}</p> | |
| {/if} | |
| <button class="login-btn" on:click={handleLogin}>Log In</button> | |
| </div> | |
| {:else} | |
| {#if connStatus === "checking"} | |
| <div class="dashboard-card" style="text-align: center;"> | |
| <h2>Synchronizing...</h2> | |
| <p class="info-msg">Establishing connection with Evolution API</p> | |
| </div> | |
| {:else if connStatus === "login"} | |
| <div class="dashboard-card" style="max-width: 800px;"> | |
| <div class="connection-grid"> | |
| <div class="instance-manager"> | |
| <h3>Instance Manager</h3> | |
| <div class="input-group"> | |
| <label for="instance-select">Select Instance</label> | |
| <select id="instance-select" bind:value={selectedInstance} on:change={handleInstanceChange}> | |
| <option value="">-- Select an Instance --</option> | |
| {#each instances as inst} | |
| <option value={inst.name}>{inst.name} ({inst.connectionStatus})</option> | |
| {/each} | |
| </select> | |
| </div> | |
| <div class="create-instance-group"> | |
| <input type="text" bind:value={newInstanceName} placeholder="New instance name" /> | |
| <button class="login-btn" style="margin-top: 0; width: auto; padding: 0.5rem 1rem;" disabled={isCreatingInstance} on:click={handleCreateInstance}> | |
| {isCreatingInstance ? '...' : 'Create'} | |
| </button> | |
| </div> | |
| </div> | |
| {#if selectedInstance} | |
| <div class="qr-section"> | |
| <h3>Option 1: Scan QR</h3> | |
| <div class="qr-box"> | |
| {#if qrCode} | |
| <img src={qrCode} alt="WhatsApp QR Code" /> | |
| {:else} | |
| <p>Generating QR Code...</p> | |
| {/if} | |
| </div> | |
| <p class="hint">Open WhatsApp > Linked Devices > Link a Device</p> | |
| </div> | |
| <div class="pairing-section"> | |
| <h3>Option 2: Pairing Code</h3> | |
| <div class="input-group"> | |
| <label for="phone">Phone Number</label> | |
| <input id="phone" type="text" bind:value={phoneNumber} placeholder="91XXXXXXXXXX" /> | |
| </div> | |
| <button class="login-btn" disabled={isPairingLoading} on:click={() => fetchConnData(phoneNumber)}> | |
| {isPairingLoading ? 'Requesting...' : 'Get Pairing Code'} | |
| </button> | |
| {#if pairingCode} | |
| <div class="pairing-result"> | |
| <span class="code-label">Your Code</span> | |
| <div class="code-value">{pairingCode}</div> | |
| </div> | |
| {/if} | |
| </div> | |
| {:else} | |
| <div class="welcome-section" style="display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; padding: 2rem; border: 2px dashed var(--border); border-radius: 12px; color: var(--muted);"> | |
| <h3 style="color: var(--accent);">Welcome to Librarian AI</h3> | |
| <p>Please create a new WhatsApp instance or select an existing one from the manager to get started.</p> | |
| </div> | |
| {/if} | |
| </div> | |
| <div style="text-align: center; margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1rem; display: flex; justify-content: center; gap: 1rem;"> | |
| <button class="secondary-btn" on:click={checkSetup}>Refresh Status</button> | |
| <button class="secondary-btn" style="color: #ef4444; border-color: rgba(239, 68, 68, 0.3);" on:click={forceReset}>Force Reset Instance</button> | |
| <button class="secondary-btn" style="color: #ef4444; border-color: rgba(239, 68, 68, 0.3);" on:click={handleDeleteInstance}>Delete Instance</button> | |
| </div> | |
| </div> | |
| {:else} | |
| <div class="dashboard-card"> | |
| <div class="dashboard-header"> | |
| <h2>Messaging Dashboard</h2> | |
| <button class="logout-link" on:click={handleLogout}>Logout</button> | |
| </div> | |
| <div class="dashboard-grid"> | |
| <div class="input-group"> | |
| <label for="recipient">Recipient Number</label> | |
| <input id="recipient" type="text" bind:value={recipient} placeholder="91XXXXXXXXXX" disabled={isSending} /> | |
| </div> | |
| <div class="input-group"> | |
| <label for="dueDate">Due Date (Optional)</label> | |
| <input id="dueDate" type="date" bind:value={dueDate} disabled={isSending} /> | |
| </div> | |
| </div> | |
| <div class="input-group"> | |
| <label for="daysLimit">Days Limit After Due Date</label> | |
| <input id="daysLimit" type="number" bind:value={daysLimit} placeholder="7" disabled={isSending} /> | |
| </div> | |
| <div class="input-group"> | |
| <label for="message">Custom Message</label> | |
| <textarea id="message" bind:value={message} placeholder="Type your core message here..." disabled={isSending}></textarea> | |
| </div> | |
| <div class="action-buttons"> | |
| <button class="login-btn" on:click={sendMessage} disabled={isSending}> | |
| {isSending ? 'Sending...' : 'Send Message with Metadata'} | |
| </button> | |
| <a href="/whatsapp" target="_blank" class="secondary-btn"> | |
| Launch WhatsApp Manager 🚀 | |
| </a> | |
| </div> | |
| {#if infoMsg} | |
| <p class="info-msg">{infoMsg}</p> | |
| {/if} | |
| <div class="overdue-section" style="margin-top: 3rem; border-top: 1px solid var(--border); padding-top: 2rem;"> | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;"> | |
| <h3>Overdue Students Tracking</h3> | |
| <button class="secondary-btn" style="padding: 0.5rem 1rem;" on:click={fetchOverdue} disabled={isLoadingOverdue}> | |
| {isLoadingOverdue ? 'Loading...' : 'Refresh List'} | |
| </button> | |
| </div> | |
| <div style="overflow-x: auto;"> | |
| <table style="width: 100%; border-collapse: collapse; font-size: 0.85rem; color: var(--text);"> | |
| <thead> | |
| <tr style="border-bottom: 1px solid var(--border); text-align: left;"> | |
| <th style="padding: 0.75rem;">ID NO</th> | |
| <th style="padding: 0.75rem;">ACC NO</th> | |
| <th style="padding: 0.75rem;">TITLE</th> | |
| <th style="padding: 0.75rem;">DUE DATE</th> | |
| <th style="padding: 0.75rem;">ACTION</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {#each overdueBooks as book} | |
| <tr style="border-bottom: 1px solid rgba(255,255,255,0.05);"> | |
| <td style="padding: 0.75rem;">{book.id_no}</td> | |
| <td style="padding: 0.75rem;">{book.acc_no}</td> | |
| <td style="padding: 0.75rem;">{book.title}</td> | |
| <td style="padding: 0.75rem; color: #ef4444;">{book.due_date}</td> | |
| <td style="padding: 0.75rem;"> | |
| <button | |
| class="secondary-btn" | |
| style="padding: 0.3rem 0.6rem; font-size: 0.75rem;" | |
| on:click={() => { | |
| message = `Reminder: The book '${book.title}' (Acc: ${book.acc_no}) was due on ${book.due_date}. Please return it to the library.`; | |
| dueDate = book.due_date; | |
| }} | |
| > | |
| Notify | |
| </button> | |
| </td> | |
| </tr> | |
| {/each} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| {/if} | |
| {/if} | |
| </div> | |
| <style> | |
| .connection-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); | |
| gap: 3rem; | |
| } | |
| .instance-manager { | |
| background: rgba(255, 255, 255, 0.03); | |
| padding: 1.5rem; | |
| border-radius: 12px; | |
| border: 1px solid var(--border); | |
| } | |
| .create-instance-group { | |
| display: flex; | |
| gap: 0.5rem; | |
| margin-top: 0.5rem; | |
| } | |
| .create-instance-group input { | |
| flex: 1; | |
| } | |
| .qr-section h3, .pairing-section h3 { | |
| color: var(--accent); | |
| margin-bottom: 1.5rem; | |
| font-family: 'DM Serif Display', serif; | |
| } | |
| .pairing-section { | |
| border-left: 1px solid var(--border); | |
| padding-left: 2rem; | |
| } | |
| .pairing-result { | |
| margin-top: 2rem; | |
| padding: 1.5rem; | |
| background: rgba(200, 169, 110, 0.1); | |
| border: 2px dashed var(--accent); | |
| border-radius: 12px; | |
| text-align: center; | |
| } | |
| .code-label { | |
| font-size: 0.7rem; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.1em; | |
| } | |
| .code-value { | |
| font-family: monospace; | |
| font-size: 2.2rem; | |
| color: var(--accent); | |
| letter-spacing: 6px; | |
| margin-top: 0.5rem; | |
| } | |
| .hint { | |
| font-size: 0.8rem; | |
| color: var(--muted); | |
| margin-top: 1rem; | |
| } | |
| @media (max-width: 768px) { | |
| .pairing-section { | |
| border-left: none; | |
| border-top: 1px solid var(--border); | |
| padding-left: 0; | |
| padding-top: 2rem; | |
| } | |
| } | |
| .action-buttons { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| margin-top: 1rem; | |
| } | |
| .secondary-btn { | |
| display: block; | |
| text-align: center; | |
| background: rgba(255, 255, 255, 0.05); | |
| color: var(--accent); | |
| border: 1px solid var(--border); | |
| padding: 0.8rem; | |
| border-radius: 8px; | |
| font-weight: 500; | |
| text-decoration: none; | |
| transition: all 0.2s; | |
| font-size: 0.9rem; | |
| } | |
| .secondary-btn:hover { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-color: var(--accent); | |
| } | |
| .admin-container { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 60vh; | |
| padding: 2rem; | |
| } | |
| .login-card, .dashboard-card { | |
| background: var(--card); | |
| border: 1px solid var(--border); | |
| padding: 2.5rem; | |
| border-radius: 16px; | |
| width: 100%; | |
| max-width: 500px; | |
| box-shadow: 0 20px 40px rgba(0,0,0,0.4); | |
| } | |
| .dashboard-card { | |
| max-width: 600px; | |
| } | |
| .dashboard-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| margin-bottom: 2rem; | |
| border-bottom: 1px solid var(--border); | |
| padding-bottom: 1rem; | |
| } | |
| .dashboard-header h2 { | |
| margin: 0; | |
| } | |
| .logout-link { | |
| background: none; | |
| border: none; | |
| color: #ef4444; | |
| cursor: pointer; | |
| font-size: 0.9rem; | |
| text-decoration: underline; | |
| } | |
| h2 { | |
| font-family: 'DM Serif Display', serif; | |
| margin-bottom: 2rem; | |
| text-align: center; | |
| color: var(--accent); | |
| } | |
| .dashboard-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 1.5rem; | |
| } | |
| .input-group { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.5rem; | |
| margin-bottom: 1.5rem; | |
| } | |
| label { | |
| font-size: 0.75rem; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| } | |
| input, textarea { | |
| background: rgba(0,0,0,0.2); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 0.8rem 1rem; | |
| color: var(--text); | |
| outline: none; | |
| transition: all 0.2s; | |
| width: 100%; | |
| box-sizing: border-box; | |
| } | |
| textarea { | |
| height: 100px; | |
| resize: vertical; | |
| } | |
| input:focus, textarea:focus { | |
| border-color: var(--accent); | |
| background: rgba(0,0,0,0.3); | |
| } | |
| .login-btn { | |
| width: 100%; | |
| background: var(--accent); | |
| color: #1a1409; | |
| border: none; | |
| padding: 1rem; | |
| border-radius: 8px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| margin-top: 1rem; | |
| } | |
| .login-btn:hover:not(:disabled) { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(200, 169, 110, 0.3); | |
| } | |
| .login-btn:disabled { | |
| opacity: 0.6; | |
| cursor: not-allowed; | |
| } | |
| .error-msg { | |
| color: #ef4444; | |
| font-size: 0.85rem; | |
| text-align: center; | |
| margin-bottom: 1rem; | |
| } | |
| .info-msg { | |
| color: var(--muted); | |
| font-size: 0.85rem; | |
| text-align: center; | |
| margin-top: 1.5rem; | |
| } | |
| </style> | |