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:
parent
47ad01d0b2
commit
24820e921e
3 changed files with 22 additions and 6 deletions
|
|
@ -65,7 +65,14 @@ function generateClipName(recorderName) {
|
||||||
const hours = String(now.getHours()).padStart(2, '0');
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
const seconds = String(now.getSeconds()).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
|
// Sanitize an operator-provided clip name so it's safe as both an S3 key
|
||||||
|
|
|
||||||
|
|
@ -286,12 +286,12 @@ function AssetDetail({ asset, onClose }) {
|
||||||
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-2)", minWidth: 70 }}>{msToTimecode(currentMs)}</span>
|
<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} />
|
<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>
|
<span className="mono" style={{ fontSize: 11.5, color: "var(--text-3)", minWidth: 70, textAlign: "right" }}>{asset.duration}</span>
|
||||||
{videoRef.current && (
|
{streamUrl && (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
|
<div style={{ width: 1, height: 18, background: "var(--border-strong)" }} />
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
title={videoRef.current.muted ? 'Unmute' : 'Mute'}
|
title="Toggle mute"
|
||||||
onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
|
onClick={function() { if (videoRef.current) { videoRef.current.muted = !videoRef.current.muted; } }}>
|
||||||
<Icon name="audio" size={14} />
|
<Icon name="audio" size={14} />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -792,15 +792,24 @@ function NewScheduleModal({ recorders, onClose, onCreated }) {
|
||||||
if (!form.recorder_id) return setErr('Pick a recorder');
|
if (!form.recorder_id) return setErr('Pick a recorder');
|
||||||
if (!form.start_at) return setErr('Start time is required');
|
if (!form.start_at) return setErr('Start time is required');
|
||||||
if (!form.end_at) return setErr('End 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
|
// Datetime-local inputs are in the browser's local zone; ship as ISO so
|
||||||
// Postgres stores them as TIMESTAMPTZ properly.
|
// Postgres stores them as TIMESTAMPTZ properly.
|
||||||
const body = {
|
const body = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
recorder_id: form.recorder_id,
|
recorder_id: form.recorder_id,
|
||||||
start_at: new Date(form.start_at).toISOString(),
|
start_at: startD.toISOString(),
|
||||||
end_at: new Date(form.end_at).toISOString(),
|
end_at: endD.toISOString(),
|
||||||
recurrence: form.recurrence,
|
recurrence: form.recurrence,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue