chore: more upload improvements

This commit is contained in:
florian 2024-07-27 19:51:38 +02:00
parent 37bc592a6d
commit e9ff853b81
15 changed files with 496 additions and 347 deletions

View File

@ -12,47 +12,47 @@
"analyze": "vite-bundle-visualizer"
},
"dependencies": {
"@heroicons/react": "^2.1.4",
"@heroicons/react": "^2.1.5",
"@noble/hashes": "^1.4.0",
"@nostr-dev-kit/ndk": "^2.8.2",
"@nostr-dev-kit/ndk-cache-dexie": "^2.4.2",
"@tanstack/react-query": "^5.50.1",
"@tanstack/react-query-devtools": "^5.50.1",
"@tanstack/react-query": "^5.51.15",
"@tanstack/react-query-devtools": "^5.51.15",
"add": "^2.0.6",
"axios": "^1.7.2",
"blossom-client-sdk": "^0.9.0",
"blurhash": "^2.0.5",
"dayjs": "^1.11.11",
"dayjs": "^1.11.12",
"id3js": "^2.1.1",
"lodash": "^4.17.21",
"nostr-tools": "^2.7.0",
"p-limit": "^6.0.0",
"nostr-tools": "^2.7.1",
"p-limit": "^6.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-pdf": "^9.1.0",
"react-router-dom": "^6.24.1"
"react-router-dom": "^6.25.1"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.50.1",
"@types/lodash": "^4.17.6",
"@tanstack/eslint-plugin-query": "^5.51.15",
"@types/lodash": "^4.17.7",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.15.0",
"@typescript-eslint/parser": "^7.15.0",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"autoprefixer": "^10.4.19",
"daisyui": "latest",
"eslint": "^8.56.0",
"eslint": "^9.8.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"postcss": "^8.4.39",
"prettier": "^3.3.2",
"tailwindcss": "^3.4.4",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"eslint-plugin-react-refresh": "^0.4.9",
"postcss": "^8.4.40",
"prettier": "^3.3.3",
"tailwindcss": "^3.4.7",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"vite-bundle-visualizer": "^1.2.1"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.18.0"
"@rollup/rollup-linux-x64-gnu": "4.19.1"
}
}

View File

