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