feat: file picker

This commit is contained in:
kieran 2024-06-20 14:32:20 +01:00
parent 8fb2d9d058
commit 9b63ec42e3
No known key found for this signature in database
GPG Key ID: DE71CEB3925BE941
7 changed files with 242 additions and 64 deletions

View File

@ -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()}

View 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>
);
}

View File

@ -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],
);
}

View File

@ -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"
},

View File

@ -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>
);
}

View File

@ -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>;
}

View File

@ -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",