test / index.html
XUUUUSID's picture
Upload 2 files
bf9f830 verified
<!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">
<!-- ── Sidebar: recorder ────────────────────────────────── -->
<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>
<!-- ── Main: table ──────────────────────────────────────── -->
<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>
<!-- ── Shared audio player (hidden) ───────────────────────── -->
<audio id="tablePlayer"></audio>
<script>
(function(){
/* ── DOM ───────────────────────────────────── */
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;
/* Persist user name */
userName.value=localStorage.getItem("audioStudioUser")||"";
userName.addEventListener("input",function(){localStorage.setItem("audioStudioUser",userName.value)});
/* ── Timer ──────────────────────────────────── */
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")}
/* ── Waveform ───────────────────────────────── */
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"}
/* ── Record ─────────────────────────────────── */
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;
});
/* ── Save ────────────────────────────────────── */
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});
});
/* ── Table ──────────────────────────────────── */
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;
}
/* Search */
searchBox.addEventListener("input",function(){renderTable(allRecordings)});
/* Play in table */
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)});
/* Inline edit */
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()}});
});
/* Delete */
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()});
});
/* Refresh & export */
$("btnRefresh").addEventListener("click",loadTable);
$("btnExport").addEventListener("click",function(){window.location="/api/export"});
$("btnExportZip").addEventListener("click",function(){window.location="/api/export-zip"});
/* Auto-refresh every 10s for collaboration */
setInterval(loadTable,10000);
/* ── Helpers ─────────────────────────────────── */
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")}
/* Init */
loadTable();
})();
</script>
</body>
</html>