328 lines
12 KiB
TypeScript
328 lines
12 KiB
TypeScript
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. You’ll 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>
|
||
</>
|
||
)
|
||
}
|