Spaces:
Sleeping
Sleeping
| const APP_CONFIG = window.APP_CONFIG || {}; | |
| const API_BASE = APP_CONFIG.API_BASE_URL || "https://api.corpus.swecha.org/api/v1"; | |
| const HF_API_BASE = APP_CONFIG.HF_API_BASE_URL || "https://api-inference.huggingface.co/models"; | |
| const HF_ASR_MODEL = APP_CONFIG.HF_ASR_MODEL || "viswamaicoe/swecha-gonthuka-asr"; | |
| const HF_TOKEN = APP_CONFIG.HF_TOKEN || ""; | |
| const LOCAL_ASR_BASE = (APP_CONFIG.LOCAL_ASR_BASE_URL !== undefined && APP_CONFIG.LOCAL_ASR_BASE_URL !== null && APP_CONFIG.LOCAL_ASR_BASE_URL !== "") | |
| ? APP_CONFIG.LOCAL_ASR_BASE_URL | |
| : ""; | |
| const CORPUS_ASR_ENDPOINT = APP_CONFIG.CORPUS_ASR_ENDPOINT || ""; | |
| // Auth elements | |
| const loginPage = document.getElementById("loginPage"); | |
| const dashboard = document.getElementById("dashboard"); | |
| const phoneInput = document.getElementById("phoneInput"); | |
| const passwordInput = document.getElementById("passwordInput"); | |
| const loginBtn = document.getElementById("loginBtn"); | |
| const loginError = document.getElementById("loginError"); | |
| const logoutBtn = document.getElementById("logoutBtn"); | |
| // Dashboard elements | |
| const userSearchInput = document.getElementById("userSearchInput"); | |
| const searchBtn = document.getElementById("searchBtn"); | |
| const searchStatus = document.getElementById("searchStatus"); | |
| const userResults = document.getElementById("userResults"); | |
| const dateFilter = document.getElementById("dateFilter"); | |
| const standupStatus = document.getElementById("standupStatus"); | |
| const standupList = document.getElementById("standupList"); | |
| const result = document.getElementById("result"); | |
| let selectedUser = null; | |
| let currentStandups = []; | |
| const standupState = new Map(); | |
| // --- Auth logic --- | |
| function getAuthToken() { | |
| return localStorage.getItem("access_token") || ""; | |
| } | |
| function setAuthToken(token) { | |
| if (token) { | |
| localStorage.setItem("access_token", token); | |
| } else { | |
| localStorage.removeItem("access_token"); | |
| } | |
| } | |
| function checkAuth() { | |
| const token = getAuthToken(); | |
| if (token) { | |
| loginPage.classList.add("hidden"); | |
| dashboard.classList.remove("hidden"); | |
| } else { | |
| loginPage.classList.remove("hidden"); | |
| dashboard.classList.add("hidden"); | |
| } | |
| } | |
| async function login() { | |
| let phone = phoneInput.value.trim(); | |
| const password = passwordInput.value.trim(); | |
| if (!phone || !password) { | |
| showError(loginError, "Please enter both phone and password."); | |
| return; | |
| } | |
| // Normalise phone: if user typed a plain 10-digit number, prepend +91 | |
| if (/^\d{10}$/.test(phone)) { | |
| phone = "+91" + phone; | |
| } else if (/^91\d{10}$/.test(phone)) { | |
| // e.g. 919876543210 → +919876543210 | |
| phone = "+" + phone; | |
| } | |
| setLoading(loginBtn, true, "Logging in..."); | |
| hideError(loginError); | |
| try { | |
| const resp = await fetch(`${API_BASE}/auth/login`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json", accept: "application/json" }, | |
| body: JSON.stringify({ phone, password }), | |
| }); | |
| if (!resp.ok) { | |
| const msg = await parseErrorResponse(resp); | |
| throw new Error(msg); | |
| } | |
| const data = await resp.json(); | |
| if (data.access_token) { | |
| setAuthToken(data.access_token); | |
| checkAuth(); | |
| } else { | |
| throw new Error("Invalid response from server."); | |
| } | |
| } catch (err) { | |
| showError(loginError, `Login failed: ${err.message}`); | |
| } finally { | |
| setLoading(loginBtn, false, "Login"); | |
| } | |
| } | |
| function logout() { | |
| setAuthToken(""); | |
| checkAuth(); | |
| } | |
| // --- API helpers --- | |
| function buildHeaders({ json = false } = {}) { | |
| const headers = { accept: "application/json" }; | |
| const token = getAuthToken(); | |
| if (token) headers.Authorization = `Bearer ${token}`; | |
| if (json) headers["Content-Type"] = "application/json"; | |
| return headers; | |
| } | |
| async function parseErrorResponse(resp) { | |
| try { | |
| const body = await resp.json(); | |
| if (typeof body?.detail === "string") return body.detail; | |
| if (Array.isArray(body?.detail)) { | |
| return body.detail.map((e) => e.msg || JSON.stringify(e)).join(", "); | |
| } | |
| if (typeof body?.message === "string") return body.message; | |
| return JSON.stringify(body); | |
| } catch (_) { | |
| return `${resp.status} ${resp.statusText}`; | |
| } | |
| } | |
| async function fetchJson(path, init = {}) { | |
| const resp = await fetch(`${API_BASE}${path}`, { | |
| ...init, | |
| headers: { | |
| ...buildHeaders({ json: init.body && typeof init.body === "string" }), | |
| ...(init.headers || {}), | |
| }, | |
| }); | |
| if (resp.status === 401) { | |
| logout(); | |
| throw new Error("Session expired. Please login again."); | |
| } | |
| if (!resp.ok) { | |
| const msg = await parseErrorResponse(resp); | |
| throw new Error(msg); | |
| } | |
| return resp.json(); | |
| } | |
| // --- UI helpers --- | |
| function setLoading(btn, isLoading, text) { | |
| btn.disabled = isLoading; | |
| btn.textContent = text; | |
| } | |
| function showError(el, msg) { | |
| el.textContent = msg; | |
| el.classList.remove("hidden"); | |
| } | |
| function hideError(el) { | |
| el.textContent = ""; | |
| el.classList.add("hidden"); | |
| } | |
| function formatDate(dateInput) { | |
| if (!dateInput) return "--"; | |
| const d = new Date(dateInput); | |
| if (Number.isNaN(d.getTime())) return String(dateInput).slice(0, 10); | |
| return d.toISOString().slice(0, 10); | |
| } | |
| function userIdFromUser(user) { | |
| return user?.id || user?.user_id || user?.uid || ""; | |
| } | |
| function usernameFromUser(user) { | |
| return user?.username || user?.user_name || user?.name || "Unknown"; | |
| } | |
| function escapeHtml(value) { | |
| return String(value ?? "") | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/\"/g, """) | |
| .replace(/'/g, "'"); | |
| } | |
| // --- Business logic --- | |
| function renderUserResults(users) { | |
| userResults.innerHTML = ""; | |
| if (!users.length) { | |
| userResults.innerHTML = '<p class="muted">No matching users found.</p>'; | |
| return; | |
| } | |
| const frag = document.createDocumentFragment(); | |
| users.forEach((user) => { | |
| const btn = document.createElement("button"); | |
| btn.className = "item-btn"; | |
| const username = usernameFromUser(user); | |
| btn.textContent = username; | |
| btn.addEventListener("click", () => onUserSelected(user)); | |
| frag.appendChild(btn); | |
| }); | |
| userResults.appendChild(frag); | |
| } | |
| function renderStandups(standups) { | |
| standupList.innerHTML = ""; | |
| if (!standups.length) { | |
| standupList.innerHTML = '<p class="muted">No standups found.</p>'; | |
| return; | |
| } | |
| const frag = document.createDocumentFragment(); | |
| standups.forEach((item) => { | |
| const card = document.createElement("article"); | |
| card.className = "standup-card"; | |
| const hasAudio = Boolean(item.audio_url); | |
| const status = standupState.get(item.id) || {}; | |
| card.innerHTML = ` | |
| <div class="standup-head">Username: <strong>${escapeHtml(item.username)}</strong></div> | |
| <div>Date: ${escapeHtml(formatDate(item.date))}</div> | |
| <div>Status: <span class="pill ${escapeHtml(item.status?.toLowerCase())}">${escapeHtml(item.status || "Unknown")}</span></div> | |
| <div>Record ID: ${escapeHtml(item.id)}</div> | |
| ${hasAudio ? `<audio controls src="${escapeHtml(item.audio_url)}"></audio>` : '<p class="muted">Audio URL not available.</p>'} | |
| <button class="transcribe-action" ${hasAudio ? "" : "disabled"}>${status.loading ? "Transcribing..." : "Transcribe"}</button> | |
| ${status.error ? `<p class="error">${escapeHtml(status.error)}</p>` : ""} | |
| ${status.telugu ? `<div><strong>Telugu:</strong><pre>${escapeHtml(status.telugu)}</pre></div>` : ""} | |
| `; | |
| const actionBtn = card.querySelector(".transcribe-action"); | |
| actionBtn?.addEventListener("click", () => transcribeStandup(item)); | |
| frag.appendChild(card); | |
| }); | |
| standupList.appendChild(frag); | |
| } | |
| async function searchUsers() { | |
| const query = userSearchInput.value.trim(); | |
| if (!query) { | |
| searchStatus.textContent = "Enter username to search."; | |
| userResults.innerHTML = ""; | |
| return; | |
| } | |
| setLoading(searchBtn, true, "Searching..."); | |
| searchStatus.textContent = "Searching users..."; | |
| try { | |
| const users = await fetchJson(`/users/search?query=${encodeURIComponent(query)}`); | |
| const filtered = (Array.isArray(users) ? users : []).filter((u) => | |
| usernameFromUser(u).toLowerCase().includes(query.toLowerCase()) | |
| ); | |
| searchStatus.textContent = `Found ${filtered.length} matching user(s).`; | |
| renderUserResults(filtered); | |
| } catch (err) { | |
| searchStatus.textContent = `Search failed: ${err.message}`; | |
| userResults.innerHTML = ""; | |
| } finally { | |
| setLoading(searchBtn, false, "Search"); | |
| } | |
| } | |
| async function fetchAudioUrl(id) { | |
| try { | |
| const resp = await fetchJson(`/records/${id}/record-url?expires_minutes=60`); | |
| return resp?.record_url || null; | |
| } catch (_) { | |
| return null; | |
| } | |
| } | |
| async function enrichContribution(contribution, username) { | |
| const id = contribution?.id || contribution?.record_id || ""; | |
| if (!id) return null; | |
| const timestamp = contribution?.timestamp || contribution?.created_at || contribution?.date || null; | |
| const audioUrl = await fetchAudioUrl(id); | |
| return { | |
| id, | |
| username, | |
| title: contribution?.title || "", | |
| date: timestamp, | |
| status: contribution?.status || "uploaded", | |
| audio_url: audioUrl, | |
| }; | |
| } | |
| async function fetchStandupsForUser(user) { | |
| const username = usernameFromUser(user); | |
| const userId = userIdFromUser(user); | |
| const selectedDate = dateFilter.value; // e.g. "2024-03-20" | |
| if (!userId && !username) throw new Error("User ID not found"); | |
| const identifier = userId || encodeURIComponent(username); | |
| standupStatus.textContent = `Loading standups for ${username}...`; | |
| standupList.innerHTML = ""; | |
| try { | |
| const payload = await fetchJson(`/users/${identifier}/contributions/audio`); | |
| console.log('Fetched contributions payload:', payload); | |
| let rawContributions = Array.isArray(payload?.contributions) | |
| ? payload.contributions | |
| : Array.isArray(payload) | |
| ? payload | |
| : []; | |
| console.log('Extracted raw contributions count:', rawContributions.length); | |
| // Filter by selected date using the contribution's timestamp | |
| if (selectedDate) { | |
| rawContributions = rawContributions.filter((item) => { | |
| const ts = item?.timestamp || item?.created_at || item?.date || ""; | |
| return ts.startsWith(selectedDate); | |
| }); | |
| console.log('After date filter, count:', rawContributions.length); | |
| } | |
| if (rawContributions.length === 0) { | |
| standupStatus.textContent = selectedDate | |
| ? `No standups found for ${username} on ${selectedDate}.` | |
| : `No standups found for ${username}.`; | |
| renderStandups([]); | |
| return; | |
| } | |
| const limited = rawContributions.slice(0, 15); | |
| standupStatus.textContent = `Loading audio URLs for ${limited.length} standup(s)...`; | |
| const enriched = (await Promise.all( | |
| limited.map((item) => enrichContribution(item, username)) | |
| )).filter(Boolean); | |
| standupStatus.textContent = `Loaded ${enriched.length} standup(s) for ${username}${selectedDate ? " on " + selectedDate : ""}.`; | |
| currentStandups = enriched; | |
| renderStandups(currentStandups); | |
| } catch (err) { | |
| standupStatus.textContent = `Failed to load standups: ${err.message}`; | |
| renderStandups([]); | |
| } | |
| } | |
| async function onUserSelected(user) { | |
| selectedUser = user; | |
| standupState.clear(); | |
| await fetchStandupsForUser(user); | |
| } | |
| async function transcribeAudio(audioUrl) { | |
| // Send the URL to the local backend which fetches & transcribes server-side. | |
| // This avoids CORS errors that occur when the browser tries to fetch | |
| // the audio directly from the Corpus CDN. | |
| let resp; | |
| try { | |
| resp = await fetch(`${LOCAL_ASR_BASE}/transcribe-url`, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ audio_url: audioUrl }), | |
| }); | |
| } catch (networkErr) { | |
| // fetch() itself threw — backend is not reachable at all | |
| throw new Error( | |
| `Cannot reach ASR backend at ${LOCAL_ASR_BASE}. Is the backend running? (${networkErr.message})` | |
| ); | |
| } | |
| // Log for debugging | |
| console.log("[ASR] response status:", resp.status, resp.statusText); | |
| console.log("[ASR] content-type:", resp.headers.get("content-type")); | |
| if (resp.status === 404) { | |
| throw new Error( | |
| `ASR endpoint not found (404). Backend may be on a different port. Check LOCAL_ASR_BASE_URL in config.js (currently: ${LOCAL_ASR_BASE})` | |
| ); | |
| } | |
| if (!resp.ok) { | |
| // Try to get a meaningful JSON detail from the backend error | |
| let detail = `${resp.status} ${resp.statusText}`; | |
| try { | |
| const body = await resp.json(); | |
| if (body?.detail) { | |
| detail = typeof body.detail === "string" ? body.detail : JSON.stringify(body.detail); | |
| } | |
| } catch (_) { /* non-JSON error body */ } | |
| throw new Error(detail); | |
| } | |
| const data = await resp.json(); | |
| return data?.text || null; | |
| } | |
| async function transcribeStandup(item) { | |
| const state = standupState.get(item.id) || {}; | |
| standupState.set(item.id, { ...state, loading: true, error: "" }); | |
| renderStandups(currentStandups); | |
| try { | |
| const telugu = await transcribeAudio(item.audio_url); | |
| if (!telugu) throw new Error("No transcription returned."); | |
| standupState.set(item.id, { | |
| loading: false, | |
| telugu, | |
| error: "", | |
| }); | |
| result.textContent = `Telugu Transcript (ID ${item.id}):\n${telugu}`; | |
| } catch (err) { | |
| standupState.set(item.id, { | |
| loading: false, | |
| error: err.message, | |
| }); | |
| result.textContent = `Transcription failed: ${err.message}`; | |
| } | |
| renderStandups(currentStandups); | |
| } | |
| // --- Listeners --- | |
| loginBtn.addEventListener("click", login); | |
| logoutBtn.addEventListener("click", logout); | |
| searchBtn.addEventListener("click", searchUsers); | |
| userSearchInput.addEventListener("keydown", (e) => { | |
| if (e.key === "Enter") searchUsers(); | |
| }); | |
| dateFilter.addEventListener('change', () => { | |
| if (selectedUser) { | |
| // Re-fetch standups for the currently selected user with new date filter | |
| fetchStandupsForUser(selectedUser); | |
| } | |
| }); | |
| // Init | |
| checkAuth(); | |