mirror of
https://git.v0l.io/florian/bouquet.git
synced 2025-03-17 21:13:02 +01:00
chore: more upload improvements
This commit is contained in:
parent
37bc592a6d
commit
e9ff853b81
38
package.json
38
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
12
src/components/MimeTypeIcon.tsx
Normal file
12
src/components/MimeTypeIcon.tsx
Normal 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;
|
@ -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 ? (
|
||||
|
@ -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 {
|
||||
|
@ -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">
|
||||
|
@ -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 />} />
|
||||
|
@ -51,7 +51,7 @@ export const Transfer = () => {
|
||||
setTransferTarget(undefined);
|
||||
setTransferLog({});
|
||||
setStarted(false);
|
||||
navigate('/');
|
||||
navigate('/browse');
|
||||
};
|
||||
|
||||
const transferJobs = useMemo(() => {
|
||||
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
@ -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;
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user