feat: file picker
This commit is contained in:
parent
8fb2d9d058
commit
9b63ec42e3
@ -12,6 +12,7 @@ export interface ModalProps {
|
||||
children: ReactNode;
|
||||
showClose?: boolean;
|
||||
ready?: boolean;
|
||||
largeModal?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal(props: ModalProps) {
|
||||
@ -49,9 +50,13 @@ export default function Modal(props: ModalProps) {
|
||||
className={
|
||||
props.bodyClassName ??
|
||||
classNames(
|
||||
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto lg:w-[500px] max-lg:w-full",
|
||||
{ "max-xl:translate-y-0": props.ready ?? true },
|
||||
{ "max-xl:translate-y-[50vh]": !(props.ready ?? true) },
|
||||
"relative bg-layer-1 p-8 transition max-xl:rounded-t-3xl xl:rounded-3xl max-xl:mt-auto xl:my-auto max-lg:w-full",
|
||||
{
|
||||
"max-xl:translate-y-0": props.ready ?? true,
|
||||
"max-xl:translate-y-[50vh]": !(props.ready ?? true),
|
||||
"lg:w-[500px]": !(props.largeModal ?? false),
|
||||
"lg:w-[80vw]": props.largeModal ?? false,
|
||||
},
|
||||
)
|
||||
}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
|
107
src/element/upload/file-list.tsx
Normal file
107
src/element/upload/file-list.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { useMediaServerList } from "@/hooks/media-servers";
|
||||
import { Nip96Server } from "@/service/upload/nip96";
|
||||
import { findTag } from "@/utils";
|
||||
import { NostrEvent } from "@snort/system";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FormattedMessage, FormattedNumber } from "react-intl";
|
||||
import { PrimaryButton } from "../buttons";
|
||||
|
||||
export function MediaServerFileList({ onPicked }: { onPicked: (files: Array<NostrEvent>) => void }) {
|
||||
const login = useLogin();
|
||||
const [fileList, setFilesList] = useState<Array<NostrEvent>>([]);
|
||||
const [pickedFiles, setPickedFiles] = useState<Array<string>>([]);
|
||||
const servers = useMediaServerList();
|
||||
|
||||
async function listFiles() {
|
||||
const res = [];
|
||||
const pub = login?.publisher();
|
||||
if (!pub) return;
|
||||
for (const s of servers.servers) {
|
||||
try {
|
||||
const sx = new Nip96Server(s, pub);
|
||||
const files = await sx.listFiles();
|
||||
if (files?.files) {
|
||||
res.push(...files.files);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
setFilesList(res);
|
||||
}
|
||||
|
||||
function toggleFile(ev: NostrEvent) {
|
||||
const hash = findTag(ev, "x");
|
||||
if (!hash) return;
|
||||
setPickedFiles(a => {
|
||||
if (a.includes(hash)) {
|
||||
return a.filter(a => a != hash);
|
||||
} else {
|
||||
return [...a, hash];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
listFiles().catch(console.error);
|
||||
}, [servers.servers.length, login?.state?.version]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h3>
|
||||
<FormattedMessage defaultMessage="File List" />
|
||||
</h3>
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{fileList.map(a => (
|
||||
<Nip96File file={a} onClick={() => toggleFile(a)} checked={pickedFiles.includes(findTag(a, "x") ?? "")} />
|
||||
))}
|
||||
</div>
|
||||
<PrimaryButton
|
||||
disabled={pickedFiles.length === 0}
|
||||
onClick={() => onPicked(fileList.filter(a => pickedFiles.includes(findTag(a, "x") ?? "")))}>
|
||||
<FormattedMessage defaultMessage="Select" />
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Nip96File({ file, checked, onClick }: { file: NostrEvent; checked: boolean; onClick: () => void }) {
|
||||
const mime = findTag(file, "m");
|
||||
const url = findTag(file, "url");
|
||||
const size = findTag(file, "size");
|
||||
return (
|
||||
<div onClick={() => onClick()}>
|
||||
<div
|
||||
className="relative bg-layer-2 rounded-xl overflow-hidden aspect-video cursor-pointer hover:outline outline-layer-3"
|
||||
style={{
|
||||
backgroundImage: mime?.startsWith("image/") ? `url(${url})` : "",
|
||||
backgroundSize: "cover",
|
||||
}}>
|
||||
<div className="absolute w-full h-full opacity-0 bg-black hover:opacity-80 flex flex-col items-center justify-center gap-4">
|
||||
<div>
|
||||
{Number(size) > 1024 * 1024 && (
|
||||
<FormattedMessage
|
||||
defaultMessage="{n}MiB"
|
||||
values={{
|
||||
n: <FormattedNumber value={Number(size) / 1024 / 1024} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Number(size) < 1024 * 1024 && (
|
||||
<FormattedMessage
|
||||
defaultMessage="{n}KiB"
|
||||
values={{
|
||||
n: <FormattedNumber value={Number(size) / 1024} />,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>{new Date(file.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<input type="checkbox" className="left-2 top-2 absolute" checked={checked} />
|
||||
</div>
|
||||
<small>{file.content}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,31 +1,35 @@
|
||||
import { EventKind, UnknownTag } from "@snort/system";
|
||||
import { useLogin } from "./login";
|
||||
import { removeUndefined, sanitizeRelayUrl } from "@snort/shared";
|
||||
import { Nip96Uploader } from "@/service/upload/nip96";
|
||||
import { Nip96Server } from "@/service/upload/nip96";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useMediaServerList() {
|
||||
const login = useLogin();
|
||||
|
||||
const servers = login?.state?.getList(EventKind.StorageServerList) ?? [];
|
||||
|
||||
return {
|
||||
servers: removeUndefined(servers.map(a => a.toEventTag()))
|
||||
.filter(a => a[0] === "server")
|
||||
.map(a => a[1]),
|
||||
addServer: async (s: string) => {
|
||||
const pub = login?.publisher();
|
||||
if (!pub) return;
|
||||
return useMemo(
|
||||
() => ({
|
||||
servers: removeUndefined(servers.map(a => a.toEventTag()))
|
||||
.filter(a => a[0] === "server")
|
||||
.map(a => a[1]),
|
||||
addServer: async (s: string) => {
|
||||
const pub = login?.publisher();
|
||||
if (!pub) return;
|
||||
|
||||
const u = sanitizeRelayUrl(s);
|
||||
if (!u) return;
|
||||
const server = new Nip96Uploader(u, pub);
|
||||
await server.loadInfo();
|
||||
await login?.state?.addToList(EventKind.StorageServerList, new UnknownTag(["server", u]), true);
|
||||
},
|
||||
removeServer: async (s: string) => {
|
||||
const u = sanitizeRelayUrl(s);
|
||||
if (!u) return;
|
||||
await login?.state?.removeFromList(EventKind.StorageServerList, new UnknownTag(["server", u]), true);
|
||||
},
|
||||
};
|
||||
const u = sanitizeRelayUrl(s);
|
||||
if (!u) return;
|
||||
const server = new Nip96Server(u, pub);
|
||||
await server.loadInfo();
|
||||
await login?.state?.addToList(EventKind.StorageServerList, new UnknownTag(["server", u]), true);
|
||||
},
|
||||
removeServer: async (s: string) => {
|
||||
const u = sanitizeRelayUrl(s);
|
||||
if (!u) return;
|
||||
await login?.state?.removeFromList(EventKind.StorageServerList, new UnknownTag(["server", u]), true);
|
||||
},
|
||||
}),
|
||||
[servers],
|
||||
);
|
||||
}
|
||||
|
@ -405,6 +405,9 @@
|
||||
"NnHu0L": {
|
||||
"defaultMessage": "Please upload at least 1 video"
|
||||
},
|
||||
"Ntjkqd": {
|
||||
"defaultMessage": "or"
|
||||
},
|
||||
"O2Cy6m": {
|
||||
"defaultMessage": "Yes, I am over 18"
|
||||
},
|
||||
@ -452,6 +455,9 @@
|
||||
"Pe0ogR": {
|
||||
"defaultMessage": "Theme"
|
||||
},
|
||||
"PySFxG": {
|
||||
"defaultMessage": "Choose file."
|
||||
},
|
||||
"Q3au2v": {
|
||||
"defaultMessage": "About {estimate}"
|
||||
},
|
||||
@ -553,6 +559,9 @@
|
||||
"defaultMessage": "Value",
|
||||
"description": "Config value column header"
|
||||
},
|
||||
"WcZM+B": {
|
||||
"defaultMessage": "File List"
|
||||
},
|
||||
"Wp4l7+": {
|
||||
"defaultMessage": "More Videos"
|
||||
},
|
||||
@ -633,6 +642,9 @@
|
||||
"bfvyfs": {
|
||||
"defaultMessage": "Anon"
|
||||
},
|
||||
"c3LlRO": {
|
||||
"defaultMessage": "{n}KiB"
|
||||
},
|
||||
"cPIKU2": {
|
||||
"defaultMessage": "Following"
|
||||
},
|
||||
@ -754,6 +766,9 @@
|
||||
"kGjqZ4": {
|
||||
"defaultMessage": "Latest Shorts"
|
||||
},
|
||||
"kQAf2d": {
|
||||
"defaultMessage": "Select"
|
||||
},
|
||||
"kc5EOy": {
|
||||
"defaultMessage": "Username is too long"
|
||||
},
|
||||
@ -790,6 +805,9 @@
|
||||
"n19IQE": {
|
||||
"defaultMessage": "Recommended size: 1920x1080 (16:9)"
|
||||
},
|
||||
"n8k1SG": {
|
||||
"defaultMessage": "{n}MiB"
|
||||
},
|
||||
"nBCvvJ": {
|
||||
"defaultMessage": "Topup"
|
||||
},
|
||||
|
@ -4,12 +4,13 @@ import { Icon } from "@/element/icon";
|
||||
import Modal from "@/element/modal";
|
||||
import { Profile } from "@/element/profile";
|
||||
import Spinner from "@/element/spinner";
|
||||
import { MediaServerFileList } from "@/element/upload/file-list";
|
||||
import { ServerList } from "@/element/upload/server-list";
|
||||
import useImgProxy from "@/hooks/img-proxy";
|
||||
import { useLogin } from "@/hooks/login";
|
||||
import { useMediaServerList } from "@/hooks/media-servers";
|
||||
import { Nip94Tags, UploadResult, nip94TagsToIMeta } from "@/service/upload";
|
||||
import { Nip96Uploader } from "@/service/upload/nip96";
|
||||
import { Nip96Server } from "@/service/upload/nip96";
|
||||
import { openFile } from "@/utils";
|
||||
import { ExternalStore, removeUndefined, unixNow, unwrap } from "@snort/shared";
|
||||
import { EventPublisher, NostrLink } from "@snort/system";
|
||||
@ -33,7 +34,7 @@ interface UploadDraft {
|
||||
}
|
||||
|
||||
class UploadManager extends ExternalStore<Array<UploadStatus>> {
|
||||
#uploaders: Map<string, Nip96Uploader> = new Map();
|
||||
#uploaders: Map<string, Nip96Server> = new Map();
|
||||
#uploads: Map<string, UploadStatus> = new Map();
|
||||
#id: string;
|
||||
|
||||
@ -71,7 +72,7 @@ class UploadManager extends ExternalStore<Array<UploadStatus>> {
|
||||
async uploadTo(server: string, file: File, pub: EventPublisher, type: UploadStatus["type"]) {
|
||||
let uploader = this.#uploaders.get(server);
|
||||
if (!uploader) {
|
||||
uploader = new Nip96Uploader(server, pub);
|
||||
uploader = new Nip96Server(server, pub);
|
||||
this.#uploaders.set(server, uploader);
|
||||
}
|
||||
|
||||
@ -173,6 +174,7 @@ export function UploadPage() {
|
||||
const [summary, setSummary] = useState("");
|
||||
const [thumb, setThumb] = useState("");
|
||||
const [editServers, setEditServers] = useState(false);
|
||||
const [mediaPicker, setMediaPicker] = useState(false);
|
||||
const { proxy } = useImgProxy();
|
||||
const uploads = useSyncExternalStore(
|
||||
c => manager.hook(c),
|
||||
@ -298,10 +300,16 @@ export function UploadPage() {
|
||||
|
||||
const uploadButton = () => {
|
||||
return (
|
||||
<div className="flex items-center gap-4 bg-layer-3 rounded-lg p-4">
|
||||
<Icon name="upload" />
|
||||
<FormattedMessage defaultMessage="Upload Video" />
|
||||
</div>
|
||||
<>
|
||||
<div className="flex items-center gap-4 bg-layer-3 rounded-lg p-4" onClick={() => uploadFile()}>
|
||||
<Icon name="upload" />
|
||||
<FormattedMessage defaultMessage="Upload Video" />
|
||||
</div>
|
||||
<FormattedMessage defaultMessage="or" />
|
||||
<div className="flex items-center gap-4 bg-layer-3 rounded-lg p-4" onClick={() => setMediaPicker(true)}>
|
||||
<FormattedMessage defaultMessage="Choose file." />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
@ -313,9 +321,7 @@ export function UploadPage() {
|
||||
<FormattedMessage defaultMessage="Manage Servers" />
|
||||
</Layer3Button>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => uploadFile()}
|
||||
className="relative bg-layer-2 rounded-xl w-full aspect-video cursor-pointer overflow-hidden">
|
||||
<div className="relative bg-layer-2 rounded-xl w-full aspect-video cursor-pointer overflow-hidden">
|
||||
{videos > 0 && (
|
||||
<video
|
||||
className="w-full h-full absolute"
|
||||
@ -324,7 +330,7 @@ export function UploadPage() {
|
||||
/>
|
||||
)}
|
||||
{videos === 0 && (
|
||||
<div className="absolute w-full h-full flex items-center justify-center">{uploadButton()}</div>
|
||||
<div className="absolute w-full h-full flex items-center gap-4 justify-center">{uploadButton()}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
@ -407,6 +413,15 @@ export function UploadPage() {
|
||||
<ServerList />
|
||||
</Modal>
|
||||
)}
|
||||
{mediaPicker && (
|
||||
<Modal id="media-picker" onClose={() => setMediaPicker(false)} largeModal={true}>
|
||||
<MediaServerFileList
|
||||
onPicked={files => {
|
||||
setMediaPicker(false);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { base64 } from "@scure/base";
|
||||
import { throwIfOffline } from "@snort/shared";
|
||||
import { EventKind, EventPublisher } from "@snort/system";
|
||||
import { EventKind, EventPublisher, NostrEvent } from "@snort/system";
|
||||
|
||||
import { FileExtensionRegex, UploadResult, readNip94Tags } from ".";
|
||||
|
||||
export class Nip96Uploader {
|
||||
export class Nip96Server {
|
||||
#info?: Nip96Info;
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly publisher: EventPublisher,
|
||||
@ -20,39 +22,26 @@ export class Nip96Uploader {
|
||||
const u = new URL(this.url);
|
||||
|
||||
const rsp = await fetch(`${u.protocol}//${u.host}/.well-known/nostr/nip96.json`);
|
||||
return (await rsp.json()) as Nip96Info;
|
||||
this.#info = (await rsp.json()) as Nip96Info;
|
||||
return this.#info;
|
||||
}
|
||||
|
||||
async listFiles(page = 0, count = 50) {
|
||||
const rsp = await this.#req(`?page=${page}&count=${count}`, "GET");
|
||||
if (rsp.ok) {
|
||||
return (await rsp.json()) as Nip96FileList;
|
||||
}
|
||||
}
|
||||
|
||||
async upload(file: File | Blob, filename: string): Promise<UploadResult> {
|
||||
throwIfOffline();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic(eb => {
|
||||
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
|
||||
});
|
||||
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
|
||||
};
|
||||
|
||||
const info = await this.loadInfo();
|
||||
const fd = new FormData();
|
||||
fd.append("size", file.size.toString());
|
||||
fd.append("caption", filename);
|
||||
fd.append("media_type", file.type);
|
||||
fd.append("file", file);
|
||||
|
||||
let u = info.api_url;
|
||||
if (u.startsWith("/")) {
|
||||
u = `${this.url}${u.slice(1)}`;
|
||||
}
|
||||
const rsp = await fetch(u, {
|
||||
body: fd,
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(u, "POST"),
|
||||
},
|
||||
});
|
||||
const rsp = await this.#req("", "POST", fd);
|
||||
if (rsp.ok) {
|
||||
throwIfOffline();
|
||||
const data = (await rsp.json()) as Nip96Result;
|
||||
if (data.status === "success") {
|
||||
const meta = readNip94Tags(data.nip94_event.tags);
|
||||
@ -72,10 +61,15 @@ export class Nip96Uploader {
|
||||
meta.url += ".webp";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
case "image/jpeg":
|
||||
case "image/jpg": {
|
||||
meta.url += ".jpg";
|
||||
break;
|
||||
}
|
||||
case "video/mp4": {
|
||||
meta.url += ".mp4";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
@ -100,6 +94,31 @@ export class Nip96Uploader {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #req(path: string, method: "GET" | "POST" | "DELETE", body?: BodyInit) {
|
||||
throwIfOffline();
|
||||
const auth = async (url: string, method: string) => {
|
||||
const auth = await this.publisher.generic(eb => {
|
||||
return eb.kind(EventKind.HttpAuthentication).tag(["u", url]).tag(["method", method]);
|
||||
});
|
||||
return `Nostr ${base64.encode(new TextEncoder().encode(JSON.stringify(auth)))}`;
|
||||
};
|
||||
|
||||
const info = this.#info ?? (await this.loadInfo());
|
||||
let u = info.api_url;
|
||||
if (u.startsWith("/")) {
|
||||
u = `${this.url}${u.slice(1)}`;
|
||||
}
|
||||
u += path;
|
||||
return await fetch(u, {
|
||||
method,
|
||||
body,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: await auth(u, method),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export interface Nip96Info {
|
||||
@ -111,8 +130,12 @@ export interface Nip96Result {
|
||||
status: string;
|
||||
message: string;
|
||||
processing_url?: string;
|
||||
nip94_event: {
|
||||
tags: Array<Array<string>>;
|
||||
content: string;
|
||||
};
|
||||
nip94_event: NostrEvent;
|
||||
}
|
||||
|
||||
export interface Nip96FileList {
|
||||
count: number;
|
||||
total: number;
|
||||
page: number;
|
||||
files: Array<NostrEvent>;
|
||||
}
|
||||
|
@ -134,6 +134,7 @@
|
||||
"My6HwN": "Ok, it's safe",
|
||||
"NTpJIm": "Enable Recording",
|
||||
"NnHu0L": "Please upload at least 1 video",
|
||||
"Ntjkqd": "or",
|
||||
"O2Cy6m": "Yes, I am over 18",
|
||||
"O7AeYh": "Description..",
|
||||
"OEW7yJ": "Zaps",
|
||||
@ -149,6 +150,7 @@
|
||||
"PXAur5": "Withdraw",
|
||||
"Pc+tM3": "Generate",
|
||||
"Pe0ogR": "Theme",
|
||||
"PySFxG": "Choose file.",
|
||||
"Q3au2v": "About {estimate}",
|
||||
"Q8Qw5B": "Description",
|
||||
"QNvtaq": "Share on X",
|
||||
@ -182,6 +184,7 @@
|
||||
"W7IRLs": "Your title is too short",
|
||||
"W9355R": "Unmute",
|
||||
"WVJZ0U": "Value",
|
||||
"WcZM+B": "File List",
|
||||
"Wp4l7+": "More Videos",
|
||||
"WsjXrZ": "Click on Log In",
|
||||
"X2PZ7D": "Create Goal",
|
||||
@ -208,6 +211,7 @@
|
||||
"bD/ZwY": "Edit Cards",
|
||||
"bbUGS7": "Recommended Stream Settings",
|
||||
"bfvyfs": "Anon",
|
||||
"c3LlRO": "{n}KiB",
|
||||
"cPIKU2": "Following",
|
||||
"ccXLVi": "Category",
|
||||
"cg1VJ2": "Connect Wallet",
|
||||
@ -248,6 +252,7 @@
|
||||
"k21gTS": "e.g. about me",
|
||||
"kAEQyV": "OK",
|
||||
"kGjqZ4": "Latest Shorts",
|
||||
"kQAf2d": "Select",
|
||||
"kc5EOy": "Username is too long",
|
||||
"khJ51Q": "Stream Earnings",
|
||||
"kp0NPF": "Planned",
|
||||
@ -260,6 +265,7 @@
|
||||
"msjwph": "Keyframe Interval",
|
||||
"mtNGwh": "A short description of the content",
|
||||
"n19IQE": "Recommended size: 1920x1080 (16:9)",
|
||||
"n8k1SG": "{n}MiB",
|
||||
"nBCvvJ": "Topup",
|
||||
"nOaArs": "Setup Profile",
|
||||
"nwA8Os": "Add card",
|
||||
|
Loading…
x
Reference in New Issue
Block a user