feat(downloads): share UI — user picker + public watch links + watch page

Rework the share dialog into two clear modes and add the public /watch player page:
- ShareDialog: (A) 'Share with a user' — autocomplete picker over registered users (was a blind
  email box that 404'd on non-users); (B) 'Share a link' — create/list/copy/revoke public links
  with allow-download toggle, optional expiry (1/7/30d), optional password; per-link view count.
- WatchPage: standalone login-free player at /watch/<token> (routed in main.tsx like /privacy),
  self-contained mini-i18n (en/hu/de by browser language); password gate → unlock → play; shows a
  Download button only when the link allows it.
- api: ShareLink/ShareRecipient types + link CRUD + recipients; share i18n (en/hu/de).
Verified end-to-end in a real browser: user picker, link create, public playback, stream-only vs
downloadable, password gate + unlock, no console errors.
This commit is contained in:
npeter83 2026-07-04 04:32:31 +02:00
parent d672583830
commit 391b8fda33
8 changed files with 543 additions and 38 deletions

View file

@ -19,6 +19,7 @@ import Modal from "./Modal";
import DownloadDialog from "./DownloadDialog";
import ProfileEditor from "./ProfileEditor";
import VideoEditor from "./VideoEditor";
import ShareDialog from "./ShareDialog";
import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api";
@ -186,39 +187,6 @@ function RenameModal({ job, onClose }: { job: DownloadJob; onClose: () => void }
);
}
function ShareModal({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation();
const [email, setEmail] = useState("");
const share = useMutation({
mutationFn: () => api.shareDownload(job.id, email.trim()),
onSuccess: (r) => {
notify({ level: "success", message: t("downloads.share.done", { email: r.shared_with }) });
onClose();
},
});
return (
<Modal title={t("downloads.share.title")} onClose={onClose}>
<label className="block text-sm font-medium mb-1">{t("downloads.share.label")}</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t("downloads.share.placeholder")}
className={inputCls}
autoFocus
/>
<div className="flex justify-end mt-4">
<button
onClick={() => share.mutate()}
disabled={!email.trim() || share.isPending}
className="px-3 py-1.5 rounded-lg text-sm font-medium bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{t("downloads.share.submit")}
</button>
</div>
</Modal>
);
}
// --- Usage bar ------------------------------------------------------------------------------
function UsageBar() {
@ -541,7 +509,7 @@ export default function DownloadCenter({ me }: { me: Me }) {
)}
{manage && <ProfileEditor onClose={() => setManage(false)} />}
{renameJob && <RenameModal job={renameJob} onClose={() => setRenameJob(null)} />}
{shareJob && <ShareModal job={shareJob} onClose={() => setShareJob(null)} />}
{shareJob && <ShareDialog job={shareJob} onClose={() => setShareJob(null)} />}
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</div>
);