| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width,initial-scale=1"> |
| <title>Audio Recording Studio</title> |
| <style> |
| /* ββ Reset & base βββββββββββββββββββββββββββββββββββββββββββ */ |
| *{box-sizing:border-box;margin:0;padding:0} |
| :root{ |
| --bg:#f5f6fa;--surface:#fff;--border:#e2e5f1; |
| --primary:#4361ee;--primary-hover:#3a56d4; |
| --danger:#ef476f;--danger-hover:#d63a5e; |
| --success:#06d6a0;--text:#1e1e2f;--muted:#6c7293; |
| --radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08); |
| } |
| body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.5} |
|
|
| /* ββ Layout βββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| .app{display:flex;min-height:100vh} |
| .sidebar{width:340px;background:var(--surface);border-right:1px solid var(--border);padding:24px;display:flex;flex-direction:column;gap:16px;position:sticky;top:0;height:100vh;overflow-y:auto} |
| .main{flex:1;padding:24px;overflow-y:auto} |
|
|
| /* ββ Sidebar ββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| .logo{font-size:20px;font-weight:700;color:var(--primary);display:flex;align-items:center;gap:8px} |
| .logo svg{width:28px;height:28px} |
|
|
| .field label{display:block;font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px} |
| .field input,.field textarea{width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px;outline:none;transition:border .15s} |
| .field input:focus,.field textarea:focus{border-color:var(--primary)} |
| .field textarea{resize:vertical;min-height:56px} |
|
|
| .timer{text-align:center;font-size:40px;font-weight:300;font-variant-numeric:tabular-nums;color:var(--muted);padding:8px 0} |
| .timer.active{color:var(--danger);font-weight:400} |
|
|
| canvas#waveform{width:100%;height:40px;border-radius:6px;background:#f0f1f6} |
|
|
| .rec-controls{display:flex;gap:10px} |
| .rec-controls button{flex:1} |
| button{padding:10px 16px;font-size:14px;font-weight:600;border:none;border-radius:var(--radius);cursor:pointer;transition:background .15s,opacity .15s} |
| button:disabled{opacity:.4;cursor:not-allowed} |
| .btn-record{background:var(--danger);color:#fff} |
| .btn-record:hover:not(:disabled){background:var(--danger-hover)} |
| .btn-stop{background:var(--muted);color:#fff} |
| .btn-stop:hover:not(:disabled){background:#555a78} |
| .btn-primary{background:var(--primary);color:#fff} |
| .btn-primary:hover:not(:disabled){background:var(--primary-hover)} |
| .btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)} |
| .btn-outline:hover{background:#f0f1f6} |
| .btn-danger-sm{background:none;border:none;color:var(--danger);cursor:pointer;font-size:13px;padding:4px 8px;border-radius:4px} |
| .btn-danger-sm:hover{background:#fde8ee} |
|
|
| .sidebar audio{width:100%;border-radius:6px} |
| .hidden{display:none} |
|
|
| /* ββ Table ββββββββββββββββββββββββββββββββββββββββββββββββββ */ |
| .toolbar{display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap} |
| .toolbar h2{font-size:18px;font-weight:600;margin-right:auto} |
| .search-box{padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px;width:220px;outline:none} |
| .search-box:focus{border-color:var(--primary)} |
| .badge{background:var(--primary);color:#fff;font-size:12px;padding:2px 8px;border-radius:12px;font-weight:600} |
|
|
| table{width:100%;border-collapse:collapse;background:var(--surface);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)} |
| thead th{text-align:left;padding:12px 14px;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);border-bottom:2px solid var(--border);background:#fafbfe;white-space:nowrap} |
| tbody td{padding:10px 14px;border-bottom:1px solid var(--border);font-size:14px;vertical-align:middle} |
| tbody tr:hover{background:#f8f9ff} |
| tbody tr.playing{background:#eef1ff} |
| .cell-user{font-weight:600;color:var(--primary)} |
| .cell-name{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| .cell-notes{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:13px} |
| .cell-duration{font-variant-numeric:tabular-nums;white-space:nowrap} |
| .cell-actions{white-space:nowrap} |
| .play-btn{background:none;border:none;cursor:pointer;font-size:18px;padding:2px 6px;border-radius:4px} |
| .play-btn:hover{background:#eef1ff} |
| .empty-state{text-align:center;padding:60px 20px;color:var(--muted)} |
| .empty-state p{font-size:15px;margin-top:8px} |
|
|
| /* ββ Inline edit ββββββββββββββββββββββββββββββββββββββββββββ */ |
| .editable{cursor:pointer;border-bottom:1px dashed transparent;transition:border .15s} |
| .editable:hover{border-bottom-color:var(--primary)} |
|
|
| /* ββ Responsive βββββββββββββββββββββββββββββββββββββββββββββ */ |
| @media(max-width:768px){ |
| .app{flex-direction:column} |
| .sidebar{width:100%;height:auto;position:static;border-right:none;border-bottom:1px solid var(--border)} |
| .main{padding:16px} |
| .search-box{width:100%} |
| } |
| </style> |
| </head> |
| <body> |
| <div class="app"> |
|
|
| |
| <div class="sidebar"> |
| <div class="logo"> |
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.93 4.93l1.41 1.41m11.32 11.32l1.41 1.41M2 12h2m16 0h2M4.93 19.07l1.41-1.41m11.32-11.32l1.41-1.41"/></svg> |
| Audio Recording Studio |
| </div> |
|
|
| <div class="field"> |
| <label for="userName">Your Name</label> |
| <input type="text" id="userName" placeholder="e.g. Alice" autocomplete="off"> |
| </div> |
| <div class="field"> |
| <label for="recName">Recording Name</label> |
| <input type="text" id="recName" placeholder="e.g. Interview β Session 3" autocomplete="off"> |
| </div> |
| <div class="field"> |
| <label for="recNotes">Notes (optional)</label> |
| <textarea id="recNotes" placeholder="Any contextβ¦"></textarea> |
| </div> |
|
|
| <div class="timer" id="timer">00:00</div> |
| <canvas id="waveform"></canvas> |
|
|
| <div class="rec-controls"> |
| <button class="btn-record" id="btnRecord">Record</button> |
| <button class="btn-stop" id="btnStop" disabled>Stop</button> |
| </div> |
|
|
| <audio id="preview" controls class="hidden"></audio> |
| <button class="btn-primary" id="btnSave" disabled>Save Recording</button> |
| <div id="sideStatus" style="text-align:center;font-size:13px;min-height:18px"></div> |
| </div> |
|
|
| |
| <div class="main"> |
| <div class="toolbar"> |
| <h2>Recordings <span class="badge" id="countBadge">0</span></h2> |
| <input class="search-box" id="searchBox" placeholder="Searchβ¦" autocomplete="off"> |
| <button class="btn-outline" id="btnRefresh">Refresh</button> |
| <button class="btn-outline" id="btnExport">Export Excel</button> |
| <button class="btn-outline" id="btnExportZip">Download All (ZIP)</button> |
| </div> |
|
|
| <table> |
| <thead> |
| <tr> |
| <th>#</th><th>User</th><th>Name</th><th>Timestamp</th><th>Duration</th><th>Notes</th><th>Play</th><th>Actions</th> |
| </tr> |
| </thead> |
| <tbody id="tableBody"> |
| <tr><td colspan="8" class="empty-state"><p>No recordings yet. Start recording from the sidebar.</p></td></tr> |
| </tbody> |
| </table> |
| </div> |
|
|
| </div> |
|
|
| |
| <audio id="tablePlayer"></audio> |
|
|
| <script> |
| (function(){ |
| |
| var $ = document.getElementById.bind(document); |
| var btnRecord=$("btnRecord"),btnStop=$("btnStop"),btnSave=$("btnSave"); |
| var preview=$("preview"),timerEl=$("timer"),statusEl=$("sideStatus"); |
| var userName=$("userName"),recName=$("recName"),recNotes=$("recNotes"); |
| var canvas=$("waveform"),ctx=canvas.getContext("2d"); |
| var tableBody=$("tableBody"),countBadge=$("countBadge"); |
| var searchBox=$("searchBox"),tablePlayer=$("tablePlayer"); |
| |
| var mediaRecorder,audioChunks,recordedBlob; |
| var timerInt,startTime,recordedDuration,analyser,animFrame,audioCtx,stream; |
| var allRecordings=[]; |
| var playingId=null; |
| |
| |
| userName.value=localStorage.getItem("audioStudioUser")||""; |
| userName.addEventListener("input",function(){localStorage.setItem("audioStudioUser",userName.value)}); |
| |
| |
| function resetTimer(){timerEl.textContent="00:00";timerEl.classList.remove("active")} |
| function startTimerFn(){ |
| timerEl.classList.add("active"); |
| timerInt=setInterval(function(){ |
| var s=Math.floor((Date.now()-startTime)/1000); |
| timerEl.textContent=String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0"); |
| },250); |
| } |
| function stopTimerFn(){clearInterval(timerInt);timerEl.classList.remove("active")} |
| |
| |
| function resizeCanvas(){canvas.width=canvas.clientWidth*(devicePixelRatio||1);canvas.height=canvas.clientHeight*(devicePixelRatio||1)} |
| resizeCanvas();window.addEventListener("resize",resizeCanvas); |
| function drawWave(){ |
| if(!analyser)return;animFrame=requestAnimationFrame(drawWave); |
| var buf=new Uint8Array(analyser.fftSize);analyser.getByteTimeDomainData(buf); |
| var w=canvas.width,h=canvas.height;ctx.clearRect(0,0,w,h); |
| ctx.lineWidth=2;ctx.strokeStyle="#4361ee";ctx.beginPath(); |
| var dx=w/buf.length,x=0; |
| for(var i=0;i<buf.length;i++){var y=buf[i]/128*h/2;i?ctx.lineTo(x,y):ctx.moveTo(x,y);x+=dx} |
| ctx.lineTo(w,h/2);ctx.stroke(); |
| } |
| function stopVis(){cancelAnimationFrame(animFrame);ctx.clearRect(0,0,canvas.width,canvas.height)} |
| function setStatus(m,ok){statusEl.textContent=m;statusEl.style.color=ok?"#06d6a0":ok===false?"#ef476f":"#6c7293"} |
| |
| |
| btnRecord.addEventListener("click",function(){ |
| setStatus("");audioChunks=[];recordedBlob=null; |
| preview.classList.add("hidden");preview.src="";btnSave.disabled=true; |
| |
| navigator.mediaDevices.getUserMedia({audio:true}).then(function(s){ |
| stream=s; |
| audioCtx=new(window.AudioContext||window.webkitAudioContext)(); |
| var src=audioCtx.createMediaStreamSource(s); |
| analyser=audioCtx.createAnalyser();analyser.fftSize=2048; |
| src.connect(analyser);drawWave(); |
| |
| var mime="audio/webm;codecs=opus"; |
| if(!MediaRecorder.isTypeSupported(mime))mime="audio/webm"; |
| if(!MediaRecorder.isTypeSupported(mime))mime=""; |
| |
| mediaRecorder=new MediaRecorder(s,mime?{mimeType:mime}:undefined); |
| mediaRecorder.ondataavailable=function(e){if(e.data.size>0)audioChunks.push(e.data)}; |
| mediaRecorder.onstop=function(){ |
| recordedBlob=new Blob(audioChunks,{type:"audio/webm"}); |
| preview.src=URL.createObjectURL(recordedBlob); |
| preview.classList.remove("hidden"); |
| btnSave.disabled=false; |
| setStatus("Ready to save."); |
| }; |
| mediaRecorder.start(1000); |
| startTime=Date.now();recordedDuration=0;startTimerFn(); |
| btnRecord.disabled=true;btnStop.disabled=false; |
| setStatus("Recordingβ¦"); |
| }).catch(function(e){setStatus("Mic denied: "+e.message,false)}); |
| }); |
| |
| btnStop.addEventListener("click",function(){ |
| recordedDuration=(Date.now()-startTime)/1000; |
| if(mediaRecorder&&mediaRecorder.state!=="inactive")mediaRecorder.stop(); |
| stopTimerFn();stopVis(); |
| if(stream){stream.getTracks().forEach(function(t){t.stop()});stream=null} |
| if(audioCtx){audioCtx.close();audioCtx=null} |
| btnRecord.disabled=false;btnStop.disabled=true; |
| }); |
| |
| |
| btnSave.addEventListener("click",function(){ |
| if(!recordedBlob)return; |
| btnSave.disabled=true;setStatus("Uploadingβ¦"); |
| |
| var fd=new FormData(); |
| fd.append("audio",recordedBlob,"recording.webm"); |
| fd.append("user",userName.value.trim()||"Anonymous"); |
| fd.append("name",recName.value.trim()); |
| fd.append("notes",recNotes.value.trim()); |
| fd.append("duration",(recordedDuration||0).toFixed(1)); |
| |
| fetch("/api/recordings",{method:"POST",body:fd}) |
| .then(function(r){if(!r.ok)throw new Error(r.statusText);return r.json()}) |
| .then(function(){ |
| setStatus("Saved!",true); |
| recName.value="";recNotes.value=""; |
| recordedBlob=null;preview.classList.add("hidden");preview.src=""; |
| resetTimer();loadTable(); |
| }) |
| .catch(function(e){setStatus("Error: "+e.message,false);btnSave.disabled=false}); |
| }); |
| |
| |
| function loadTable(){ |
| fetch("/api/recordings").then(function(r){return r.json()}).then(function(data){ |
| allRecordings=data;renderTable(data); |
| }); |
| } |
| |
| function renderTable(data){ |
| var q=searchBox.value.toLowerCase(); |
| var filtered=q?data.filter(function(r){ |
| return (r.name+r.user+r.notes+r.created_at).toLowerCase().indexOf(q)>=0; |
| }):data; |
| |
| countBadge.textContent=filtered.length; |
| |
| if(!filtered.length){ |
| tableBody.innerHTML='<tr><td colspan="8" class="empty-state"><p>No recordings found.</p></td></tr>'; |
| return; |
| } |
| var html=""; |
| filtered.forEach(function(r,i){ |
| var dur=r.duration?fmtDur(r.duration):"β"; |
| var playing=playingId===r.id; |
| html+='<tr class="'+(playing?"playing":"")+'" data-id="'+r.id+'">' |
| +'<td>'+(i+1)+'</td>' |
| +'<td class="cell-user">'+esc(r.user)+'</td>' |
| +'<td class="cell-name editable" data-field="name" title="Click to edit">'+esc(r.name)+'</td>' |
| +'<td>'+esc(r.created_at)+'</td>' |
| +'<td class="cell-duration">'+dur+'</td>' |
| +'<td class="cell-notes editable" data-field="notes" title="Click to edit">'+esc(r.notes||"β")+'</td>' |
| +'<td><button class="play-btn" data-file="'+esc(r.filename)+'" data-rid="'+r.id+'">'+(playing?"\u23F9":"\u25B6")+'</button></td>' |
| +'<td class="cell-actions"><button class="btn-danger-sm" data-del="'+r.id+'">Delete</button></td>' |
| +'</tr>'; |
| }); |
| tableBody.innerHTML=html; |
| } |
| |
| |
| searchBox.addEventListener("input",function(){renderTable(allRecordings)}); |
| |
| |
| tableBody.addEventListener("click",function(e){ |
| var btn=e.target.closest(".play-btn"); |
| if(!btn)return; |
| var rid=btn.dataset.rid,file=btn.dataset.file; |
| if(playingId===rid){tablePlayer.pause();tablePlayer.src="";playingId=null} |
| else{tablePlayer.src="/audio/"+file;tablePlayer.play();playingId=rid} |
| renderTable(allRecordings); |
| }); |
| tablePlayer.addEventListener("ended",function(){playingId=null;renderTable(allRecordings)}); |
| |
| |
| tableBody.addEventListener("dblclick",function(e){ |
| var td=e.target.closest(".editable"); |
| if(!td)return; |
| var rid=td.parentElement.dataset.id; |
| var field=td.dataset.field; |
| var old=td.textContent==="β"?"":td.textContent; |
| var input=document.createElement("input"); |
| input.type="text";input.value=old; |
| input.style.cssText="width:100%;padding:4px 6px;font-size:13px;border:1px solid var(--primary);border-radius:4px"; |
| td.textContent="";td.appendChild(input);input.focus(); |
| |
| function commit(){ |
| var val=input.value.trim(); |
| td.textContent=val||"β"; |
| if(val!==old){ |
| var body={};body[field]=val; |
| fetch("/api/recordings/"+rid,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}) |
| .then(function(){loadTable()}); |
| } |
| } |
| input.addEventListener("blur",commit); |
| input.addEventListener("keydown",function(ev){if(ev.key==="Enter")input.blur();if(ev.key==="Escape"){input.value=old;input.blur()}}); |
| }); |
| |
| |
| tableBody.addEventListener("click",function(e){ |
| var btn=e.target.closest("[data-del]"); |
| if(!btn)return; |
| if(!confirm("Delete this recording?"))return; |
| fetch("/api/recordings/"+btn.dataset.del,{method:"DELETE"}).then(function(){loadTable()}); |
| }); |
| |
| |
| $("btnRefresh").addEventListener("click",loadTable); |
| $("btnExport").addEventListener("click",function(){window.location="/api/export"}); |
| $("btnExportZip").addEventListener("click",function(){window.location="/api/export-zip"}); |
| |
| |
| setInterval(loadTable,10000); |
| |
| |
| function esc(s){var d=document.createElement("div");d.textContent=s;return d.innerHTML} |
| function fmtDur(s){var m=Math.floor(s/60),sec=Math.floor(s%60);return String(m).padStart(2,"0")+":"+String(sec).padStart(2,"0")} |
| |
| |
| loadTable(); |
| })(); |
| </script> |
| </body> |
| </html> |
|
|