|
@@ -131,20 +131,30 @@ def _recording_path(sid: str) -> Path | None:
|
|
|
# ── Speaker API ───────────────────────────────────────────────────────────────
|
|
# ── Speaker API ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
class NameBody(BaseModel):
|
|
class NameBody(BaseModel):
|
|
|
- name: str
|
|
|
|
|
|
|
+ name: str | None = None
|
|
|
|
|
+ role: str | None = None
|
|
|
|
|
+ location: str | None = None
|
|
|
|
|
|
|
|
class AddBody(BaseModel):
|
|
class AddBody(BaseModel):
|
|
|
id: str
|
|
id: str
|
|
|
- name: str
|
|
|
|
|
|
|
+ name: str = ""
|
|
|
|
|
+ role: str = ""
|
|
|
|
|
+ location: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.get("/api/speakers")
|
|
@app.get("/api/speakers")
|
|
|
def api_list():
|
|
def api_list():
|
|
|
speakers = _load()
|
|
speakers = _load()
|
|
|
- return {"speakers": [
|
|
|
|
|
- {"id": k, "name": v, "has_recording": _recording_path(k) is not None}
|
|
|
|
|
- for k, v in sorted(speakers.items())
|
|
|
|
|
- ]}
|
|
|
|
|
|
|
+ result = []
|
|
|
|
|
+ for k, v in sorted(speakers.items()):
|
|
|
|
|
+ if isinstance(v, dict):
|
|
|
|
|
+ entry = {"id": k, "name": v.get("name", ""), "role": v.get("role", ""),
|
|
|
|
|
+ "location": v.get("location", ""), "has_recording": _recording_path(k) is not None}
|
|
|
|
|
+ else:
|
|
|
|
|
+ entry = {"id": k, "name": str(v), "role": "", "location": "",
|
|
|
|
|
+ "has_recording": _recording_path(k) is not None}
|
|
|
|
|
+ result.append(entry)
|
|
|
|
|
+ return {"speakers": result}
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.post("/api/speakers")
|
|
@app.post("/api/speakers")
|
|
@@ -155,18 +165,24 @@ def api_add(body: AddBody):
|
|
|
raise HTTPException(400, "Speaker ID cannot be empty")
|
|
raise HTTPException(400, "Speaker ID cannot be empty")
|
|
|
if sid in speakers:
|
|
if sid in speakers:
|
|
|
raise HTTPException(400, f"'{sid}' already exists")
|
|
raise HTTPException(400, f"'{sid}' already exists")
|
|
|
- speakers[sid] = body.name.strip()
|
|
|
|
|
|
|
+ speakers[sid] = {"name": body.name.strip(), "role": body.role.strip(), "location": body.location.strip()}
|
|
|
_save(speakers)
|
|
_save(speakers)
|
|
|
- return {"ok": True, "id": sid, "name": speakers[sid]}
|
|
|
|
|
|
|
+ return {"ok": True, "id": sid, "name": body.name}
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.put("/api/speakers/{sid}")
|
|
@app.put("/api/speakers/{sid}")
|
|
|
def api_update(sid: str, body: NameBody):
|
|
def api_update(sid: str, body: NameBody):
|
|
|
- name = body.name.strip()
|
|
|
|
|
- if not name:
|
|
|
|
|
- raise HTTPException(400, "Name cannot be empty")
|
|
|
|
|
speakers = _load()
|
|
speakers = _load()
|
|
|
- speakers[sid] = name
|
|
|
|
|
|
|
+ entry = speakers.get(sid, {})
|
|
|
|
|
+ if not isinstance(entry, dict):
|
|
|
|
|
+ entry = {"name": str(entry), "role": "", "location": ""}
|
|
|
|
|
+ if body.name is not None:
|
|
|
|
|
+ entry["name"] = body.name.strip()
|
|
|
|
|
+ if body.role is not None:
|
|
|
|
|
+ entry["role"] = body.role.strip()
|
|
|
|
|
+ if body.location is not None:
|
|
|
|
|
+ entry["location"] = body.location.strip()
|
|
|
|
|
+ speakers[sid] = entry
|
|
|
_save(speakers)
|
|
_save(speakers)
|
|
|
return {"ok": True}
|
|
return {"ok": True}
|
|
|
|
|
|
|
@@ -683,9 +699,13 @@ HTML = """<!DOCTYPE html>
|
|
|
<input id="new-initials" placeholder="J.B.B">
|
|
<input id="new-initials" placeholder="J.B.B">
|
|
|
</div>
|
|
</div>
|
|
|
<div class="field">
|
|
<div class="field">
|
|
|
- <label>Name</label>
|
|
|
|
|
|
|
+ <label>Name / Role</label>
|
|
|
<input id="new-name" placeholder="John Brown">
|
|
<input id="new-name" placeholder="John Brown">
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <div class="field">
|
|
|
|
|
+ <label>Locality</label>
|
|
|
|
|
+ <input id="new-location" placeholder="Sydney">
|
|
|
|
|
+ </div>
|
|
|
<div class="modal-actions">
|
|
<div class="modal-actions">
|
|
|
<button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
|
|
<button class="btn btn-ghost" onclick="closeAddModal()">Cancel</button>
|
|
|
<button class="btn btn-primary" onclick="addSpeaker()">Add</button>
|
|
<button class="btn btn-primary" onclick="addSpeaker()">Add</button>
|
|
@@ -738,10 +758,20 @@ function render() {
|
|
|
filterTable();
|
|
filterTable();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function mkEdit(id, field, value) {
|
|
|
|
|
+ return `<div class="name-cell">
|
|
|
|
|
+ <span class="name-display" onclick="startEdit(this,'${id}','${field}')"
|
|
|
|
|
+ title="Click to edit">${value}</span>
|
|
|
|
|
+ <input class="name-input" style="display:none"
|
|
|
|
|
+ onblur="saveEdit(this,'${id}','${field}')"
|
|
|
|
|
+ onkeydown="editKeydown(event,this,'${id}','${field}')">
|
|
|
|
|
+ </div>`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function makeRow(s) {
|
|
function makeRow(s) {
|
|
|
const tr = document.createElement('tr');
|
|
const tr = document.createElement('tr');
|
|
|
tr.dataset.id = s.id;
|
|
tr.dataset.id = s.id;
|
|
|
- tr.dataset.name = s.name.toLowerCase();
|
|
|
|
|
|
|
+ tr.dataset.name = (s.name + ' ' + s.role + ' ' + s.location).toLowerCase();
|
|
|
|
|
|
|
|
const recHtml = s.has_recording
|
|
const recHtml = s.has_recording
|
|
|
? `<span class="rec-badge rec-yes">▶ Recorded</span>
|
|
? `<span class="rec-badge rec-yes">▶ Recorded</span>
|
|
@@ -751,15 +781,9 @@ function makeRow(s) {
|
|
|
|
|
|
|
|
tr.innerHTML = `
|
|
tr.innerHTML = `
|
|
|
<td class="sid">${esc(s.id)}</td>
|
|
<td class="sid">${esc(s.id)}</td>
|
|
|
- <td>
|
|
|
|
|
- <div class="name-cell">
|
|
|
|
|
- <span class="name-display" onclick="startEdit(this, '${esc(s.id)}')"
|
|
|
|
|
- title="Click to edit">${esc(s.name)}</span>
|
|
|
|
|
- <input class="name-input" style="display:none"
|
|
|
|
|
- onblur="saveEdit(this,'${esc(s.id)}')"
|
|
|
|
|
- onkeydown="nameKeydown(event,this,'${esc(s.id)}')">
|
|
|
|
|
- </div>
|
|
|
|
|
- </td>
|
|
|
|
|
|
|
+ <td>${mkEdit(esc(s.id), 'name', esc(s.name))}</td>
|
|
|
|
|
+ <td>${mkEdit(esc(s.id), 'role', esc(s.role))}</td>
|
|
|
|
|
+ <td>${mkEdit(esc(s.id), 'location', esc(s.location))}</td>
|
|
|
<td>${recHtml}</td>
|
|
<td>${recHtml}</td>
|
|
|
<td>
|
|
<td>
|
|
|
<div class="actions">
|
|
<div class="actions">
|
|
@@ -792,7 +816,7 @@ function filterTable() {
|
|
|
q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
|
|
q ? `${visible} of ${speakers.length} speakers` : `${speakers.length} speakers`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function startEdit(span, id) {
|
|
|
|
|
|
|
+function startEdit(span, id, field) {
|
|
|
const input = span.nextElementSibling;
|
|
const input = span.nextElementSibling;
|
|
|
input.value = span.textContent;
|
|
input.value = span.textContent;
|
|
|
span.style.display = 'none';
|
|
span.style.display = 'none';
|
|
@@ -801,7 +825,7 @@ function startEdit(span, id) {
|
|
|
input.select();
|
|
input.select();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-function nameKeydown(e, input, id) {
|
|
|
|
|
|
|
+function editKeydown(e, input, id, field) {
|
|
|
if (e.key === 'Enter') { input.blur(); }
|
|
if (e.key === 'Enter') { input.blur(); }
|
|
|
if (e.key === 'Escape') { cancelEdit(input); }
|
|
if (e.key === 'Escape') { cancelEdit(input); }
|
|
|
}
|
|
}
|
|
@@ -812,19 +836,19 @@ function cancelEdit(input) {
|
|
|
span.style.display = '';
|
|
span.style.display = '';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-async function saveEdit(input, id) {
|
|
|
|
|
- const name = input.value.trim();
|
|
|
|
|
|
|
+async function saveEdit(input, id, field) {
|
|
|
|
|
+ const value = input.value.trim();
|
|
|
const span = input.previousElementSibling;
|
|
const span = input.previousElementSibling;
|
|
|
- if (!name || name === span.textContent) { cancelEdit(input); return; }
|
|
|
|
|
|
|
+ if (value === span.textContent) { cancelEdit(input); return; }
|
|
|
|
|
+ const body = {};
|
|
|
|
|
+ body[field] = value;
|
|
|
const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
|
|
const res = await fetch(`/api/speakers/${encodeURIComponent(id)}`, {
|
|
|
method: 'PUT',
|
|
method: 'PUT',
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
- body: JSON.stringify({name})
|
|
|
|
|
|
|
+ body: JSON.stringify(body)
|
|
|
});
|
|
});
|
|
|
if (res.ok) {
|
|
if (res.ok) {
|
|
|
- span.textContent = name;
|
|
|
|
|
- const tr = input.closest('tr');
|
|
|
|
|
- tr.dataset.name = name.toLowerCase();
|
|
|
|
|
|
|
+ span.textContent = value;
|
|
|
toast('Saved');
|
|
toast('Saved');
|
|
|
} else {
|
|
} else {
|
|
|
toast('Save failed', true);
|
|
toast('Save failed', true);
|
|
@@ -837,10 +861,12 @@ function openAddModal() {
|
|
|
.filter(s => /^SPEAKER_\\d+$/.test(s.id))
|
|
.filter(s => /^SPEAKER_\\d+$/.test(s.id))
|
|
|
.map(s => parseInt(s.id.split('_')[1]));
|
|
.map(s => parseInt(s.id.split('_')[1]));
|
|
|
const next = nums.length ? Math.max(...nums) + 1 : 0;
|
|
const next = nums.length ? Math.max(...nums) + 1 : 0;
|
|
|
- document.getElementById('new-id').value = `SPEAKER_${String(next).padStart(2,'0')}`;
|
|
|
|
|
- document.getElementById('new-name').value = '';
|
|
|
|
|
|
|
+ document.getElementById('new-id').value = `SPEAKER_${String(next).padStart(2,'0')}`;
|
|
|
|
|
+ document.getElementById('new-initials').value = '';
|
|
|
|
|
+ document.getElementById('new-name').value = '';
|
|
|
|
|
+ document.getElementById('new-location').value = '';
|
|
|
document.getElementById('add-modal').classList.add('open');
|
|
document.getElementById('add-modal').classList.add('open');
|
|
|
- setTimeout(() => document.getElementById('new-name').focus(), 50);
|
|
|
|
|
|
|
+ setTimeout(() => document.getElementById('new-initials').focus(), 50);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function closeAddModal(e) {
|
|
function closeAddModal(e) {
|
|
@@ -849,17 +875,19 @@ function closeAddModal(e) {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
async function addSpeaker() {
|
|
async function addSpeaker() {
|
|
|
- const id = document.getElementById('new-id').value.trim();
|
|
|
|
|
- const name = document.getElementById('new-name').value.trim();
|
|
|
|
|
- if (!id || !name) { toast('ID and name are required', true); return; }
|
|
|
|
|
|
|
+ const id = document.getElementById('new-id').value.trim();
|
|
|
|
|
+ const name = document.getElementById('new-initials').value.trim();
|
|
|
|
|
+ const role = document.getElementById('new-name').value.trim();
|
|
|
|
|
+ const location = document.getElementById('new-location').value.trim();
|
|
|
|
|
+ if (!id) { toast('Speaker ID is required', true); return; }
|
|
|
const res = await fetch('/api/speakers', {
|
|
const res = await fetch('/api/speakers', {
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
headers: {'Content-Type': 'application/json'},
|
|
headers: {'Content-Type': 'application/json'},
|
|
|
- body: JSON.stringify({id, name})
|
|
|
|
|
|
|
+ body: JSON.stringify({id, name, role, location})
|
|
|
});
|
|
});
|
|
|
if (res.ok) {
|
|
if (res.ok) {
|
|
|
closeAddModal();
|
|
closeAddModal();
|
|
|
- toast(`Added ${name}`);
|
|
|
|
|
|
|
+ toast(`Added ${name || id}`);
|
|
|
await load();
|
|
await load();
|
|
|
} else {
|
|
} else {
|
|
|
const err = await res.json().catch(() => ({detail:'Error'}));
|
|
const err = await res.json().catch(() => ({detail:'Error'}));
|