@ -2,13 +2,9 @@ import { useState, useMemo } from 'react';
import { BlobDescriptor } from 'blossom-client-sdk';
import {
ClipboardDocumentIcon,
DocumentIcon,
ExclamationTriangleIcon,
FilmIcon,
FolderIcon,
FolderPlusIcon,
MusicalNoteIcon,
PhotoIcon,
PlusIcon,
TrashIcon,
XMarkIcon,
@ -24,6 +20,7 @@ import BlobListTypeMenu, { ListMode } from './BlobListTypeMenu';
import useFileMetaEventsByHash from '../../utils/useFileMetaEvents';
import './BlobList.css';
import { useBlobSelection } from './useBlobSelection';
import MimeTypeIcon from '../MimeTypeIcon';
type BlobListProps = {
blobs: BlobDescriptor[];
@ -75,15 +72,6 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
</div>
);
const getMimeTypeIcon = (type: string | undefined) => {
if (!type) return <DocumentIcon />;
if (type.startsWith('image/')) return <PhotoIcon />;
if (type.startsWith('video/')) return <FilmIcon />;
if (type.startsWith('audio/')) return <MusicalNoteIcon />;
if (type === 'application/pdf') return <DocumentIcon />;
return <DocumentIcon />;
};
const Badges = ({ blob }: { blob: BlobDescriptor }) => {
const events = fileMetaEventsByHash[blob.sha256];
if (!events) return null;
@ -208,7 +196,7 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
onChange={e => handleSelectBlob(blob.sha256, e)}
onClick={e => e.stopPropagation()}
/>
{getMimeTypeIcon(blob.type)}
<MimeTypeIcon type={blob.type} />
</td>
<td className="whitespace-nowrap">
<a className="link link-primary" href={blob.url} target="_blank">

View File

@ -1,14 +1,12 @@
import { useEffect, useState } from 'react';
import { formatFileSize } from '../../utils/utils';
import { useEffect } from 'react';
import { extractDomain, formatFileSize } from '../../utils/utils';
import { fetchId3Tag } from '../../utils/id3';
import useVideoThumbnailDvm from './dvm';
import { usePublishing } from './usePublishing';
import { BlobDescriptor } from 'blossom-client-sdk';
import { transferBlob } from '../../utils/transfer';
import { useNDK } from '../../utils/ndk';
import TagInput from '../TagInput';
import { allGenres } from '../../utils/genres';
import { useServerInfo } from '../../utils/useServerInfo';
import { ServerIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
import MimeTypeIcon from '../MimeTypeIcon';
import { NostrEvent } from '@nostr-dev-kit/ndk';
export type FileEventData = {
originalFile: File;
@ -21,6 +19,7 @@ export type FileEventData = {
m?: string;
size: number;
thumbnails?: string[];
selectedThumbnail?: string;
publishedThumbnail?: string;
blurHash?: string;
tags: string[];
@ -32,17 +31,32 @@ export type FileEventData = {
year?: string;
genre?: string;
subgenre?: string;
publish: {
file?: boolean;
audio?: boolean;
video?: boolean;
};
events: NostrEvent[];
};
const FileEventEditor = ({ data }: { data: FileEventData }) => {
const { signEventTemplate } = useNDK();
const { serverInfo } = useServerInfo();
const [fileEventData, setFileEventData] = useState(data);
const [selectedThumbnail, setSelectedThumbnail] = useState<string | undefined>();
const copyToClipboard = (text: string) => {
navigator.clipboard
.writeText(text)
.then(() => {})
.catch(err => {
console.error('Failed to copy: ', err);
});
};
const { createDvmThumbnailRequest, thumbnailRequestEventId } = useVideoThumbnailDvm(setFileEventData);
const { publishAudioEvent, publishFileEvent, publishVideoEvent } = usePublishing();
const [jsonOutput, setJsonOutput] = useState('');
const FileEventEditor = ({
fileEventData,
setFileEventData,
}: {
fileEventData: FileEventData;
setFileEventData: (fe: FileEventData) => void;
}) => {
const { createDvmThumbnailRequest, thumbnailRequestEventId } = useVideoThumbnailDvm(fileEventData, setFileEventData);
const isAudio = fileEventData.m?.startsWith('audio/');
const isVideo = fileEventData.m?.startsWith('video/');
@ -74,292 +88,260 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
title: id3.title,
year: id3.year,
thumbnails: res.coverFull ? [res.coverFull] : [],
selectedThumbnail: res.coverFull,
});
setSelectedThumbnail(res.coverFull);
});
}
}, [fileEventData]);
function extractDomain(url: string): string | null {
const regex = /^(https?:\/\/)([^/]+)/;
const match = url.match(regex);
return match ? match[2]?.toLocaleLowerCase() : null;
}
const publishSelectedThumbnailToAllOwnServers = async (): Promise<BlobDescriptor | undefined> => {
// TODO investigate why mimetype is not set for reuploaded thumbnail (on mediaserver)
const servers = fileEventData.url.map(extractDomain);
// upload selected thumbnail to the same blossom servers as the video
let uploadedThumbnails: BlobDescriptor[] = [];
if (selectedThumbnail) {
uploadedThumbnails = (
await Promise.all(
servers.map(s => {
if (s && selectedThumbnail) {
console.log(s);
console.log(serverInfo);
return transferBlob(selectedThumbnail, serverInfo[s], signEventTemplate);
}
})
)
).filter(t => t !== undefined) as BlobDescriptor[];
return uploadedThumbnails.length > 0 ? uploadedThumbnails[0] : undefined; // TODO do we need multiple thumbsnails?? or server URLs?
}
};
useEffect(() => {
if (selectedThumbnail == undefined) {
if (fileEventData.selectedThumbnail == undefined) {
if (fileEventData.thumbnails && fileEventData.thumbnails?.length > 0) {
setSelectedThumbnail(fileEventData.thumbnails[0]);
setFileEventData({
...fileEventData,
selectedThumbnail: fileEventData.thumbnails[0],
});
}
}
}, [fileEventData.thumbnails, selectedThumbnail]);
// TODO add tags editor
}, [fileEventData.thumbnails, fileEventData.selectedThumbnail]);
return (
<>
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
{fileEventData.m?.startsWith('video/') && (
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
<div>
<div className="flex gap-4 flex-col">
{fileEventData.publish.file !== undefined && (
<div className="form-control flex-row">
<label className="label cursor-pointer gap-2">
<input
type="checkbox"
className="toggle toggle-primary"
checked={fileEventData.publish.file}
onChange={e =>
setFileEventData({
...fileEventData,
publish: { ...fileEventData.publish, file: e.target.checked },
})
}
/>
<span className="label-text">Publish file meta data event</span>
</label>
</div>
)}
{fileEventData.publish.video !== undefined && (
<div className="form-control flex-row">
<label className="label cursor-pointer gap-2">
<input
type="checkbox"
className="toggle toggle-primary"
checked={fileEventData.publish.video}
onChange={e =>
setFileEventData({
...fileEventData,
publish: { ...fileEventData.publish, video: e.target.checked },
})
}
/>
<span className="label-text">Publish video event</span>
</label>
</div>
)}
{fileEventData.publish.audio !== undefined && (
<div className="form-control flex-row">
<label className="label cursor-pointer gap-2">
<input
type="checkbox"
className="toggle toggle-primary"
checked={fileEventData.publish.audio}
onChange={e =>
setFileEventData({
...fileEventData,
publish: { ...fileEventData.publish, audio: e.target.checked },
})
}
/>
<span className="label-text">Publish audio event</span>
</label>
</div>
)}
</div>
</div>
{fileEventData.m?.startsWith('video/') && (
<>
{thumbnailRequestEventId &&
(fileEventData.thumbnails && fileEventData.thumbnails.length > 0 ? (
<div className="w-2/6">
<div className="carousel w-full">
{fileEventData.thumbnails.map((t, i) => (
<div id={`item${i + 1}`} key={`item${i + 1}`} className="carousel-item w-full">
<img
width={300}
height={300}
src={`https://images.slidestr.net/insecure/f:webp/rs:fill:600/plain/${t}`}
className="w-full"
/>
</div>
))}
</div>
<div className="flex justify-center w-full py-2 gap-2">
{fileEventData.thumbnails.map((t, i) => (
<a
key={`link${i + 1}`}
href={`#item${i + 1}`}
onClick={() => setFileEventData({ ...fileEventData, selectedThumbnail: t })}
className={'btn btn-xs ' + (t == fileEventData.selectedThumbnail ? 'btn-primary' : '')}
>{`${i + 1}`}</a>
))}
</div>
</div>
) : (
<div>
Creating previews <span className="loading loading-spinner loading-md"></span>
</div>
))}
</>
)}
{isAudio && (fileEventData.publishedThumbnail || fileEventData.selectedThumbnail) && (
<div className="w-2/6">
<img
width={300}
height={300}
src={
fileEventData.publishedThumbnail
? `https://images.slidestr.net/insecure/f:webp/rs:fill:600/plain/${fileEventData.publishedThumbnail}`
: fileEventData.selectedThumbnail
}
className="w-full"
/>
</div>
)}
{fileEventData.m?.startsWith('image/') && (
<div className="p-4 bg-base-300 w-2/6">
<img
width={300}
height={300}
src={`https://images.slidestr.net/insecure/f:webp/rs:fill:600/plain/${fileEventData.url[0]}`}
></img>
</div>
)}
<div className="grid gap-4 w-4/6" style={{ gridTemplateColumns: '1fr 30em' }}>
{(isAudio || isVideo) && (
<>
{thumbnailRequestEventId &&
(fileEventData.thumbnails && fileEventData.thumbnails.length > 0 ? (
<div className="w-2/6">
<div className="carousel w-full">
{fileEventData.thumbnails.map((t, i) => (
<div id={`item${i + 1}`} key={`item${i + 1}`} className="carousel-item w-full">
<img
width={300}
height={300}
src={`https://images.slidestr.net/insecure/f:webp/rs:fill:600/plain/${t}`}
className="w-full"
/>
</div>
))}
</div>
<div className="flex justify-center w-full py-2 gap-2">
{fileEventData.thumbnails.map((t, i) => (
<a
key={`link${i + 1}`}
href={`#item${i + 1}`}
onClick={() => setSelectedThumbnail(t)}
className={'btn btn-xs ' + (t == selectedThumbnail ? 'btn-primary' : '')}
>{`${i + 1}`}</a>
))}
</div>
</div>
) : (
<div>
Creating previews <span className="loading loading-spinner loading-md"></span>
</div>
))}
<span className="font-bold">Title</span>
<input
type="text"
className="input input-primary"
value={fileEventData.title}
onChange={e => setFileEventData({ ...fileEventData, title: e.target.value })}
></input>
</>
)}
{isAudio && (fileEventData.publishedThumbnail || selectedThumbnail) && (
<div className="w-2/6">
<img
width={300}
height={300}
src={
fileEventData.publishedThumbnail
? `https://images.slidestr.net/insecure/f:webp/rs:fill:600/plain/${fileEventData.publishedThumbnail}`
: selectedThumbnail
}
className="w-full"
/>
</div>
{isAudio && (
<>
<span className="font-bold">Artist</span>
<span>{fileEventData.artist}</span>
</>
)}
{isAudio && (
<>
<span className="font-bold">Album</span>
<span>{fileEventData.album}</span>
</>
)}
{isAudio && (
<>
<span className="font-bold">Year</span>
<span>{fileEventData.year}</span>
</>
)}
{fileEventData.m?.startsWith('image/') && (
<div className="p-4 bg-base-300 w-2/6">
<img
width={300}
height={300}
src={`https://images.slidestr.net/insecure/f:webp/rs:fill:600/plain/${fileEventData.url[0]}`}
></img>
</div>
)}
<div className="grid gap-4 w-4/6" style={{ gridTemplateColumns: '1fr 30em' }}>
{(isAudio || isVideo) && (
<>
<span className="font-bold">Title</span>
<input
type="text"
className="input input-primary"
value={fileEventData.title}
onChange={e => setFileEventData(ed => ({ ...ed, title: e.target.value }))}
></input>
</>
)}
{isAudio && (
<>
<span className="font-bold">Artist</span>
<span>{fileEventData.artist}</span>
</>
)}
{isAudio && (
<>
<span className="font-bold">Album</span>
<span>{fileEventData.album}</span>
</>
)}
{isAudio && (
<>
<span className="font-bold">Year</span>
<span>{fileEventData.year}</span>
</>
)}
<span className="font-bold">Summary / Description</span>
<textarea
value={fileEventData.content}
onChange={e => setFileEventData(ed => ({ ...ed, content: e.target.value }))}
className="textarea textarea-primary"
placeholder="Caption"
></textarea>
{isAudio && (
<>
<span className="font-bold">Genre</span>
<div>
<select
className="select select-bordered select-primary w-full max-w-xs"
value={fileEventData.genre}
onChange={e => setFileEventData(ed => ({ ...ed, genre: e.target.value, subgenre: '' }))}
>
<option disabled>Select a genre</option>
{Object.keys(allGenres).map(g => (
<span className="font-bold">Summary / Description</span>
<textarea
value={fileEventData.content}
onChange={e => setFileEventData({ ...fileEventData, content: e.target.value })}
className="textarea textarea-primary"
placeholder="Caption"
></textarea>
{isAudio && (
<>
<span className="font-bold">Genre</span>
<div>
<select
className="select select-bordered select-primary w-full max-w-xs"
value={fileEventData.genre}
onChange={e => setFileEventData({ ...fileEventData, genre: e.target.value, subgenre: '' })}
>
<option disabled>Select a genre</option>
{Object.keys(allGenres).map(g => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<select
className="select select-bordered select-primary w-full max-w-xs mt-2"
value={fileEventData.subgenre}
disabled={
fileEventData.genre == undefined ||
allGenres[fileEventData.genre] == undefined ||
allGenres[fileEventData.genre].length == 0
}
onChange={e => setFileEventData({ ...fileEventData, subgenre: e.target.value })}
>
<option disabled value="">
Select a sub genre
</option>
{fileEventData.genre &&
allGenres[fileEventData.genre] &&
allGenres[fileEventData.genre].length > 0 &&
allGenres[fileEventData.genre].map(g => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
<select
className="select select-bordered select-primary w-full max-w-xs mt-2"
value={fileEventData.subgenre}
disabled={
fileEventData.genre == undefined ||
allGenres[fileEventData.genre] == undefined ||
allGenres[fileEventData.genre].length == 0
}
onChange={e => setFileEventData(ed => ({ ...ed, subgenre: e.target.value }))}
>
<option disabled value="">
Select a sub genre
</option>
{fileEventData.genre &&
allGenres[fileEventData.genre] &&
allGenres[fileEventData.genre].length > 0 &&
allGenres[fileEventData.genre].map(g => (
<option key={g} value={g}>
{g}
</option>
))}
</select>
</div>
</>
)}
<span className="font-bold">Tags</span>
<TagInput
tags={fileEventData.tags}
setTags={(tags: string[]) => setFileEventData(ed => ({ ...ed, tags }))}
></TagInput>
</select>
</div>
</>
)}
<span className="font-bold">Tags</span>
<TagInput
tags={fileEventData.tags}
setTags={(tags: string[]) => setFileEventData({ ...fileEventData, tags })}
></TagInput>
<span className="font-bold">Type</span>
<span>{fileEventData.m}</span>
<span className="font-bold">Type</span>
<span className="flex flex-row gap-2">
<MimeTypeIcon className="w-6 h-6" type={fileEventData.m} /> {fileEventData.m}
</span>
{fileEventData.dim && (
<>
<span className="font-bold">Dimensions</span>
<span>{fileEventData.dim}</span>
</>
)}
{fileEventData.dim && (
<>
<span className="font-bold">Dimensions</span>
<span>{fileEventData.dim}</span>
</>
)}
<span className="font-bold">File size</span>
<span>{fileEventData.size ? formatFileSize(fileEventData.size) : 'unknown'}</span>
<span className="font-bold">URL</span>
<div className="">
{fileEventData.url.map((text, i) => (
<div key={i} className="break-words mb-2">
{text}
</div>
))}
</div>
<span className="font-bold">File size</span>
<span>{fileEventData.size ? formatFileSize(fileEventData.size) : 'unknown'}</span>
<span className="font-bold">URLs</span>
<div className="flex flex-col gap-2">
{fileEventData.url.map((url, i) => (
<div key={i} className="flex flex-row gap-2 items-center">
<a href={url} className="flex flex-row gap-2 hover:text-primary" target="_blank">
<ServerIcon className="w-6 h-6" /> {extractDomain(url)}
</a>
<a
onClick={() => copyToClipboard(url)}
className="btn btn-sm btn-ghost hover:btn-neutral p-1 tooltip"
data-tip="Copy to clipboard"
>
<ClipboardDocumentIcon className="w-6 h-6" />
</a>
</div>
))}
</div>
</div>{' '}
<div className="flex gap-2 flex-col">
<div className=" alert alert-warning ">
DEVELOPMENT ZONE! These publish buttons do not work yet. Events are only shown in the browser console.
</div>
<div className="flex gap-2">
<button
className="btn btn-primary"
onClick={async () => {
if (!fileEventData.publishedThumbnail) {
await publishSelectedThumbnailToAllOwnServers();
}
setJsonOutput(await publishFileEvent(fileEventData));
}}
>
Create File Event
</button>
<button
className="btn btn-primary"
onClick={async () => {
if (!fileEventData.publishedThumbnail) {
const selfHostedThumbnail = await publishSelectedThumbnailToAllOwnServers();
if (selfHostedThumbnail) {
const newData: FileEventData = {
...fileEventData,
publishedThumbnail: selfHostedThumbnail.url,
thumbnails: [selfHostedThumbnail.url],
};
setFileEventData(newData);
setJsonOutput(await publishAudioEvent(newData));
} else {
// self hosting failed
console.log('self hosting failed');
setJsonOutput(await publishAudioEvent(fileEventData));
}
} else {
// data thumbnail already defined
console.log('data thumbnail already defined');
setJsonOutput(await publishAudioEvent(fileEventData));
}
}}
>
Create Audio Event
</button>
<button
className="btn btn-primary"
onClick={async () => {
if (!fileEventData.publishedThumbnail) {
const selfHostedThumbnail = await publishSelectedThumbnailToAllOwnServers();
if (selfHostedThumbnail) {
const newData: FileEventData = {
...fileEventData,
publishedThumbnail: selfHostedThumbnail.url,
thumbnails: [selfHostedThumbnail.url],
};
setFileEventData(newData);
setJsonOutput(await publishVideoEvent(newData));
} else {
// self hosting failed
console.log('self hosting failed');
setJsonOutput(await publishVideoEvent(fileEventData));
}
} else {
// data thumbnail already defined
console.log('data thumbnail already defined');
setJsonOutput(await publishVideoEvent(fileEventData));
}
}}
>
Create Video Event
</button>
</div>
<div className="font-mono text-xs whitespace-pre">{jsonOutput}</div>
</div>
</>
</div>
);
};

View File

@ -46,7 +46,7 @@ const ensureDecrypted = async (dvm: NDKUser, event: NDKEvent): Promise<NDKEvent
return event;
};
const useVideoThumbnailDvm = (setFileEventData: React.Dispatch<React.SetStateAction<FileEventData>>) => {
const useVideoThumbnailDvm = (fileEventData: FileEventData, setFileEventData: (data: FileEventData) => void) => {
const [thumbnailRequestEventId, setThumbnailRequestEventId] = useState<string | undefined>();
const { ndk, user } = useNDK();
const dvm = ndk.getUser({ npub: NPUB_DVM_THUMBNAIL_CREATION });
@ -68,7 +68,7 @@ const useVideoThumbnailDvm = (setFileEventData: React.Dispatch<React.SetStateAct
const urls = firstEvent.tags.filter(t => t[0] === 'thumb').map(t => t[1]);
const dim = firstEvent.tags.find(t => t[0] === 'dim')?.[1];
const duration = firstEvent.tags.find(t => t[0] === 'duration')?.[1];
setFileEventData(ed => ({ ...ed, thumbnails: urls, dim, duration }));
setFileEventData({ ...fileEventData, thumbnails: urls, dim, duration });
}
};
doASync();

View File

@ -8,7 +8,7 @@ import { KIND_AUDIO, KIND_FILE_META, KIND_VIDEO_HORIZONTAL, KIND_VIDEO_VERTICAL
export const usePublishing = () => {
const { ndk, user } = useNDK();
const publishFileEvent = async (data: FileEventData): Promise<string> => {
const publishFileEvent = async (data: FileEventData): Promise<NostrEvent> => {
const e: NostrEvent = {
created_at: dayjs().unix(),
content: data.content,
@ -45,11 +45,11 @@ export const usePublishing = () => {
const ev = new NDKEvent(ndk, e);
await ev.sign();
console.log(ev.rawEvent());
// await ev.publish();
return JSON.stringify(ev.rawEvent(), null, 2);
//await ev.publish();
return ev.rawEvent();
};
const publishAudioEvent = async (data: FileEventData): Promise<string> => {
const publishAudioEvent = async (data: FileEventData): Promise<NostrEvent> => {
const e: NostrEvent = {
created_at: dayjs().unix(),
content: `${data.artist} - ${data.title}`,
@ -88,16 +88,16 @@ export const usePublishing = () => {
}
}
// published_at
e.tags.push(['published_at', `${dayjs().unix()}`]);
const ev = new NDKEvent(ndk, e);
await ev.sign();
console.log(ev.rawEvent());
// await ev.publish();
return JSON.stringify(ev.rawEvent(), null, 2);
//await ev.publish();
return ev.rawEvent();
};
const publishVideoEvent = async (data: FileEventData): Promise<string> => {
const publishVideoEvent = async (data: FileEventData): Promise<NostrEvent> => {
const videoIsHorizontal = data.width == undefined || data.height == undefined || data.width > data.height;
const e: NostrEvent = {
@ -138,8 +138,8 @@ export const usePublishing = () => {
const ev = new NDKEvent(ndk, e);
await ev.sign();
console.log(ev.rawEvent());
// await ev.publish();
return JSON.stringify(ev.rawEvent(), null, 2);
//await ev.publish();
return ev.rawEvent();
};
return {

View File

@ -26,7 +26,7 @@ export const Layout = () => {
>
<ArrowUpOnSquareIcon /> Upload
</button>
<button className={`btn ${location.pathname == '/' ? 'btn-neutral' : ''} `} onClick={() => navigate('/')}>
<button className={`btn ${location.pathname == '/' ? 'btn-neutral' : ''} `} onClick={() => navigate('/browse')}>
<MagnifyingGlassIcon /> Browse
</button>
<button
@ -60,7 +60,7 @@ export const Layout = () => {
</div>
</div>
<button className="btn btn-ghost text-xl">
<a className="logo" onClick={() => navigate('/')}>
<a className="logo" onClick={() => navigate('/browse')}>
<img className="w-8" src="/bouquet.png" />{' '}
</a>
<span>bouquet</span>

View File

@ -0,0 +1,12 @@
import { DocumentIcon, FilmIcon, MusicalNoteIcon, PhotoIcon } from '@heroicons/react/24/outline';
const MimeTypeIcon = ({ type, className }: { type: string | undefined; className?: string }) => {
if (!type) return <DocumentIcon className={className} />;
if (type.startsWith('image/')) return <PhotoIcon className={className} />;
if (type.startsWith('video/')) return <FilmIcon className={className} />;
if (type.startsWith('audio/')) return <MusicalNoteIcon className={className} />;
if (type === 'application/pdf') return <DocumentIcon className={className} />;
return <DocumentIcon className={className} />;
};
export default MimeTypeIcon;

View File

@ -47,11 +47,13 @@ const Server = ({
<div className="flex flex-col grow">
<div className="server-name">
{server.name}
<div
className={`badge ${selectedServer == server.name ? 'badge-primary' : 'badge-neutral'} ml-2 align-middle`}
>
{server.type}
</div>
{!server.virtual && (
<div
className={`badge ${selectedServer == server.name ? 'badge-primary' : 'badge-neutral'} ml-2 align-middle`}
>
{server.type}
</div>
)}
{server.isLoading && <span className="ml-2 loading loading-spinner loading-sm"></span>}
</div>
{server.isError ? (

View File

@ -1,13 +1,13 @@
.server-list {
@apply flex flex-col gap-4;
@apply flex flex-col gap-2;
}
.server {
@apply bg-base-200 text-neutral-content rounded-lg p-4 gap-4 w-full flex flex-row items-center;
@apply bg-base-200 text-neutral-content rounded-lg p-3 gap-4 w-full flex flex-row items-center;
}
.server-name {
@apply text-2xl mb-2 text-neutral-content;
@apply text-xl mb-2 text-accent;
}
.server.selected {
@ -19,7 +19,7 @@
}
.server-stats {
@apply grid gap-x-8 gap-y-4 grid-cols-2 md:grid-cols-[4em_6em_8em_auto];
@apply grid gap-x-2 gap-y-2 md:gap-x-8 md:gap-y-4 grid-cols-2 md:grid-cols-[4em_6em_8em_auto];
}
.server-stat svg {
@ -32,11 +32,11 @@
.server-icon svg,
.server-actions svg {
@apply w-10;
@apply w-8;
}
.server-actions {
@apply flex flex-row gap-4 hidden md:flex;
@apply flex flex-row gap-2 hidden md:flex;
}
.server-actions a {

View File

@ -1,4 +1,4 @@
import { ArrowUpOnSquareIcon, ServerIcon, TrashIcon } from '@heroicons/react/24/outline';
import { ArrowUpOnSquareIcon, ExclamationTriangleIcon, ServerIcon, TrashIcon } from '@heroicons/react/24/outline';
import React, { ChangeEvent, DragEvent, useMemo, useRef } from 'react';
import CheckBox from './CheckBox/CheckBox';
import { Server } from '../utils/useUserServers';
@ -121,7 +121,7 @@ const UploadFileSelection: React.FC<UploadFileSelectionProps> = ({
</label>
<div className="cursor-pointer gap-2 flex flex-row">
<div className="flex flex-col gap-4 w-1/2">
<div className="flex flex-col gap-4 w-1/2 pl-4">
<h3 className="text-lg text-neutral-content">Servers</h3>
<div className="grid gap-2" style={{ gridTemplateColumns: '2em auto' }}>
{servers.map(s => (
@ -138,6 +138,12 @@ const UploadFileSelection: React.FC<UploadFileSelectionProps> = ({
</CheckBox>
))}
</div>
{Object.values(transfers).filter(t => t.enabled).length <= 1 && (
<div className="alert alert-neutral text-sm pl-0">
<ExclamationTriangleIcon className="w-6 text-warning" /> It's recommended to upload to multiple servers to
ensure availability and censorship resistance.
</div>
)}
</div>
{imagesAreUploaded && (
<div className="flex flex-col gap-4 w-1/2">

View File

@ -4,7 +4,7 @@ import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { NDKContextProvider } from './utils/ndk.tsx';
import { Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from 'react-router-dom';
import { Navigate, Route, RouterProvider, createBrowserRouter, createRoutesFromElements } from 'react-router-dom';
import { Layout } from './components/Layout/Layout.tsx';
import Home from './pages/Home.tsx';
import { Transfer } from './pages/Transfer.tsx';
@ -40,7 +40,8 @@ export function createIDBPersister(idbValidKey: IDBValidKey = 'reactQuery') {
const router = createBrowserRouter(
createRoutesFromElements(
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/" element={<Navigate to="/upload" replace />} />
<Route path="/browse" element={<Home />} />
<Route path="/transfer/:source" element={<Transfer />} />
<Route path="/transfer" element={<Transfer />} />
<Route path="/upload" element={<Upload />} />

View File

@ -51,7 +51,7 @@ export const Transfer = () => {
setTransferTarget(undefined);
setTransferLog({});
setStarted(false);
navigate('/');
navigate('/browse');
};
const transferJobs = useMemo(() => {

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { BlobDescriptor, BlossomClient, SignedEvent } from 'blossom-client-sdk';
import { useNDK } from '../utils/ndk';
import { useServerInfo } from '../utils/useServerInfo';
@ -13,6 +13,12 @@ import { getBlurhashAndSizeFromFile } from '../utils/blur';
import UploadFileSelection, { ResizeOptions, TransferStats } from '../components/UploadFileSelection';
import UploadProgress from '../components/UploadProgress';
import { uploadNip96File } from '../utils/nip96';
import { extractDomain } from '../utils/utils';
import { transferBlob } from '../utils/transfer';
import { usePublishing } from '../components/FileEventEditor/usePublishing';
import { useNavigate } from 'react-router-dom';
import BlobList from '../components/BlobList/BlobList';
import { NostrEvent } from '@nostr-dev-kit/ndk';
function Upload() {
const servers = useUserServers();
@ -27,6 +33,8 @@ function Upload() {
const [fileEventsToPublish, setFileEventsToPublish] = useState<FileEventData[]>([]);
const [imageResize, setImageResize] = useState(0);
const [uploadStep, setUploadStep] = useState(0);
const { publishFileEvent, publishAudioEvent, publishVideoEvent } = usePublishing();
const navigate = useNavigate();
async function getListOfFilesToUpload() {
const filesToUpload: File[] = [];
@ -58,6 +66,14 @@ function Upload() {
url: [] as string[],
originalFile: file,
tags: [] as string[],
size: file.size,
m: file.type,
publish: {
file: true,
audio: file.type.startsWith('audio/') ? true : undefined,
video: file.type.startsWith('video/') ? true : undefined,
},
events: [] as NostrEvent[],
} as FileEventData;
if (file.type.startsWith('image/')) {
const imageInfo = await getBlurhashAndSizeFromFile(file);
@ -228,6 +244,97 @@ function Upload() {
}
}, [servers, transfersInitialized]);
const publishSelectedThumbnailToAllOwnServers = async (
fileEventData: FileEventData
): Promise<BlobDescriptor | undefined> => {
// TODO investigate why mimetype is not set for reuploaded thumbnail (on mediaserver)
const servers = fileEventData.url.map(u => extractDomain(u));
// upload selected thumbnail to the same blossom servers as the video
let uploadedThumbnails: BlobDescriptor[] = [];
if (fileEventData.selectedThumbnail) {
uploadedThumbnails = (
await Promise.all(
servers.map(s => {
if (s && fileEventData.selectedThumbnail) {
console.log(s);
console.log(serverInfo);
return transferBlob(fileEventData.selectedThumbnail, serverInfo[s], signEventTemplate);
}
})
)
).filter(t => t !== undefined) as BlobDescriptor[];
return uploadedThumbnails.length > 0 ? uploadedThumbnails[0] : undefined; // TODO do we need multiple thumbsnails?? or server URLs?
}
};
const publishAll = async () => {
setUploadStep(3);
const publishedEvents: FileEventData[] = [];
fileEventsToPublish.forEach(async fe => {
if (fe.publish.file) {
const publishedEvent = await publishFileEvent(fe);
publishedEvents.push({ ...fe, events: [...fe.events, publishedEvent] });
}
if (fe.publish.audio) {
if (!fe.publishedThumbnail) {
const selfHostedThumbnail = await publishSelectedThumbnailToAllOwnServers(fe);
if (selfHostedThumbnail) {
const newData: FileEventData = {
...fe,
publishedThumbnail: selfHostedThumbnail.url,
thumbnails: [selfHostedThumbnail.url],
};
const publishedEvent = await publishAudioEvent(newData);
publishedEvents.push({ ...newData, events: [...newData.events, publishedEvent] });
} else {
// self hosting failed
console.log('self hosting failed');
const publishedEvent = await publishAudioEvent(fe);
publishedEvents.push({ ...fe, events: [...fe.events, publishedEvent] });
}
} else {
// data thumbnail already defined
console.log('data thumbnail already defined');
const publishedEvent = await publishAudioEvent(fe);
publishedEvents.push({ ...fe, events: [...fe.events, publishedEvent] });
}
}
if (fe.publish.video) {
if (!fe.publishedThumbnail) {
const selfHostedThumbnail = await publishSelectedThumbnailToAllOwnServers(fe);
if (selfHostedThumbnail) {
const newData: FileEventData = {
...fe,
publishedThumbnail: selfHostedThumbnail.url,
thumbnails: [selfHostedThumbnail.url],
};
publishedEvents.push(newData);
const publishedEvent = await publishVideoEvent(newData);
publishedEvents.push({ ...newData, events: [...newData.events, publishedEvent] });
} else {
// self hosting failed
console.log('self hosting failed');
const publishedEvent = await publishVideoEvent(fe);
publishedEvents.push({ ...fe, events: [...fe.events, publishedEvent] });
}
} else {
// data thumbnail already defined
console.log('data thumbnail already defined');
const publishedEvent = await publishVideoEvent(fe);
publishedEvents.push({ ...fe, events: [...fe.events, publishedEvent] });
}
}
});
setFileEventsToPublish(publishedEvents);
};
const publishCount = useMemo(
() => fileEventsToPublish.filter(fe => fe.publish.file || fe.publish.audio || fe.publish.video).length,
[fileEventsToPublish]
);
return (
<>
<ul className="steps p-8">
@ -258,12 +365,54 @@ function Upload() {
{uploadStep == 1 && <UploadProgress servers={servers} transfers={transfers} />}
</div>
)}
{fileEventsToPublish.length > 0 && (
{uploadStep == 2 && fileEventsToPublish.length > 0 && (
<>
<h2 className="py-4">Publish events</h2>
{fileEventsToPublish.map(fe => (
<FileEventEditor key={fe.x} data={fe} />
))}
<div className="flex flex-col gap-4">
{fileEventsToPublish.map(fe => (
<FileEventEditor
key={fe.x}
fileEventData={fe}
setFileEventData={updatedFe =>
setFileEventsToPublish(prev => prev.map(f => (f.x === fe.x ? updatedFe : f)) as FileEventData[])
}
/>
))}
</div>
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex mt-4 flex-row justify-center">
<button
className={`btn ${publishCount === 0 ? 'btn-primary' : 'btn-neutral'} w-40`}
onClick={() => {
navigate('/browse');
}}
>
Skip publishing
</button>
{publishCount > 0 && (
<button className="btn btn-primary w-40" onClick={() => publishAll()}>
Publish ({publishCount} event{publishCount > 1 ? 's' : ''})
</button>
)}
</div>
</>
)}
{uploadStep == 3 && (
<>
<div>Published events</div>
<div className="flex flex-col gap-4">
{fileEventsToPublish.map(fe =>
fe.events.map(ev => <div className=" pre">{JSON.stringify(ev, null, 2)}</div>)
)}
<BlobList
blobs={fileEventsToPublish.map(fe => ({
url: fe.url[0],
type: fe.m,
sha256: fe.x,
size: fe.size,
uploaded: 0,
}))}
></BlobList>
</div>
</>
)}
</>

View File

@ -53,7 +53,10 @@ export const uploadBlossomBlob = async (
return res.data;
};
export const downloadBlossomBlob = async (url: string, onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void) => {
export const downloadBlossomBlob = async (
url: string,
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
) => {
const response = await axios.get(url, {
responseType: 'blob',
onDownloadProgress,
@ -89,4 +92,4 @@ export const mirrordBlossomBlob = async (
}
);
return res.data;
};
};

View File

@ -44,3 +44,9 @@ export const formatDate = (unixTimeStamp: number): string => {
if (ts == 0) return 'never';
return dayjs(ts * 1000).format('YYYY-MM-DD');
};
export function extractDomain(url: string): string | null {
const regex = /^(https?:\/\/)([^/]+)/;
const match = url.match(regex);
return match ? match[2]?.toLocaleLowerCase() : null;
}