| <!doctype html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>Formstr Survey</title> |
| <style> |
| *, |
| *::before, |
| *::after { |
| box-sizing: border-box; |
| margin: 0; |
| padding: 0; |
| } |
| |
| body { |
| font-family: |
| system-ui, |
| -apple-system, |
| BlinkMacSystemFont, |
| "Segoe UI", |
| sans-serif; |
| background: #f3f4f6; |
| min-height: 100vh; |
| color: #111827; |
| } |
| |
| .container { |
| max-width: 800px; |
| margin: 0 auto; |
| padding: 2.5rem 1.25rem 4rem; |
| } |
| |
| |
| .progress-header { |
| margin-bottom: 1.75rem; |
| } |
| |
| .progress-row { |
| display: flex; |
| justify-content: space-between; |
| align-items: baseline; |
| margin-bottom: 0.5rem; |
| } |
| |
| .progress-title { |
| font-size: 0.8rem; |
| font-weight: 600; |
| text-transform: uppercase; |
| letter-spacing: 0.07em; |
| color: #6b7280; |
| } |
| |
| .progress-label { |
| font-size: 0.8rem; |
| color: #6b7280; |
| } |
| |
| .progress-bar-track { |
| height: 6px; |
| background: #e5e7eb; |
| border-radius: 999px; |
| overflow: hidden; |
| } |
| |
| .progress-bar-fill { |
| height: 100%; |
| background: linear-gradient(90deg, #7c3aed, #a855f7); |
| border-radius: 999px; |
| transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1); |
| } |
| |
| |
| #form-card { |
| background: #ffffff; |
| border-radius: 16px; |
| padding: 2rem; |
| box-shadow: |
| 0 1px 3px rgba(0, 0, 0, 0.06), |
| 0 4px 16px rgba(0, 0, 0, 0.06); |
| } |
| |
| |
| .loading { |
| text-align: center; |
| padding: 3rem 0; |
| color: #9ca3af; |
| font-size: 0.95rem; |
| } |
| |
| .loading::after { |
| content: ""; |
| display: inline-block; |
| width: 1em; |
| height: 1em; |
| border: 2px solid #e5e7eb; |
| border-top-color: #7c3aed; |
| border-radius: 50%; |
| animation: spin 0.8s linear infinite; |
| vertical-align: middle; |
| margin-left: 0.5rem; |
| } |
| |
| @keyframes spin { |
| to { |
| transform: rotate(360deg); |
| } |
| } |
| |
| .status-msg { |
| padding: 0.75rem 1rem; |
| border-radius: 8px; |
| font-size: 0.9rem; |
| margin-bottom: 1rem; |
| } |
| |
| .status-msg.error { |
| background: #fef2f2; |
| color: #b91c1c; |
| border: 1px solid #fca5a5; |
| } |
| |
| .status-msg.info { |
| background: #ede9fe; |
| color: #5b21b6; |
| border: 1px solid #c4b5fd; |
| } |
| |
| |
| #submit-container { |
| display: none !important; |
| } |
| |
| .form-section { |
| margin-bottom: 1.5rem; |
| } |
| |
| .form-section:last-of-type { |
| margin-bottom: 0; |
| } |
| |
| .form-name { |
| font-size: 1.4rem; |
| font-weight: 700; |
| margin-bottom: 0.375rem; |
| line-height: 1.3; |
| } |
| |
| .form-description { |
| font-size: 0.9rem; |
| color: #6b7280; |
| line-height: 1.6; |
| } |
| |
| .section-title { |
| font-size: 1rem; |
| font-weight: 600; |
| margin-bottom: 1rem; |
| color: #374151; |
| } |
| |
| .section-description { |
| font-size: 0.875rem; |
| color: #6b7280; |
| margin-bottom: 1rem; |
| } |
| |
| |
| form label { |
| display: block; |
| font-size: 0.9rem; |
| font-weight: 500; |
| color: #374151; |
| margin-bottom: 0.35rem; |
| } |
| |
| form input[type="text"], |
| form textarea { |
| width: 100%; |
| padding: 0.6rem 0.875rem; |
| border: 1.5px solid #d1d5db; |
| border-radius: 8px; |
| font-size: 0.95rem; |
| color: #111827; |
| outline: none; |
| transition: |
| border-color 0.15s, |
| box-shadow 0.15s; |
| margin-bottom: 1rem; |
| } |
| |
| form input[type="text"]:focus, |
| form textarea:focus { |
| border-color: #7c3aed; |
| box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.12); |
| } |
| |
| form input[type="text"]:disabled { |
| background: #f9fafb; |
| border-color: #e5e7eb; |
| color: #9ca3af; |
| cursor: not-allowed; |
| font-size: 0.8rem; |
| font-style: italic; |
| overflow: hidden; |
| text-overflow: ellipsis; |
| white-space: nowrap; |
| } |
| |
| |
| .action-row { |
| margin-top: 1.75rem; |
| display: flex; |
| justify-content: flex-end; |
| align-items: center; |
| gap: 1rem; |
| } |
| |
| .next-btn { |
| padding: 0.65rem 1.75rem; |
| background: #7c3aed; |
| color: #fff; |
| border: none; |
| border-radius: 8px; |
| font-size: 0.95rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: |
| background 0.15s, |
| opacity 0.15s; |
| line-height: 1; |
| } |
| |
| .next-btn:hover:not(:disabled) { |
| background: #6d28d9; |
| } |
| |
| .next-btn:disabled { |
| opacity: 0.55; |
| cursor: not-allowed; |
| } |
| |
| |
| .done-card { |
| text-align: center; |
| padding: 2.5rem 1rem; |
| } |
| |
| .done-icon { |
| font-size: 3.5rem; |
| margin-bottom: 1rem; |
| } |
| |
| .done-title { |
| font-size: 1.75rem; |
| font-weight: 700; |
| margin-bottom: 0.6rem; |
| } |
| |
| .done-sub { |
| color: #6b7280; |
| font-size: 0.95rem; |
| } |
| |
| .done-actions { |
| margin-top: 1.5rem; |
| } |
| |
| .done-close { |
| margin-top: 1rem; |
| color: #9ca3af; |
| font-size: 0.85rem; |
| } |
| |
| |
| .lang-picker-title { |
| font-size: 1.25rem; |
| font-weight: 700; |
| margin-bottom: 0.375rem; |
| } |
| |
| .lang-picker-sub { |
| font-size: 0.9rem; |
| color: #6b7280; |
| margin-bottom: 1.5rem; |
| } |
| |
| .lang-grid { |
| display: grid; |
| grid-template-columns: 1fr 1fr; |
| gap: 0.75rem; |
| } |
| |
| .lang-btn { |
| padding: 1rem; |
| border: 2px solid #e5e7eb; |
| border-radius: 10px; |
| background: #fff; |
| font-size: 1rem; |
| font-weight: 600; |
| cursor: pointer; |
| transition: |
| border-color 0.15s, |
| background 0.15s, |
| color 0.15s; |
| text-align: center; |
| } |
| |
| .lang-btn:hover { |
| border-color: #7c3aed; |
| background: #faf5ff; |
| color: #7c3aed; |
| } |
| |
| |
| .audio-block { |
| margin: 1.25rem 0 1.5rem; |
| } |
| .audio-block audio { |
| width: 100%; |
| border-radius: 8px; |
| } |
| |
| |
| .grid-field { |
| margin-bottom: 1.25rem; |
| } |
| |
| .grid-wrapper { |
| overflow-x: auto; |
| } |
| |
| .grid-table { |
| width: 100%; |
| border-collapse: collapse; |
| font-size: 0.875rem; |
| } |
| |
| .grid-table th { |
| text-align: center; |
| padding: 0.4rem 0.6rem; |
| color: #6b7280; |
| font-weight: 600; |
| border-bottom: 1.5px solid #e5e7eb; |
| } |
| |
| .grid-table td { |
| text-align: center; |
| padding: 0.45rem 0.5rem; |
| border-bottom: 1px solid #f3f4f6; |
| } |
| |
| .grid-table td.grid-row-label { |
| text-align: left; |
| color: #374151; |
| font-weight: 500; |
| padding-left: 0; |
| min-width: 140px; |
| } |
| |
| .grid-table input[type="radio"] { |
| accent-color: #7c3aed; |
| width: 1rem; |
| height: 1rem; |
| cursor: pointer; |
| } |
| |
| |
| .option-group { |
| margin-bottom: 1.25rem; |
| } |
| |
| .option-label { |
| font-size: 0.9rem; |
| font-weight: 500; |
| color: #374151; |
| margin-bottom: 0.6rem; |
| } |
| |
| .option-group label { |
| display: flex; |
| align-items: center; |
| gap: 0.6rem; |
| padding: 0.55rem 0.75rem; |
| border: 1.5px solid #e5e7eb; |
| border-radius: 8px; |
| cursor: pointer; |
| font-size: 0.9rem; |
| font-weight: 400; |
| color: #374151; |
| margin-bottom: 0.4rem; |
| transition: |
| border-color 0.15s, |
| background 0.15s; |
| } |
| |
| .option-group label:hover { |
| border-color: #a78bfa; |
| background: #faf5ff; |
| } |
| |
| .option-group input[type="radio"] { |
| accent-color: #7c3aed; |
| width: 1rem; |
| height: 1rem; |
| flex-shrink: 0; |
| margin: 0; |
| } |
| |
| |
| .disclaimer-section { |
| padding-bottom: 1.5rem; |
| margin-bottom: 1.5rem; |
| border-bottom: 1px solid #e5e7eb; |
| } |
| .survey-description { |
| margin-bottom: 0.25rem; |
| padding-bottom: 0.75rem; |
| border-bottom: 1px solid #e5e7eb; |
| } |
| .disclaimer-heading { |
| font-size: 0.7rem; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| color: #9ca3af; |
| margin-bottom: 0.6rem; |
| } |
| .disclaimer { |
| font-size: 0.9rem; |
| color: #374151; |
| line-height: 1.7; |
| white-space: pre-line; |
| } |
| |
| |
| .spinner { |
| display: inline-block; |
| width: 1em; |
| height: 1em; |
| border: 2px solid rgba(255, 255, 255, 0.4); |
| border-top-color: #fff; |
| border-radius: 50%; |
| animation: spin 0.7s linear infinite; |
| vertical-align: middle; |
| margin-right: 0.4rem; |
| } |
| |
| |
| .form-footer { |
| margin-top: 1.5rem; |
| padding-top: 1rem; |
| border-top: 1px solid #e5e7eb; |
| text-align: center; |
| font-size: 0.8rem; |
| color: #9ca3af; |
| } |
| .form-footer a { |
| color: #7c3aed; |
| text-decoration: none; |
| } |
| .form-footer a:hover { |
| text-decoration: underline; |
| } |
| |
| |
| .form-body p ul, |
| .form-body p ol { |
| margin: 0.5rem 0 0.5rem 1.5rem; |
| padding-left: 0.5rem; |
| } |
| .form-body p li { |
| margin-bottom: 0.25rem; |
| line-height: 1.5; |
| } |
| </style> |
| </head> |
|
|
| <body> |
| <div class="container"> |
| <div class="progress-header" id="progress-header" style="display: none"> |
| <div class="progress-row"> |
| <span class="progress-title">Progress</span> |
| <span class="progress-label" id="progress-label" |
| >0 / 10 responses submitted</span |
| > |
| </div> |
| <div class="progress-bar-track"> |
| <div |
| class="progress-bar-fill" |
| id="progress-fill" |
| style="width: 0%" |
| ></div> |
| </div> |
| </div> |
|
|
| <div id="form-card"></div> |
| </div> |
|
|
| <script type="module"> |
| import { FormstrSDK } from "https://esm.sh/@formstr/sdk"; |
| import { |
| SimplePool, |
| nip19, |
| nip44, |
| } from "https://esm.sh/nostr-tools@2.3.2"; |
| import { marked } from "https://esm.sh/marked@12.0.0"; |
| |
| |
| function parseMarkdown(text) { |
| if (!text) return ""; |
| return marked.parse(text); |
| } |
| |
| |
| const DISCLAIMER = `By participating in this survey, you agree that your anonymized responses may be used for academic research as part of a master's thesis. No personally identifiable information will be collected.\n\nPlease select only one language that you are a native speaker of or fluent in at a bilingual level. <strong style="color:#dc2626;">⚠️ Do not proceed if you are not proficient in any of the listed languages, as inaccurate responses may affect the validity of the survey results.</strong>\n\nEach audio clip you hear may not exceed 30 seconds.\n\nIf you speak multiple languages, please complete the survey for one language first. You will have the option to select another language and complete a new survey afterward.`; |
| |
| |
| |
| const HF_DATASET = "chaurAr/crosslingual-asr-entity-benchmark"; |
| |
| const METADATA_URL= 'https://huggingface.co/datasets/chaurAr/crosslingual-asr-entity-benchmark/raw/main/metadata.csv' |
| |
| const LANGUAGE_CODE_MAP = { |
| "French": "fr", |
| "German": "de", |
| "Spanish": "es", |
| "Italian": "it", |
| }; |
| |
| const TOTAL = 10; |
| const NADDR = |
| "naddr1qvzqqqr4mqpzqgqewrpkjx0nx68trtvh5hh7n3ed8hc0zdmvj388us6geknfjtz0qythwumn8ghj7un9d3shjtnswf5k6ctv9ehx2ap0qy88wumn8ghj7mn0wvhxcmmv9uq3uamnwvaz7tmjv4kxz7fwdehhxarj9emkjun9v3hx2apwdfcz7qgawaehxw309ahx7um5wgknqvfw09skk6tgdahxuefwvdhk6tcqqeshyn3ewdfq7r75cv"; |
| const VIEW_KEY = |
| "a68710cefc1607225f0d754e054d2e5e49dca7ca74e6f1c1698a647a9a8f9c16"; |
| |
| const DEFAULT_RELAYS = [ |
| "wss://relay.damus.io/", |
| "wss://relay.primal.net/", |
| "wss://nos.lol", |
| "wss://relay.nostr.band", |
| "wss://relay.snort.social", |
| ]; |
| |
| function hexToBytes(hex) { |
| const bytes = new Uint8Array(hex.length / 2); |
| for (let i = 0; i < hex.length; i += 2) |
| bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); |
| return bytes; |
| } |
| |
| |
| |
| async function fetchFormManually(naddr, viewKeyHex) { |
| const sdk = new FormstrSDK(); |
| const pool = new SimplePool(); |
| |
| const decoded = nip19.decode(naddr).data; |
| const { pubkey, identifier, relays } = decoded; |
| const relayList = relays?.length ? relays : DEFAULT_RELAYS; |
| |
| const event = await pool.get(relayList, { |
| kinds: [30168], |
| authors: [pubkey], |
| "#d": [identifier], |
| }); |
| |
| if (!event) throw new Error("Form event not found on relays"); |
| |
| |
| if (event.content === "") { |
| const tags = [...event.tags, ["pubkey", event.pubkey]]; |
| return sdk.normalizeForm(tags); |
| } |
| |
| |
| const viewKeyBytes = hexToBytes(viewKeyHex); |
| const convKey = nip44.v2.utils.getConversationKey( |
| viewKeyBytes, |
| event.pubkey, |
| ); |
| const decrypted = nip44.v2.decrypt(event.content, convKey); |
| const decryptedTags = JSON.parse(decrypted); |
| const relayTags = event.tags.filter((t) => t[0] === "relay"); |
| decryptedTags.push(...relayTags, ["pubkey", event.pubkey]); |
| |
| |
| |
| |
| const rawFieldExtras = {}; |
| decryptedTags.forEach((tag) => { |
| if (tag[0] !== "field") return; |
| const [, fieldId, type, label, optionsVal, configVal] = tag; |
| let parsedOptions = null; |
| try { |
| parsedOptions = JSON.parse(optionsVal); |
| } catch {} |
| if ( |
| parsedOptions !== null && |
| !Array.isArray(parsedOptions) && |
| typeof parsedOptions === "object" |
| ) { |
| let parsedConfig = {}; |
| try { |
| parsedConfig = JSON.parse(configVal); |
| } catch {} |
| rawFieldExtras[fieldId] = { |
| type, |
| label, |
| options: parsedOptions, |
| config: parsedConfig, |
| }; |
| } |
| }); |
| |
| |
| |
| const normalizedTags = decryptedTags.map((tag) => { |
| if (tag[0] !== "field") return tag; |
| return tag.map((val, idx) => { |
| if (idx < 4) return val; |
| const str = |
| val !== null && typeof val !== "string" |
| ? JSON.stringify(val) |
| : val; |
| if (idx === 4) { |
| try { |
| if (!Array.isArray(JSON.parse(str))) return null; |
| } catch { |
| return null; |
| } |
| } |
| return str; |
| }); |
| }); |
| |
| const form = sdk.normalizeForm(normalizedTags); |
| form._rawFieldExtras = rawFieldExtras; |
| return form; |
| } |
| |
| const sdk = new FormstrSDK(); |
| const card = document.getElementById("form-card"); |
| |
| |
| const SESSION_ID_KEY = "formstr_session_id"; |
| function getOrCreateSessionId() { |
| let id = localStorage.getItem(SESSION_ID_KEY); |
| if (!id) { |
| const bytes = new Uint8Array(32); |
| crypto.getRandomValues(bytes); |
| id = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join( |
| "", |
| ); |
| localStorage.setItem(SESSION_ID_KEY, id); |
| } |
| return id; |
| } |
| const SESSION_ID = getOrCreateSessionId(); |
| |
| function applyUniqueIdField() { |
| const field = Object.values(formDef.fields).find( |
| (f) => f.labelHtml.trim() === "Unique Id", |
| ); |
| if (!field) return; |
| |
| const input = document.querySelector(`input[name="${field.id}"]`); |
| if (!input) return; |
| |
| input.value = SESSION_ID; |
| |
| |
| const label = input.previousElementSibling; |
| if (label?.tagName === "LABEL") label.style.display = "none"; |
| input.style.display = "none"; |
| } |
| |
| function applyLanguageField() { |
| const field = Object.values(formDef.fields).find( |
| (f) => f.labelHtml.trim().toLowerCase() === "language", |
| ); |
| if (!field) return; |
| |
| const input = document.querySelector(`input[name="${field.id}"]`); |
| if (!input) return; |
| |
| input.value = selectedLanguage; |
| |
| |
| const label = input.previousElementSibling; |
| if (label?.tagName === "LABEL") label.style.display = "none"; |
| input.style.display = "none"; |
| } |
| |
| let formDef = null; |
| let submitted = 0; |
| let selectedLanguage = null; |
| let audioSamples = []; |
| |
| |
| function parseCSVLine(line) { |
| const result = []; |
| let current = ''; |
| let inQuotes = false; |
| |
| for (let i = 0; i < line.length; i++) { |
| const char = line[i]; |
| if (char === '"') { |
| inQuotes = !inQuotes; |
| } else if (char === ',' && !inQuotes) { |
| result.push(current.trim()); |
| current = ''; |
| } else { |
| current += char; |
| } |
| } |
| result.push(current.trim()); |
| return result; |
| } |
| |
| |
| async function fetchAudioSamples(language) { |
| const langCode = LANGUAGE_CODE_MAP[language]; |
| if (!langCode) { |
| throw new Error(`Unknown language: ${language}`); |
| } |
| |
| const response = await fetch(METADATA_URL); |
| if (!response.ok) { |
| throw new Error(`Failed to fetch metadata: ${response.statusText}`); |
| } |
| |
| const csvText = await response.text(); |
| const lines = csvText.trim().split("\n"); |
| |
| |
| const files = []; |
| for (let i = 1; i < lines.length; i++) { |
| const line = lines[i].trim(); |
| if (!line) continue; |
| |
| const parts = parseCSVLine(line); |
| if (parts.length < 3) continue; |
| |
| const file = parts[0]; |
| const lang = parts[1]; |
| const namedEntity = parts[2]; |
| |
| if (lang === langCode) { |
| files.push({ file, namedEntity }); |
| } |
| } |
| |
| if (files.length === 0) { |
| throw new Error(`No audio files found for language: ${language}`); |
| } |
| |
| |
| const shuffled = files.sort(() => Math.random() - 0.5); |
| return shuffled.slice(0, TOTAL); |
| } |
| |
| |
| function getAudioUrl(audioSample) { |
| const filePath = audioSample.file; |
| const fileName = filePath.split("/").pop(); |
| return `https://huggingface.co/datasets/${HF_DATASET}/resolve/main/${filePath}`; |
| } |
| |
| |
| function getNamedEntity() { |
| const audioSample = audioSamples[submitted]; |
| return audioSample?.namedEntity || ''; |
| } |
| |
| function updateProgress() { |
| document.getElementById("progress-label").textContent = |
| `${submitted} / ${TOTAL} responses submitted`; |
| document.getElementById("progress-fill").style.width = |
| `${(submitted / TOTAL) * 100}%`; |
| } |
| |
| function applyAudioFields(audioUrl) { |
| Object.values(formDef.fields).forEach((field) => { |
| if (!field.labelHtml.includes("Audio")) return; |
| const input = document.querySelector(`input[name="${field.id}"]`); |
| if (!input) return; |
| input.value = audioUrl; |
| |
| input.style.display = "none"; |
| const label = input.previousElementSibling; |
| if (label?.tagName === "LABEL") label.style.display = "none"; |
| }); |
| } |
| |
| function renderGridField(fieldId, raw, namedEntity = '') { |
| const { label, options: g } = raw; |
| |
| const rowsRaw = g.rows ?? []; |
| const colsRaw = g.columns ?? g.cols ?? []; |
| |
| const norm = (items) => |
| items.map((item) => |
| Array.isArray(item) |
| ? { id: String(item[0]), label: String(item[1]) } |
| : { |
| id: String(item.id ?? item), |
| label: String(item.label ?? item), |
| }, |
| ); |
| |
| const rows = norm(rowsRaw); |
| const cols = norm(colsRaw); |
| const n = cols.length; |
| |
| |
| const headerCells = cols.map((c) => `<th>${c.label}</th>`).join(""); |
| const bodyRows = rows |
| .map( |
| (r, index) => { |
| let rowLabel = r.label; |
| |
| if (index === 2 && namedEntity) { |
| rowLabel += ` <em>"<strong style="color:#6b7280;">${namedEntity}</strong>"</em>?`; |
| } |
| return ` |
| <tr> |
| <td class="grid-row-label">${rowLabel}</td> |
| ${cols.map((c, i) => `<td><input type="radio" name="${fieldId}__${r.id}" value="${i + 1}"></td>`).join("")} |
| </tr>`; |
| }, |
| ) |
| .join(""); |
| |
| return ` |
| <div class="grid-field"> |
| <div class="option-label">${label}</div> |
| <div class="grid-wrapper"> |
| <table class="grid-table"> |
| <thead><tr><th></th>${headerCells}</tr></thead> |
| <tbody>${bodyRows}</tbody> |
| </table> |
| </div> |
| </div>`; |
| } |
| |
| function renderForm() { |
| updateProgress(); |
| |
| if (submitted >= TOTAL) { |
| card.innerHTML = ` |
| <div class="done-card"> |
| <div class="done-icon">🎉</div> |
| <div class="done-title">All done!</div> |
| <div class="done-sub">All 10 responses have been submitted. Thank you!<br /><br />If you are a native or near-native speaker of a <strong>different language</strong> than the one you just completed, you can continue with another language.</div> |
| <div class="done-actions"> |
| <button class="next-btn" id="restart-btn">Continue with Another Language</button> |
| </div> |
| <div class="done-close">Otherwise, you may close this page now.</div> |
| </div> |
| `; |
| document.getElementById("restart-btn").addEventListener("click", () => { |
| submitted = 0; |
| selectedLanguage = null; |
| audioSamples = []; |
| document.getElementById("progress-header").style.display = "none"; |
| showLanguagePicker(); |
| }); |
| return; |
| } |
| |
| sdk.renderHtml(formDef); |
| |
| const isLast = submitted === TOTAL - 1; |
| const formHtml = formDef.html.form.replace( |
| /\[Audio\]/g, |
| `${selectedLanguage} Audio will come here`, |
| ); |
| |
| card.innerHTML = ` |
| <div id="status-msg" class="status-msg" style="display:none"></div> |
| ${formHtml} |
| <div class="action-row"> |
| <button class="next-btn" id="next-btn"> |
| ${isLast ? "Submit" : "Next →"} |
| </button> |
| </div> |
| <div class="form-footer"> |
| Powered by <a href="https://about.formstr.app/" target="_blank" rel="noopener">form*</a> |
| </div> |
| `; |
| |
| applyUniqueIdField(); |
| applyLanguageField(); |
| |
| |
| document.querySelectorAll(`#form-${formDef.id} p`).forEach((label) => { |
| const originalText = label.getAttribute('data-original') || label.innerHTML; |
| if (!label.getAttribute('data-original')) { |
| label.setAttribute('data-original', originalText); |
| } |
| |
| |
| if (originalText.includes('**') || originalText.includes('*') || originalText.includes('`') || originalText.includes('#')) { |
| label.innerHTML = parseMarkdown(originalText); |
| } |
| }); |
| |
| |
| |
| const audioFile = audioSamples[submitted]; |
| const audioUrl = getAudioUrl(audioFile); |
| |
| applyAudioFields(audioUrl); |
| const introSection = document.querySelector( |
| `#form-${formDef.id} .form-intro`, |
| ); |
| if (introSection) { |
| introSection.insertAdjacentHTML( |
| "afterend", |
| ` |
| <div class="audio-block"> |
| <audio controls src="${audioUrl}"></audio> |
| </div> |
| `, |
| ); |
| } |
| |
| |
| |
| const extras = formDef._rawFieldExtras ?? {}; |
| const formBody = document.querySelector( |
| `#form-${formDef.id} .form-body`, |
| ); |
| |
| |
| let gridFieldsHtml = ''; |
| Object.entries(extras).forEach(([fieldId, raw]) => { |
| const namedEntity = getNamedEntity(); |
| gridFieldsHtml += renderGridField(fieldId, raw, namedEntity); |
| }); |
| |
| |
| if (formBody) { |
| const optionGroups = formBody.querySelectorAll('.option-group'); |
| const optionGroupsArray = Array.from(optionGroups); |
| |
| |
| optionGroupsArray.forEach(og => og.remove()); |
| |
| |
| formBody.insertAdjacentHTML("beforeend", gridFieldsHtml); |
| optionGroupsArray.forEach(og => formBody.appendChild(og)); |
| |
| |
| const allLabels = formBody.querySelectorAll('label'); |
| allLabels.forEach(label => { |
| if (label.textContent.includes('additional comments')) { |
| |
| const nextSibling = label.nextElementSibling; |
| if (nextSibling && (nextSibling.tagName === 'INPUT' || nextSibling.tagName === 'TEXTAREA')) { |
| |
| const container = document.createElement('div'); |
| container.appendChild(label); |
| container.appendChild(nextSibling); |
| formBody.appendChild(container); |
| } |
| } |
| }); |
| } |
| |
| |
| function validateForm() { |
| const missingFields = []; |
| |
| |
| const gridTables = document.querySelectorAll('.grid-table'); |
| gridTables.forEach(table => { |
| const rows = table.querySelectorAll('tbody tr'); |
| rows.forEach(row => { |
| const radios = row.querySelectorAll('input[type="radio"]'); |
| const isAnswered = Array.from(radios).some(r => r.checked); |
| if (!isAnswered) { |
| const labelCell = row.querySelector('.grid-row-label'); |
| const fieldLabel = labelCell ? labelCell.textContent.trim() : 'Grid question'; |
| missingFields.push(fieldLabel); |
| } |
| }); |
| }); |
| |
| |
| const optionGroups = document.querySelectorAll('.option-group'); |
| optionGroups.forEach(og => { |
| const radios = og.querySelectorAll('input[type="radio"]'); |
| const isAnswered = Array.from(radios).some(r => r.checked); |
| if (!isAnswered) { |
| const label = og.querySelector('.option-label'); |
| const fieldLabel = label ? label.textContent.trim() : 'Option question'; |
| missingFields.push(fieldLabel); |
| } |
| }); |
| |
| return missingFields; |
| } |
| |
| |
| function showValidationError() { |
| alert("Please answer all questions before moving forward"); |
| } |
| |
| document |
| .getElementById("next-btn") |
| .addEventListener("click", async () => { |
| const btn = document.getElementById("next-btn"); |
| const statusEl = document.getElementById("status-msg"); |
| |
| |
| const missingFields = validateForm(); |
| if (missingFields.length > 0) { |
| showValidationError(); |
| return; |
| } |
| |
| btn.disabled = true; |
| btn.innerHTML = `<span class="spinner"></span>${isLast ? "Submitting…" : "Saving…"}`; |
| statusEl.style.display = "none"; |
| |
| const formEl = document.getElementById(`form-${formDef.id}`); |
| const fd = new FormData(formEl); |
| const values = {}; |
| fd.forEach((v, k) => { |
| values[k] = v; |
| }); |
| |
| try { |
| await sdk.submit(formDef, values); |
| submitted++; |
| renderForm(); |
| } catch (err) { |
| console.error("Submission failed:", err); |
| statusEl.textContent = "Submission failed — please try again."; |
| statusEl.className = "status-msg error"; |
| statusEl.style.display = "block"; |
| btn.disabled = false; |
| btn.textContent = isLast ? "Submit" : "Next →"; |
| } |
| }); |
| } |
| |
| |
| const SURVEY_DES = "In this survey, you will evaluate <strong>10 audio samples</strong>. Each audio clip contains few sentences in your selected language with an embedded English-language entity (e.g., a name or term). You will rate the overall audio quality as well as how naturally this entity is pronounced within the sentence. For each audio, you need to answer <strong>4 questions</strong> related to 3 aspects:\n\n<strong>Pronunciation</strong> – How clearly and correctly the words are pronounced\n<strong>Grammar</strong> – How grammatically correct the speech sounds\n<strong>Naturalness</strong> – How natural the overall speech sounds\n\n<strong>Rating Scale:</strong> 1 (Poor) to 5 (Excellent)\n\nPlease listen to each audio carefully before providing your ratings."; |
| |
| function showLanguagePicker() { |
| const languages = ["French", "German", "Italian", "Spanish"]; |
| card.innerHTML = ` |
| <div class="disclaimer-section"> |
| <div class="disclaimer-heading">Before you begin</div> |
| <div class="disclaimer">${DISCLAIMER}</div> |
| </div> |
| <div class="survey-description"> |
| <div class="disclaimer-heading">About this Survey</div> |
| <div class="disclaimer"> |
| ${SURVEY_DES} |
| </div> |
| </div> |
| <div class="lang-picker-title">Choose a language</div> |
| <div class="lang-picker-sub">Select the language for this survey session.</div> |
| <div class="lang-grid"> |
| ${languages.map((l) => `<button class="lang-btn" data-lang="${l}">${l}</button>`).join("")} |
| </div> |
| `; |
| card.querySelectorAll(".lang-btn").forEach((btn) => { |
| btn.addEventListener("click", async () => { |
| selectedLanguage = btn.dataset.lang; |
| card.innerHTML = `<div class="loading">Loading audio samples...</div>`; |
| document.getElementById("progress-header").style.display = ""; |
| try { |
| |
| audioSamples = await fetchAudioSamples(selectedLanguage); |
| formDef = await fetchFormManually(NADDR, VIEW_KEY); |
| renderForm(); |
| } catch (err) { |
| console.error("Failed to load:", err); |
| card.innerHTML = `<div class="status-msg error">Failed to load: ${err.message}</div>`; |
| } |
| }); |
| }); |
| } |
| |
| showLanguagePicker(); |
| </script> |
| </body> |
| </html> |
|
|