XUUUUSID commited on
Commit
8cb4f00
Β·
verified Β·
1 Parent(s): b780cdd

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +14 -0
  2. app.py +161 -0
  3. requirements.txt +3 -0
  4. templates/index.html +361 -0
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+ COPY requirements.txt .
5
+ RUN pip install --no-cache-dir -r requirements.txt
6
+
7
+ COPY . .
8
+
9
+ # HF Spaces persistent storage
10
+ ENV DATA_DIR=/data
11
+ RUN mkdir -p /data/audio
12
+
13
+ EXPOSE 7860
14
+ CMD ["python", "app.py"]
app.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Audio Recording Studio β€” collaborative audio collection tool.
3
+ Flask + SQLite backend. Deploy on HF Spaces (Docker) or any server.
4
+ """
5
+
6
+ import os
7
+ import sqlite3
8
+ import uuid
9
+ from datetime import datetime
10
+
11
+ from flask import (
12
+ Flask, render_template, request, jsonify, send_from_directory, send_file
13
+ )
14
+
15
+ app = Flask(__name__)
16
+
17
+ DATA_DIR = os.environ.get("DATA_DIR", "data")
18
+ DB_PATH = os.path.join(DATA_DIR, "recordings.db")
19
+ AUDIO_DIR = os.path.join(DATA_DIR, "audio")
20
+ os.makedirs(AUDIO_DIR, exist_ok=True)
21
+
22
+
23
+ # ── Database ────────────────────────────────────────────────────
24
+
25
+ def get_db():
26
+ conn = sqlite3.connect(DB_PATH)
27
+ conn.row_factory = sqlite3.Row
28
+ return conn
29
+
30
+
31
+ def init_db():
32
+ with get_db() as db:
33
+ db.execute("""
34
+ CREATE TABLE IF NOT EXISTS recordings (
35
+ id TEXT PRIMARY KEY,
36
+ user TEXT NOT NULL,
37
+ name TEXT NOT NULL,
38
+ filename TEXT NOT NULL,
39
+ duration REAL DEFAULT 0,
40
+ created_at TEXT NOT NULL,
41
+ notes TEXT DEFAULT ''
42
+ )
43
+ """)
44
+
45
+
46
+ init_db()
47
+
48
+
49
+ # ── Routes ──────────────────────────────────────────────────────
50
+
51
+ @app.route("/")
52
+ def index():
53
+ return render_template("index.html")
54
+
55
+
56
+ @app.route("/api/recordings", methods=["GET"])
57
+ def list_recordings():
58
+ with get_db() as db:
59
+ rows = db.execute(
60
+ "SELECT * FROM recordings ORDER BY created_at DESC"
61
+ ).fetchall()
62
+ return jsonify([dict(r) for r in rows])
63
+
64
+
65
+ @app.route("/api/recordings", methods=["POST"])
66
+ def create_recording():
67
+ audio = request.files.get("audio")
68
+ if not audio:
69
+ return jsonify({"error": "No audio file"}), 400
70
+
71
+ user = request.form.get("user", "Anonymous").strip() or "Anonymous"
72
+ name = request.form.get("name", "").strip()
73
+ duration = float(request.form.get("duration", 0))
74
+ notes = request.form.get("notes", "").strip()
75
+
76
+ rec_id = uuid.uuid4().hex[:12]
77
+ ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
78
+
79
+ if not name:
80
+ name = f"Recording {ts}"
81
+
82
+ filename = f"{rec_id}.webm"
83
+ audio.save(os.path.join(AUDIO_DIR, filename))
84
+
85
+ with get_db() as db:
86
+ db.execute(
87
+ "INSERT INTO recordings (id, user, name, filename, duration, created_at, notes) "
88
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
89
+ (rec_id, user, name, filename, duration, ts, notes),
90
+ )
91
+
92
+ return jsonify({
93
+ "id": rec_id, "user": user, "name": name,
94
+ "filename": filename, "duration": duration,
95
+ "created_at": ts, "notes": notes,
96
+ }), 201
97
+
98
+
99
+ @app.route("/api/recordings/<rec_id>", methods=["PATCH"])
100
+ def update_recording(rec_id):
101
+ data = request.get_json(force=True)
102
+ fields, values = [], []
103
+ for col in ("name", "notes", "user"):
104
+ if col in data:
105
+ fields.append(f"{col} = ?")
106
+ values.append(data[col])
107
+ if not fields:
108
+ return jsonify({"error": "Nothing to update"}), 400
109
+
110
+ values.append(rec_id)
111
+ with get_db() as db:
112
+ db.execute(f"UPDATE recordings SET {', '.join(fields)} WHERE id = ?", values)
113
+ return jsonify({"ok": True})
114
+
115
+
116
+ @app.route("/api/recordings/<rec_id>", methods=["DELETE"])
117
+ def delete_recording(rec_id):
118
+ with get_db() as db:
119
+ row = db.execute("SELECT filename FROM recordings WHERE id = ?", (rec_id,)).fetchone()
120
+ if row:
121
+ path = os.path.join(AUDIO_DIR, row["filename"])
122
+ if os.path.exists(path):
123
+ os.remove(path)
124
+ db.execute("DELETE FROM recordings WHERE id = ?", (rec_id,))
125
+ return jsonify({"ok": True})
126
+
127
+
128
+ @app.route("/audio/<filename>")
129
+ def serve_audio(filename):
130
+ return send_from_directory(AUDIO_DIR, filename)
131
+
132
+
133
+ @app.route("/api/export")
134
+ def export_excel():
135
+ import pandas as pd
136
+
137
+ with get_db() as db:
138
+ rows = db.execute("SELECT * FROM recordings ORDER BY created_at DESC").fetchall()
139
+
140
+ data = []
141
+ for r in rows:
142
+ data.append({
143
+ "Name": r["name"],
144
+ "User": r["user"],
145
+ "Timestamp": r["created_at"],
146
+ "Duration (s)": round(r["duration"], 1),
147
+ "Notes": r["notes"],
148
+ "Audio File": r["filename"],
149
+ })
150
+
151
+ df = pd.DataFrame(data) if data else pd.DataFrame(
152
+ columns=["Name", "User", "Timestamp", "Duration (s)", "Notes", "Audio File"]
153
+ )
154
+ path = os.path.join(DATA_DIR, "export.xlsx")
155
+ df.to_excel(path, index=False)
156
+ return send_file(path, as_attachment=True, download_name="recordings.xlsx")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ port = int(os.environ.get("PORT", 7860))
161
+ app.run(host="0.0.0.0", port=port, debug=False)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ flask
2
+ pandas
3
+ openpyxl
templates/index.html ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Audio Recording Studio</title>
7
+ <style>
8
+ /* ── Reset & base ─────────────────────────────────────────── */
9
+ *{box-sizing:border-box;margin:0;padding:0}
10
+ :root{
11
+ --bg:#f5f6fa;--surface:#fff;--border:#e2e5f1;
12
+ --primary:#4361ee;--primary-hover:#3a56d4;
13
+ --danger:#ef476f;--danger-hover:#d63a5e;
14
+ --success:#06d6a0;--text:#1e1e2f;--muted:#6c7293;
15
+ --radius:8px;--shadow:0 1px 3px rgba(0,0,0,.08);
16
+ }
17
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
18
+
19
+ /* ── Layout ───────────────────────────────────────────────── */
20
+ .app{display:flex;min-height:100vh}
21
+ .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}
22
+ .main{flex:1;padding:24px;overflow-y:auto}
23
+
24
+ /* ── Sidebar ──────────────────────────────────────────────── */
25
+ .logo{font-size:20px;font-weight:700;color:var(--primary);display:flex;align-items:center;gap:8px}
26
+ .logo svg{width:28px;height:28px}
27
+
28
+ .field label{display:block;font-size:12px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:4px}
29
+ .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}
30
+ .field input:focus,.field textarea:focus{border-color:var(--primary)}
31
+ .field textarea{resize:vertical;min-height:56px}
32
+
33
+ .timer{text-align:center;font-size:40px;font-weight:300;font-variant-numeric:tabular-nums;color:var(--muted);padding:8px 0}
34
+ .timer.active{color:var(--danger);font-weight:400}
35
+
36
+ canvas#waveform{width:100%;height:40px;border-radius:6px;background:#f0f1f6}
37
+
38
+ .rec-controls{display:flex;gap:10px}
39
+ .rec-controls button{flex:1}
40
+ button{padding:10px 16px;font-size:14px;font-weight:600;border:none;border-radius:var(--radius);cursor:pointer;transition:background .15s,opacity .15s}
41
+ button:disabled{opacity:.4;cursor:not-allowed}
42
+ .btn-record{background:var(--danger);color:#fff}
43
+ .btn-record:hover:not(:disabled){background:var(--danger-hover)}
44
+ .btn-stop{background:var(--muted);color:#fff}
45
+ .btn-stop:hover:not(:disabled){background:#555a78}
46
+ .btn-primary{background:var(--primary);color:#fff}
47
+ .btn-primary:hover:not(:disabled){background:var(--primary-hover)}
48
+ .btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
49
+ .btn-outline:hover{background:#f0f1f6}
50
+ .btn-danger-sm{background:none;border:none;color:var(--danger);cursor:pointer;font-size:13px;padding:4px 8px;border-radius:4px}
51
+ .btn-danger-sm:hover{background:#fde8ee}
52
+
53
+ .sidebar audio{width:100%;border-radius:6px}
54
+ .hidden{display:none}
55
+
56
+ /* ── Table ────────────────────────────────────────────────── */
57
+ .toolbar{display:flex;align-items:center;gap:12px;margin-bottom:16px;flex-wrap:wrap}
58
+ .toolbar h2{font-size:18px;font-weight:600;margin-right:auto}
59
+ .search-box{padding:8px 12px;border:1px solid var(--border);border-radius:var(--radius);font-size:14px;width:220px;outline:none}
60
+ .search-box:focus{border-color:var(--primary)}
61
+ .badge{background:var(--primary);color:#fff;font-size:12px;padding:2px 8px;border-radius:12px;font-weight:600}
62
+
63
+ table{width:100%;border-collapse:collapse;background:var(--surface);border-radius:var(--radius);overflow:hidden;box-shadow:var(--shadow)}
64
+ 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}
65
+ tbody td{padding:10px 14px;border-bottom:1px solid var(--border);font-size:14px;vertical-align:middle}
66
+ tbody tr:hover{background:#f8f9ff}
67
+ tbody tr.playing{background:#eef1ff}
68
+ .cell-user{font-weight:600;color:var(--primary)}
69
+ .cell-name{max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
70
+ .cell-notes{max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted);font-size:13px}
71
+ .cell-duration{font-variant-numeric:tabular-nums;white-space:nowrap}
72
+ .cell-actions{white-space:nowrap}
73
+ .play-btn{background:none;border:none;cursor:pointer;font-size:18px;padding:2px 6px;border-radius:4px}
74
+ .play-btn:hover{background:#eef1ff}
75
+ .empty-state{text-align:center;padding:60px 20px;color:var(--muted)}
76
+ .empty-state p{font-size:15px;margin-top:8px}
77
+
78
+ /* ── Inline edit ──────────────────────────────────────────── */
79
+ .editable{cursor:pointer;border-bottom:1px dashed transparent;transition:border .15s}
80
+ .editable:hover{border-bottom-color:var(--primary)}
81
+
82
+ /* ── Responsive ───────────────────────────────────────────── */
83
+ @media(max-width:768px){
84
+ .app{flex-direction:column}
85
+ .sidebar{width:100%;height:auto;position:static;border-right:none;border-bottom:1px solid var(--border)}
86
+ .main{padding:16px}
87
+ .search-box{width:100%}
88
+ }
89
+ </style>
90
+ </head>
91
+ <body>
92
+ <div class="app">
93
+
94
+ <!-- ── Sidebar: recorder ────────────────────────────────── -->
95
+ <div class="sidebar">
96
+ <div class="logo">
97
+ <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>
98
+ Audio Recording Studio
99
+ </div>
100
+
101
+ <div class="field">
102
+ <label for="userName">Your Name</label>
103
+ <input type="text" id="userName" placeholder="e.g. Alice" autocomplete="off">
104
+ </div>
105
+ <div class="field">
106
+ <label for="recName">Recording Name</label>
107
+ <input type="text" id="recName" placeholder="e.g. Interview – Session 3" autocomplete="off">
108
+ </div>
109
+ <div class="field">
110
+ <label for="recNotes">Notes (optional)</label>
111
+ <textarea id="recNotes" placeholder="Any context…"></textarea>
112
+ </div>
113
+
114
+ <div class="timer" id="timer">00:00</div>
115
+ <canvas id="waveform"></canvas>
116
+
117
+ <div class="rec-controls">
118
+ <button class="btn-record" id="btnRecord">Record</button>
119
+ <button class="btn-stop" id="btnStop" disabled>Stop</button>
120
+ </div>
121
+
122
+ <audio id="preview" controls class="hidden"></audio>
123
+ <button class="btn-primary" id="btnSave" disabled>Save Recording</button>
124
+ <div id="sideStatus" style="text-align:center;font-size:13px;min-height:18px"></div>
125
+ </div>
126
+
127
+ <!-- ── Main: table ──────────────────────────────────────── -->
128
+ <div class="main">
129
+ <div class="toolbar">
130
+ <h2>Recordings <span class="badge" id="countBadge">0</span></h2>
131
+ <input class="search-box" id="searchBox" placeholder="Search…" autocomplete="off">
132
+ <button class="btn-outline" id="btnRefresh">Refresh</button>
133
+ <button class="btn-outline" id="btnExport">Export Excel</button>
134
+ </div>
135
+
136
+ <table>
137
+ <thead>
138
+ <tr>
139
+ <th>#</th><th>User</th><th>Name</th><th>Timestamp</th><th>Duration</th><th>Notes</th><th>Play</th><th>Actions</th>
140
+ </tr>
141
+ </thead>
142
+ <tbody id="tableBody">
143
+ <tr><td colspan="8" class="empty-state"><p>No recordings yet. Start recording from the sidebar.</p></td></tr>
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+
148
+ </div>
149
+
150
+ <!-- ── Shared audio player (hidden) ───────────────────────── -->
151
+ <audio id="tablePlayer"></audio>
152
+
153
+ <script>
154
+ (function(){
155
+ /* ── DOM ───────────────────────────────────── */
156
+ var $ = document.getElementById.bind(document);
157
+ var btnRecord=$("btnRecord"),btnStop=$("btnStop"),btnSave=$("btnSave");
158
+ var preview=$("preview"),timerEl=$("timer"),statusEl=$("sideStatus");
159
+ var userName=$("userName"),recName=$("recName"),recNotes=$("recNotes");
160
+ var canvas=$("waveform"),ctx=canvas.getContext("2d");
161
+ var tableBody=$("tableBody"),countBadge=$("countBadge");
162
+ var searchBox=$("searchBox"),tablePlayer=$("tablePlayer");
163
+
164
+ var mediaRecorder,audioChunks,recordedBlob;
165
+ var timerInt,startTime,analyser,animFrame,audioCtx,stream;
166
+ var allRecordings=[];
167
+ var playingId=null;
168
+
169
+ /* Persist user name */
170
+ userName.value=localStorage.getItem("audioStudioUser")||"";
171
+ userName.addEventListener("input",function(){localStorage.setItem("audioStudioUser",userName.value)});
172
+
173
+ /* ── Timer ──────────────────────────────────── */
174
+ function resetTimer(){timerEl.textContent="00:00";timerEl.classList.remove("active")}
175
+ function startTimerFn(){
176
+ startTime=Date.now();timerEl.classList.add("active");
177
+ timerInt=setInterval(function(){
178
+ var s=Math.floor((Date.now()-startTime)/1000);
179
+ timerEl.textContent=String(Math.floor(s/60)).padStart(2,"0")+":"+String(s%60).padStart(2,"0");
180
+ },250);
181
+ }
182
+ function stopTimerFn(){clearInterval(timerInt);timerEl.classList.remove("active")}
183
+
184
+ /* ── Waveform ───────────────────────────────── */
185
+ function resizeCanvas(){canvas.width=canvas.clientWidth*(devicePixelRatio||1);canvas.height=canvas.clientHeight*(devicePixelRatio||1)}
186
+ resizeCanvas();window.addEventListener("resize",resizeCanvas);
187
+ function drawWave(){
188
+ if(!analyser)return;animFrame=requestAnimationFrame(drawWave);
189
+ var buf=new Uint8Array(analyser.fftSize);analyser.getByteTimeDomainData(buf);
190
+ var w=canvas.width,h=canvas.height;ctx.clearRect(0,0,w,h);
191
+ ctx.lineWidth=2;ctx.strokeStyle="#4361ee";ctx.beginPath();
192
+ var dx=w/buf.length,x=0;
193
+ 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}
194
+ ctx.lineTo(w,h/2);ctx.stroke();
195
+ }
196
+ function stopVis(){cancelAnimationFrame(animFrame);ctx.clearRect(0,0,canvas.width,canvas.height)}
197
+ function setStatus(m,ok){statusEl.textContent=m;statusEl.style.color=ok?"#06d6a0":ok===false?"#ef476f":"#6c7293"}
198
+
199
+ /* ── Record ─────────────────────────────────── */
200
+ btnRecord.addEventListener("click",function(){
201
+ setStatus("");audioChunks=[];recordedBlob=null;
202
+ preview.classList.add("hidden");preview.src="";btnSave.disabled=true;
203
+
204
+ navigator.mediaDevices.getUserMedia({audio:true}).then(function(s){
205
+ stream=s;
206
+ audioCtx=new(window.AudioContext||window.webkitAudioContext)();
207
+ var src=audioCtx.createMediaStreamSource(s);
208
+ analyser=audioCtx.createAnalyser();analyser.fftSize=2048;
209
+ src.connect(analyser);drawWave();
210
+
211
+ var mime="audio/webm;codecs=opus";
212
+ if(!MediaRecorder.isTypeSupported(mime))mime="audio/webm";
213
+ if(!MediaRecorder.isTypeSupported(mime))mime="";
214
+
215
+ mediaRecorder=new MediaRecorder(s,mime?{mimeType:mime}:undefined);
216
+ mediaRecorder.ondataavailable=function(e){if(e.data.size>0)audioChunks.push(e.data)};
217
+ mediaRecorder.onstop=function(){
218
+ recordedBlob=new Blob(audioChunks,{type:"audio/webm"});
219
+ preview.src=URL.createObjectURL(recordedBlob);
220
+ preview.classList.remove("hidden");
221
+ btnSave.disabled=false;
222
+ setStatus("Ready to save.");
223
+ };
224
+ mediaRecorder.start(1000);startTimerFn();
225
+ btnRecord.disabled=true;btnStop.disabled=false;
226
+ setStatus("Recording…");
227
+ }).catch(function(e){setStatus("Mic denied: "+e.message,false)});
228
+ });
229
+
230
+ btnStop.addEventListener("click",function(){
231
+ if(mediaRecorder&&mediaRecorder.state!=="inactive")mediaRecorder.stop();
232
+ stopTimerFn();stopVis();
233
+ if(stream){stream.getTracks().forEach(function(t){t.stop()});stream=null}
234
+ if(audioCtx){audioCtx.close();audioCtx=null}
235
+ btnRecord.disabled=false;btnStop.disabled=true;
236
+ });
237
+
238
+ /* ── Save ────────────────────────────────────── */
239
+ btnSave.addEventListener("click",function(){
240
+ if(!recordedBlob)return;
241
+ btnSave.disabled=true;setStatus("Uploading…");
242
+
243
+ var fd=new FormData();
244
+ fd.append("audio",recordedBlob,"recording.webm");
245
+ fd.append("user",userName.value.trim()||"Anonymous");
246
+ fd.append("name",recName.value.trim());
247
+ fd.append("notes",recNotes.value.trim());
248
+ fd.append("duration",((Date.now()-startTime)/1000).toFixed(1));
249
+
250
+ fetch("/api/recordings",{method:"POST",body:fd})
251
+ .then(function(r){if(!r.ok)throw new Error(r.statusText);return r.json()})
252
+ .then(function(){
253
+ setStatus("Saved!",true);
254
+ recName.value="";recNotes.value="";
255
+ recordedBlob=null;preview.classList.add("hidden");preview.src="";
256
+ resetTimer();loadTable();
257
+ })
258
+ .catch(function(e){setStatus("Error: "+e.message,false);btnSave.disabled=false});
259
+ });
260
+
261
+ /* ── Table ──────────────────────────────────── */
262
+ function loadTable(){
263
+ fetch("/api/recordings").then(function(r){return r.json()}).then(function(data){
264
+ allRecordings=data;renderTable(data);
265
+ });
266
+ }
267
+
268
+ function renderTable(data){
269
+ var q=searchBox.value.toLowerCase();
270
+ var filtered=q?data.filter(function(r){
271
+ return (r.name+r.user+r.notes+r.created_at).toLowerCase().indexOf(q)>=0;
272
+ }):data;
273
+
274
+ countBadge.textContent=filtered.length;
275
+
276
+ if(!filtered.length){
277
+ tableBody.innerHTML='<tr><td colspan="8" class="empty-state"><p>No recordings found.</p></td></tr>';
278
+ return;
279
+ }
280
+ var html="";
281
+ filtered.forEach(function(r,i){
282
+ var dur=r.duration?fmtDur(r.duration):"β€”";
283
+ var playing=playingId===r.id;
284
+ html+='<tr class="'+(playing?"playing":"")+'" data-id="'+r.id+'">'
285
+ +'<td>'+(i+1)+'</td>'
286
+ +'<td class="cell-user">'+esc(r.user)+'</td>'
287
+ +'<td class="cell-name editable" data-field="name" title="Click to edit">'+esc(r.name)+'</td>'
288
+ +'<td>'+esc(r.created_at)+'</td>'
289
+ +'<td class="cell-duration">'+dur+'</td>'
290
+ +'<td class="cell-notes editable" data-field="notes" title="Click to edit">'+esc(r.notes||"β€”")+'</td>'
291
+ +'<td><button class="play-btn" data-file="'+esc(r.filename)+'" data-rid="'+r.id+'">'+(playing?"\u23F9":"\u25B6")+'</button></td>'
292
+ +'<td class="cell-actions"><button class="btn-danger-sm" data-del="'+r.id+'">Delete</button></td>'
293
+ +'</tr>';
294
+ });
295
+ tableBody.innerHTML=html;
296
+ }
297
+
298
+ /* Search */
299
+ searchBox.addEventListener("input",function(){renderTable(allRecordings)});
300
+
301
+ /* Play in table */
302
+ tableBody.addEventListener("click",function(e){
303
+ var btn=e.target.closest(".play-btn");
304
+ if(!btn)return;
305
+ var rid=btn.dataset.rid,file=btn.dataset.file;
306
+ if(playingId===rid){tablePlayer.pause();tablePlayer.src="";playingId=null}
307
+ else{tablePlayer.src="/audio/"+file;tablePlayer.play();playingId=rid}
308
+ renderTable(allRecordings);
309
+ });
310
+ tablePlayer.addEventListener("ended",function(){playingId=null;renderTable(allRecordings)});
311
+
312
+ /* Inline edit */
313
+ tableBody.addEventListener("dblclick",function(e){
314
+ var td=e.target.closest(".editable");
315
+ if(!td)return;
316
+ var rid=td.parentElement.dataset.id;
317
+ var field=td.dataset.field;
318
+ var old=td.textContent==="β€”"?"":td.textContent;
319
+ var input=document.createElement("input");
320
+ input.type="text";input.value=old;
321
+ input.style.cssText="width:100%;padding:4px 6px;font-size:13px;border:1px solid var(--primary);border-radius:4px";
322
+ td.textContent="";td.appendChild(input);input.focus();
323
+
324
+ function commit(){
325
+ var val=input.value.trim();
326
+ td.textContent=val||"β€”";
327
+ if(val!==old){
328
+ var body={};body[field]=val;
329
+ fetch("/api/recordings/"+rid,{method:"PATCH",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})
330
+ .then(function(){loadTable()});
331
+ }
332
+ }
333
+ input.addEventListener("blur",commit);
334
+ input.addEventListener("keydown",function(ev){if(ev.key==="Enter")input.blur();if(ev.key==="Escape"){input.value=old;input.blur()}});
335
+ });
336
+
337
+ /* Delete */
338
+ tableBody.addEventListener("click",function(e){
339
+ var btn=e.target.closest("[data-del]");
340
+ if(!btn)return;
341
+ if(!confirm("Delete this recording?"))return;
342
+ fetch("/api/recordings/"+btn.dataset.del,{method:"DELETE"}).then(function(){loadTable()});
343
+ });
344
+
345
+ /* Refresh & export */
346
+ $("btnRefresh").addEventListener("click",loadTable);
347
+ $("btnExport").addEventListener("click",function(){window.location="/api/export"});
348
+
349
+ /* Auto-refresh every 10s for collaboration */
350
+ setInterval(loadTable,10000);
351
+
352
+ /* ── Helpers ─────────────────────────────────── */
353
+ function esc(s){var d=document.createElement("div");d.textContent=s;return d.innerHTML}
354
+ 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")}
355
+
356
+ /* Init */
357
+ loadTable();
358
+ })();
359
+ </script>
360
+ </body>
361
+ </html>