pegasus/frontend/src/UploaderView.tsx

328 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
)
}