Add season field to episode scripts and improve global search; v2.0.50
Publish Container / build (push) Successful in 16m53s
Publish Container / build (push) Successful in 16m53s
Tag scripts with a season number (with an RSS-feed backfill for existing uploads), surface it in search results, and fix word-boundary matching plus a title/script-match dedup bug in the search merge logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b236e69f22
commit
a6e8243705
5 files changed
+226
-41
No files matched your search
+79
-12
@@ -1586,13 +1586,16 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [commentSearch, setCommentSearch] = useState('')
|
||||
|
||||
// Episode scripts state
|
||||
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; uploadedAt: string | null; wordCount: number }>>([])
|
||||
const [scriptsList, setScriptsList] = useState<Array<{ episodeNumber: string; title: string; filename: string; season: string | null; uploadedAt: string | null; wordCount: number }>>([])
|
||||
const [scriptsLoading, setScriptsLoading] = useState(false)
|
||||
const [scriptUploadEpisode, setScriptUploadEpisode] = useState('')
|
||||
const [scriptUploadTitle, setScriptUploadTitle] = useState('')
|
||||
const [scriptUploadSeason, setScriptUploadSeason] = useState('')
|
||||
const [scriptUploadFile, setScriptUploadFile] = useState<File | null>(null)
|
||||
const [scriptUploadBusy, setScriptUploadBusy] = useState(false)
|
||||
const [scriptUploadMsg, setScriptUploadMsg] = useState('')
|
||||
const [scriptSeasonFilter, setScriptSeasonFilter] = useState('all')
|
||||
const [scriptBackfillMsg, setScriptBackfillMsg] = useState('')
|
||||
|
||||
// QR codes state
|
||||
interface QrCode { id: string; slug: string; label: string; destination: string; active: boolean; createdAt: string; scanCount?: number }
|
||||
@@ -2997,6 +3000,9 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (!scriptUploadEpisode.trim() || !/^\d+$/.test(scriptUploadEpisode.trim())) {
|
||||
setScriptUploadMsg('Enter a valid episode number.'); return
|
||||
}
|
||||
if (scriptUploadSeason.trim() && !/^\d+$/.test(scriptUploadSeason.trim())) {
|
||||
setScriptUploadMsg('Season must be a positive whole number.'); return
|
||||
}
|
||||
if (!scriptUploadFile) { setScriptUploadMsg('Select a .docx file.'); return }
|
||||
setScriptUploadBusy(true)
|
||||
setScriptUploadMsg('')
|
||||
@@ -3010,13 +3016,19 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
const res = await fetch(`/api/admin-episode-scripts/${encodeURIComponent(scriptUploadEpisode.trim())}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dataUrl, filename: scriptUploadFile.name, title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}` }),
|
||||
body: JSON.stringify({
|
||||
dataUrl,
|
||||
filename: scriptUploadFile.name,
|
||||
title: scriptUploadTitle.trim() || `Episode ${scriptUploadEpisode.trim()}`,
|
||||
season: scriptUploadSeason.trim(),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setScriptUploadMsg(data.message ?? 'Upload failed.'); return }
|
||||
setScriptUploadMsg(`✓ Episode ${scriptUploadEpisode} uploaded — ${data.wordCount?.toLocaleString()} words indexed.`)
|
||||
setScriptUploadEpisode('')
|
||||
setScriptUploadTitle('')
|
||||
setScriptUploadSeason('')
|
||||
setScriptUploadFile(null)
|
||||
loadScriptsList()
|
||||
} catch {
|
||||
@@ -3026,6 +3038,26 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackfillSeasons() {
|
||||
setScriptsLoading(true)
|
||||
setScriptBackfillMsg('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-episode-scripts/backfill-seasons', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (!res.ok) { setScriptBackfillMsg(data.message ?? 'Backfill failed.'); return }
|
||||
const missing = (data.stillMissing ?? []) as string[]
|
||||
setScriptBackfillMsg(
|
||||
`✓ Filled in season for ${data.updated} script${data.updated === 1 ? '' : 's'} from the RSS feed.` +
|
||||
(missing.length > 0 ? ` ${missing.length} episode${missing.length === 1 ? '' : 's'} not found in the feed (set season manually): ${missing.join(', ')}.` : '')
|
||||
)
|
||||
await loadScriptsList()
|
||||
} catch {
|
||||
setScriptBackfillMsg('Backfill failed. Please try again.')
|
||||
} finally {
|
||||
setScriptsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteScript(episodeNumber: string) {
|
||||
if (!confirm(`Remove script for Episode ${episodeNumber}?`)) return
|
||||
try {
|
||||
@@ -5227,6 +5259,17 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
onChange={e => setScriptUploadTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="script-season">Season <span style={{ color: '#5a5440', fontWeight: 400 }}>(optional — matches the podcast season number)</span></label>
|
||||
<input
|
||||
id="script-season"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="e.g. 3"
|
||||
value={scriptUploadSeason}
|
||||
onChange={e => setScriptUploadSeason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="script-file">Word Document (.docx)</label>
|
||||
<input
|
||||
@@ -5247,10 +5290,31 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
{/* Indexed scripts list */}
|
||||
<div className="admin-panel-head" style={{ marginTop: '2rem' }}>
|
||||
<h3 style={{ margin: 0 }}>Indexed Scripts</h3>
|
||||
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading} style={{ marginLeft: 'auto' }}>
|
||||
{scriptsList.length > 0 && (() => {
|
||||
const seasons = Array.from(new Set(scriptsList.map(s => s.season).filter((s): s is string => Boolean(s)))).sort((a, b) => Number(a) - Number(b))
|
||||
return seasons.length > 0 ? (
|
||||
<select
|
||||
value={scriptSeasonFilter}
|
||||
onChange={e => setScriptSeasonFilter(e.target.value)}
|
||||
style={{ marginLeft: 'auto', marginRight: '0.75rem' }}
|
||||
>
|
||||
<option value="all">All Seasons</option>
|
||||
{seasons.map(s => <option key={s} value={s}>Season {s}</option>)}
|
||||
</select>
|
||||
) : null
|
||||
})()}
|
||||
<button type="button" className="btn-admin-reset" onClick={handleBackfillSeasons} disabled={scriptsLoading} style={{ marginLeft: scriptsList.length === 0 ? 'auto' : undefined, marginRight: '0.5rem' }}>
|
||||
Backfill Seasons from RSS
|
||||
</button>
|
||||
<button type="button" className="btn-admin-reset" onClick={loadScriptsList} disabled={scriptsLoading}>
|
||||
{scriptsLoading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{scriptBackfillMsg && (
|
||||
<p style={{ fontSize: '0.85rem', color: scriptBackfillMsg.startsWith('✓') ? '#6fcf97' : '#e07a7a', marginTop: '0.5rem' }}>
|
||||
{scriptBackfillMsg}
|
||||
</p>
|
||||
)}
|
||||
{scriptsList.length === 0 ? (
|
||||
<p style={{ color: '#5a5440', fontSize: '0.9rem', marginTop: '0.75rem' }}>
|
||||
No scripts uploaded yet.{' '}
|
||||
@@ -5258,16 +5322,19 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
</p>
|
||||
) : (
|
||||
<div className="admin-script-list">
|
||||
{scriptsList.map(script => (
|
||||
<div key={script.episodeNumber} className="admin-script-row">
|
||||
<div className="admin-script-row-info">
|
||||
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
|
||||
<span className="admin-script-title">{script.title}</span>
|
||||
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
|
||||
{scriptsList
|
||||
.filter(script => scriptSeasonFilter === 'all' || script.season === scriptSeasonFilter)
|
||||
.map(script => (
|
||||
<div key={script.episodeNumber} className="admin-script-row">
|
||||
<div className="admin-script-row-info">
|
||||
<span className="admin-script-ep">Ep. {script.episodeNumber}</span>
|
||||
{script.season && <span className="admin-script-ep">Season {script.season}</span>}
|
||||
<span className="admin-script-title">{script.title}</span>
|
||||
<span className="admin-script-meta">{script.wordCount.toLocaleString()} words · {script.filename}</span>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button>
|
||||
</div>
|
||||
<button type="button" className="btn-admin-remove" onClick={() => handleDeleteScript(script.episodeNumber)}>Remove</button>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user