pegasus/frontend/src/UploaderView.tsx

328 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-11 00:02:59 -03:00
import PreviewThumb from './uploader-thumb'
import uploaderUtils from './uploader-utils'
import useUploaderController from './uploader-controller'
const { composeName, isVideoFile, fmt } = uploaderUtils
export default function UploaderView() {
const c = useUploaderController()
return (
<>
<section>
<hgroup>
<h2>Signed in: {c.me?.username}</h2>
<p className="meta">
Destination: <strong>{c.destPath || '/(choose a library)'}</strong>
</p>
</hgroup>
</section>
<section>
<h3>Choose content</h3>
<div className="grid-3">
<label>
Default date
<input type="date" value={c.globalDate} onChange={(e) => c.setGlobalDate(e.target.value)} />
</label>
</div>
<div className="file-picker">
{c.mobile ? (
<>
<label>
Gallery/Photos
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
<label>
Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
</>
) : (
<>
<label>
Select file(s)
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
<label>
Select folder(s)
<input type="file" multiple ref={c.folderInputRef} onChange={(e) => e.target.files && c.handleChoose(e.target.files)} />
</label>
</>
)}
</div>
</section>
<section>
<h3>Review Files</h3>
{c.sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
<div style={{ display: 'grid', gap: 12 }}>
<label>
Description for all videos (optional)
<input
placeholder="Short video description"
value={c.bulkDesc}
onChange={(e) => c.setBulkDesc(e.target.value)}
/>
</label>
<button type="button" className="stretch" onClick={c.applyDescToAllVideos} disabled={!c.bulkDesc.trim()}>
Apply to all videos
</button>
{c.videosNeedingDesc > 0 ? (
<small className="meta bad">{c.videosNeedingDesc} video(s) need a description</small>
) : (
<small className="meta mono-wrap">All videos have descriptions</small>
)}
</div>
</article>
{c.sel.map((s, i) => (
<article key={i} className="video-card">
<div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div>
<h4 className="filename wrap-anywhere" title={s.file.name}>
{s.file.name}
</h4>
<label>
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
<input
required={isVideoFile(s.file)}
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc}
onChange={(e) => {
const v = e.target.value
c.setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
}}
/>
</label>
<label>
Date
<input
type="date"
value={s.date}
onChange={(e) => {
const v = e.target.value
c.setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
}}
/>
</label>
<small className="meta" title={s.finalName}>
{s.finalName}
</small>
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>}
{c.duplicateNamesInSelection.has(s.finalName) && (
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
)}
{c.existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
</article>
))}
</>
)}
</section>
<section>
<h3>Select Destination</h3>
<p className="meta">Manage a library & choose a destination.</p>
<div className="grid-3">
<label>
Library (required)
<select
value={c.lib}
onChange={(e) => {
const v = e.target.value
c.setLib(v)
c.setSub('')
if (v) void c.refresh(v, '')
}}
>
<option value=""> Select a library </option>
{c.libs.map((L) => (
<option key={L} value={L}>
{L}
</option>
))}
</select>
</label>
</div>
{!c.lib ? (
<p className="meta">Select a library to create subfolders and view contents.</p>
) : (
<>
<div className="grid-3">
<label style={{ gridColumn: '1 / -1' }}>
Add a new subfolder (optional):
<input
placeholder="letters, numbers, underscores, dashes"
value={c.newFolderRaw}
onChange={(e) => c.setNewFolderRaw(e.target.value)}
inputMode="text"
/>
</label>
{c.newFolderRaw.trim() && (
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<button
type="button"
onClick={() => {
void c.createSubfolder(c.newFolderRaw)
c.setNewFolderRaw('')
}}
>
Create
</button>
</div>
)}
</div>
<article className="card">
<div className="row-between" style={{ marginBottom: 6 }}>
<div>
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{c.sub || '(library root)'}</strong>
</div>
</div>
{c.sub && (
<div className="sub-actions">
<button
type="button"
className="icon-btn"
title="Go to library root"
aria-label="Go to library root"
onClick={() => {
c.setSub('')
void c.refresh(c.lib, '')
}}
>
🏠
</button>
<button type="button" onClick={() => void c.renameFolder(c.sub)}>
Rename
</button>
<button
type="button"
className="icon-btn danger"
title="Delete subfolder"
aria-label="Delete subfolder"
onClick={() => void c.deleteFolder(c.sub)}
>
</button>
</div>
)}
</article>
<details open style={{ marginTop: 8 }}>
<summary>Subfolders</summary>
{c.rootDirs.length === 0 ? (
<p className="meta">
No subfolders yet. Youll upload into <b>/{c.lib}</b>.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{c.rootDirs.map((d) => (
<article key={d} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={d}>
📁 {d}
</div>
</div>
<div className="row-right">
<button
onClick={() => {
c.setSub(d)
void c.refresh(c.lib, d)
}}
>
Select
</button>
</div>
</article>
))}
</div>
)}
</details>
<details open style={{ marginTop: 8 }}>
<summary>Contents of {c.destPath || '/(choose)'}</summary>
{c.sortedRows.length === 0 ? (
<p className="meta">Empty.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{c.sortedRows.map((f) => (
<article key={f.path} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>
{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}
</div>
<small className="meta meta-wrap">
{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}
</small>
</div>
<div className="row-right">
{f.is_dir ? (
<button
onClick={() => {
c.setSub(f.name)
void c.refresh(c.lib, f.name)
}}
>
Open
</button>
) : (
<button
className="icon-btn bad"
title="Delete"
aria-label={`Delete ${f.name}`}
onClick={() => void c.deletePath(f.path, false)}
>
</button>
)}
</div>
</article>
))}
</div>
)}
</details>
</>
)}
</section>
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<small className="meta" aria-live="polite">
{c.status}
</small>
{c.hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
</div>
<button
type="button"
onClick={() => {
const disabled = !c.lib || !c.sel.length || c.uploading || c.videosNeedingDesc > 0 || c.hasNameIssues
if (!disabled) void c.doUpload()
}}
disabled={!c.lib || !c.sel.length || c.uploading || c.videosNeedingDesc > 0 || c.hasNameIssues}
>
Upload {c.sel.length ? `(${c.sel.length})` : ''}
</button>
</footer>
</>
)
}