polish: schedule past-time confirm, recorder name sanitization, asset detail player controls

- Schedule: if start_at is more than a minute in the past, confirm()
  before submitting (operator may want to fire immediately, but
  shouldn't do it accidentally)
- Recorders: generateClipName now sanitizes the recorder name so the
  S3 key / SMB path / ffmpeg arg stays clean — spaces become
  underscores, anything outside [A-Za-z0-9._-] is dropped, capped at 40
- Asset detail: audio mute + fullscreen buttons now key off streamUrl
  state (rather than videoRef.current which is null on first render)
  so they reliably appear when a stream is available
This commit is contained in:
claude 2026-05-23 04:12:42 +00:00
parent 47ad01d0b2
commit 24820e921e
3 changed files with 22 additions and 6 deletions

View file

@ -65,7 +65,14 @@ function generateClipName(recorderName) {
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${recorderName}_${year}${month}${day}_${hours}${minutes}${seconds}`;
// Strip filesystem-hostile characters out of the recorder name (spaces
// become underscores, anything outside [A-Za-z0-9._-] is dropped) so the
// clipName flows cleanly through S3 keys, SMB paths, and ffmpeg args.
const safe = String(recorderName || 'rec')
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9._-]/g, '')
.slice(0, 40) || 'rec';
return `${safe}_${year}${month}${day}_${hours}${minutes}${seconds}`;
}
// Sanitize an operator-provided clip name so it's safe as both an S3 key

View file

@ -286,12 +286,12 @@ function AssetDetail({ asset, onClose }) {
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
<PlaybackBar current={currentMs} total={totalMs} onSeek={seek} comments={visibleComments} />
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 70, textAlign: "right" }}>{asset.duration}</span>
{videoRef.current && (
{streamUrl && (
<React.Fragment>
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
<button
className="icon-btn"
title={videoRef.current.muted ? 'Unmute' : 'Mute'}
title="Toggle mute"
onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
<Icon name="audio" size={14} />
</button>

View file

@ -792,15 +792,24 @@ function NewScheduleModal({ recorders, onClose, onCreated }) {
if (!form.recorder_id) return setErr('Pick a recorder');
if (!form.start_at) return setErr('Start time is required');
if (!form.end_at) return setErr('End time is required');
if (new Date(form.end_at) <= new Date(form.start_at)) return setErr('End must be after start');
const startD = new Date(form.start_at);
const endD = new Date(form.end_at);
if (endD <= startD) return setErr('End must be after start');
// Warn (but allow) start times in the past the scheduler tick will fire
// them immediately, which is occasionally what the operator wants
// (e.g. "record the next 30 minutes starting now").
if (startD < new Date(Date.now() - 60_000)) {
if (!confirm('Start time is in the past — recorder will fire immediately when saved.\nContinue?')) return;
}
// Datetime-local inputs are in the browser's local zone; ship as ISO so
// Postgres stores them as TIMESTAMPTZ properly.
const body = {
name: form.name.trim(),
recorder_id: form.recorder_id,
start_at: new Date(form.start_at).toISOString(),
end_at: new Date(form.end_at).toISOString(),
start_at: startD.toISOString(),
end_at: endD.toISOString(),
recurrence: form.recurrence,
};