feat: Added support for thumb re upload
This commit is contained in:
parent
694cb4cc49
commit
27e9eda2b0
13
package-lock.json
generated
13
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"blossom-client-sdk": "^0.9.0",
|
"blossom-client-sdk": "^0.9.0",
|
||||||
|
"compress.js": "^2.1.2",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"id3js": "^2.1.1",
|
"id3js": "^2.1.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
@ -29,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.35.6",
|
"@tanstack/eslint-plugin-query": "^5.35.6",
|
||||||
|
"@types/compress.js": "^1.1.3",
|
||||||
"@types/lodash": "^4.17.4",
|
"@types/lodash": "^4.17.4",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
@ -1215,6 +1217,12 @@
|
|||||||
"react": "^18 || ^19"
|
"react": "^18 || ^19"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/compress.js": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/compress.js/-/compress.js-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-3uflodXzp7qcfxCwbkZ0KrqwgLIXzR/0IHoJvLfWmFXCDrB85aVSZFElsVkAm8ng227oFpNLFT4yWnm0Ozfhfw==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
||||||
@ -1998,6 +2006,11 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/compress.js": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/compress.js/-/compress.js-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-DBb6M4wwe0rRAPeiKQ8HJrWuocVppUw9Qte4rEXiDrc5X3TrzeRKLzpvSE9oZ0Nd4HTXSSFphj3/XWwuptkQqw=="
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
|
33
package.json
33
package.json
@ -12,43 +12,44 @@
|
|||||||
"analyze": "vite-bundle-visualizer"
|
"analyze": "vite-bundle-visualizer"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.1.4",
|
||||||
"@noble/hashes": "^1.4.0",
|
"@noble/hashes": "^1.4.0",
|
||||||
"@nostr-dev-kit/ndk": "^2.8.2",
|
"@nostr-dev-kit/ndk": "^2.8.2",
|
||||||
"@nostr-dev-kit/ndk-cache-dexie": "^2.4.2",
|
"@nostr-dev-kit/ndk-cache-dexie": "^2.4.2",
|
||||||
"@tanstack/react-query": "^5.39.0",
|
"@tanstack/react-query": "^5.50.1",
|
||||||
"@tanstack/react-query-devtools": "^5.39.0",
|
"@tanstack/react-query-devtools": "^5.50.1",
|
||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"blossom-client-sdk": "^0.9.0",
|
"blossom-client-sdk": "^0.9.0",
|
||||||
|
"blurhash": "^2.0.5",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"id3js": "^2.1.1",
|
"id3js": "^2.1.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"nostr-tools": "^2.5.2",
|
"nostr-tools": "^2.7.0",
|
||||||
"p-limit": "^5.0.0",
|
"p-limit": "^6.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-pdf": "^8.0.2",
|
"react-pdf": "^9.1.0",
|
||||||
"react-router-dom": "^6.23.1"
|
"react-router-dom": "^6.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tanstack/eslint-plugin-query": "^5.35.6",
|
"@tanstack/eslint-plugin-query": "^5.50.1",
|
||||||
"@types/lodash": "^4.17.4",
|
"@types/lodash": "^4.17.6",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.10.0",
|
"@typescript-eslint/eslint-plugin": "^7.15.0",
|
||||||
"@typescript-eslint/parser": "^7.10.0",
|
"@typescript-eslint/parser": "^7.15.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.7.0",
|
"@vitejs/plugin-react-swc": "^3.7.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"daisyui": "latest",
|
"daisyui": "latest",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.2",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.7",
|
"eslint-plugin-react-refresh": "^0.4.7",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.3.2",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.5.3",
|
||||||
"vite": "^5.2.11",
|
"vite": "^5.3.3",
|
||||||
"vite-bundle-visualizer": "^1.2.1"
|
"vite-bundle-visualizer": "^1.2.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
|
29
src/components/BlurImage.tsx
Normal file
29
src/components/BlurImage.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { decode } from 'blurhash';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
type BlurhashImageProps = {
|
||||||
|
blurhash: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BlurhashImage({ blurhash, width, height, ...props }: BlurhashImageProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
const pixels = decode(blurhash, width, height);
|
||||||
|
if (ctx) {
|
||||||
|
const imageData = ctx.createImageData(width, height);
|
||||||
|
imageData.data.set(pixels);
|
||||||
|
ctx.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [blurhash, width, height]);
|
||||||
|
|
||||||
|
return <canvas ref={canvasRef} width={width} height={height} {...props} />;
|
||||||
|
}
|
@ -3,19 +3,23 @@ import { formatFileSize } from '../../utils/utils';
|
|||||||
import { fetchId3Tag } from '../../utils/id3';
|
import { fetchId3Tag } from '../../utils/id3';
|
||||||
import useVideoThumbnailDvm from './dvm';
|
import useVideoThumbnailDvm from './dvm';
|
||||||
import { usePublishing } from './usePublishing';
|
import { usePublishing } from './usePublishing';
|
||||||
|
import { BlobDescriptor } from 'blossom-client-sdk';
|
||||||
|
import { transferBlob } from '../../utils/transfer';
|
||||||
|
import { useNDK } from '../../utils/ndk';
|
||||||
|
|
||||||
export type FileEventData = {
|
export type FileEventData = {
|
||||||
originalFile: File;
|
originalFile: File;
|
||||||
content: string;
|
content: string;
|
||||||
url: string[];
|
url: string[];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
dim?: string;
|
dim?: string;
|
||||||
x: string;
|
x: string;
|
||||||
m?: string;
|
m?: string;
|
||||||
size: number;
|
size: number;
|
||||||
thumbnails?: string[];
|
thumbnails?: string[];
|
||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
//summary: string;
|
blurHash?: string;
|
||||||
//alt: string;
|
|
||||||
|
|
||||||
artist?: string;
|
artist?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@ -24,13 +28,19 @@ export type FileEventData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
||||||
|
const { signEventTemplate } = useNDK();
|
||||||
const [fileEventData, setFileEventData] = useState(data);
|
const [fileEventData, setFileEventData] = useState(data);
|
||||||
|
const [selectedThumbnail, setSelectedThumbnail] = useState<string | undefined>();
|
||||||
|
|
||||||
const { createDvmThumbnailRequest, thumbnailRequestEventId } = useVideoThumbnailDvm(setFileEventData);
|
const { createDvmThumbnailRequest, thumbnailRequestEventId } = useVideoThumbnailDvm(setFileEventData);
|
||||||
const { publishAudioEvent, publishFileEvent, publishVideoEvent } = usePublishing();
|
const { publishAudioEvent, publishFileEvent, publishVideoEvent } = usePublishing();
|
||||||
const [jsonOutput, setJsonOutput] = useState('');
|
const [jsonOutput, setJsonOutput] = useState('');
|
||||||
|
|
||||||
|
const isAudio = fileEventData.m?.startsWith('audio/');
|
||||||
|
const isVideo = fileEventData.m?.startsWith('video/');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fileEventData.m?.startsWith('video/') && fileEventData.thumbnails == undefined) {
|
if (isVideo && fileEventData.thumbnails == undefined) {
|
||||||
createDvmThumbnailRequest(fileEventData);
|
createDvmThumbnailRequest(fileEventData);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@ -62,9 +72,37 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
}
|
}
|
||||||
}, [fileEventData]);
|
}, [fileEventData]);
|
||||||
|
|
||||||
|
function extractProtocolAndDomain(url: string): string | null {
|
||||||
|
const regex = /^(https?:\/\/[^/]+)/;
|
||||||
|
const match = url.match(regex);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publishSelectedThumbnailToOwnServer = async () => {
|
||||||
|
const servers = data.url.map(extractProtocolAndDomain);
|
||||||
|
|
||||||
|
// 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) return transferBlob(selectedThumbnail, s, signEventTemplate);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
).filter(t => t !== undefined) as BlobDescriptor[];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedThumbnails.length > 0) {
|
||||||
|
data.thumbnail = uploadedThumbnails[0].url; // TODO do we need multiple thumbsnails?? or server URLs?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// TODO add tags editor
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className=" bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
|
<div className="bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
|
||||||
{fileEventData.m?.startsWith('video/') && (
|
{fileEventData.m?.startsWith('video/') && (
|
||||||
<>
|
<>
|
||||||
{thumbnailRequestEventId &&
|
{thumbnailRequestEventId &&
|
||||||
@ -82,7 +120,7 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
<a
|
<a
|
||||||
key={`link${i + 1}`}
|
key={`link${i + 1}`}
|
||||||
href={`#item${i + 1}`}
|
href={`#item${i + 1}`}
|
||||||
onClick={() => setFileEventData(ed => ({ ...ed, thumbnail: t }))}
|
onClick={() => setSelectedThumbnail(t)}
|
||||||
className={'btn btn-xs ' + (t == fileEventData.thumbnail ? 'btn-primary' : '')}
|
className={'btn btn-xs ' + (t == fileEventData.thumbnail ? 'btn-primary' : '')}
|
||||||
>{`${i + 1}`}</a>
|
>{`${i + 1}`}</a>
|
||||||
))}
|
))}
|
||||||
@ -95,9 +133,9 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{fileEventData.m?.startsWith('audio/') && fileEventData.thumbnail && (
|
{isAudio && fileEventData.thumbnail && (
|
||||||
<div className="w-2/6">
|
<div className="w-2/6">
|
||||||
<img src={fileEventData.thumbnail} className="w-full" />
|
<img src={fileEventData.thumbnail || selectedThumbnail} className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -111,31 +149,44 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid gap-4 w-4/6" style={{ gridTemplateColumns: '1fr 30em' }}>
|
<div className="grid gap-4 w-4/6" style={{ gridTemplateColumns: '1fr 30em' }}>
|
||||||
{fileEventData.title && (
|
{(isAudio || isVideo) && (
|
||||||
<>
|
<>
|
||||||
<span className="font-bold">Title</span>
|
<span className="font-bold">Title</span>
|
||||||
<span>{fileEventData.title}</span>
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-primary"
|
||||||
|
value={fileEventData.title}
|
||||||
|
onChange={e => setFileEventData(ed => ({ ...ed, title: e.target.value }))}
|
||||||
|
></input>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{fileEventData.artist && (
|
{isAudio && (
|
||||||
<>
|
<>
|
||||||
<span className="font-bold">Artist</span>
|
<span className="font-bold">Artist</span>
|
||||||
<span>{fileEventData.artist}</span>
|
<span>{fileEventData.artist}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{fileEventData.album && (
|
{isAudio && (
|
||||||
<>
|
<>
|
||||||
<span className="font-bold">Album</span>
|
<span className="font-bold">Album</span>
|
||||||
<span>{fileEventData.album}</span>
|
<span>{fileEventData.album}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{fileEventData.year && (
|
{isAudio && (
|
||||||
<>
|
<>
|
||||||
<span className="font-bold">Year</span>
|
<span className="font-bold">Year</span>
|
||||||
<span>{fileEventData.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>
|
||||||
|
|
||||||
<span className="font-bold">Type</span>
|
<span className="font-bold">Type</span>
|
||||||
<span>{fileEventData.m}</span>
|
<span>{fileEventData.m}</span>
|
||||||
|
|
||||||
@ -148,13 +199,6 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
|
|
||||||
<span className="font-bold">File size</span>
|
<span className="font-bold">File size</span>
|
||||||
<span>{fileEventData.size ? formatFileSize(fileEventData.size) : 'unknown'}</span>
|
<span>{fileEventData.size ? formatFileSize(fileEventData.size) : 'unknown'}</span>
|
||||||
<span className="font-bold">Content / Description</span>
|
|
||||||
<textarea
|
|
||||||
value={fileEventData.content}
|
|
||||||
onChange={e => setFileEventData(ed => ({ ...ed, content: e.target.value }))}
|
|
||||||
className="textarea"
|
|
||||||
placeholder="Caption"
|
|
||||||
></textarea>
|
|
||||||
<span className="font-bold">URL</span>
|
<span className="font-bold">URL</span>
|
||||||
<div className="">
|
<div className="">
|
||||||
{fileEventData.url.map((text, i) => (
|
{fileEventData.url.map((text, i) => (
|
||||||
@ -172,7 +216,12 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={async () => setJsonOutput(await publishFileEvent(fileEventData))}
|
onClick={async () => {
|
||||||
|
if (!data.thumbnail) {
|
||||||
|
await publishSelectedThumbnailToOwnServer();
|
||||||
|
}
|
||||||
|
setJsonOutput(await publishFileEvent(fileEventData));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Create File Event
|
Create File Event
|
||||||
</button>
|
</button>
|
||||||
@ -184,7 +233,12 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={async () => setJsonOutput(await publishVideoEvent(fileEventData))}
|
onClick={async () => {
|
||||||
|
if (!data.thumbnail) {
|
||||||
|
await publishSelectedThumbnailToOwnServer();
|
||||||
|
}
|
||||||
|
setJsonOutput(await publishVideoEvent(fileEventData));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Create Video Event
|
Create Video Event
|
||||||
</button>
|
</button>
|
||||||
|
@ -3,26 +3,24 @@ import dayjs from 'dayjs';
|
|||||||
import { FileEventData } from './FileEventEditor';
|
import { FileEventData } from './FileEventEditor';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
import { useNDK } from '../../utils/ndk';
|
import { useNDK } from '../../utils/ndk';
|
||||||
|
import { KIND_AUDIO, KIND_FILE_META, KIND_VIDEO_HORIZONTAL, KIND_VIDEO_VERTICAL } from '../../utils/useFileMetaEvents';
|
||||||
|
|
||||||
export const usePublishing = () => {
|
export const usePublishing = () => {
|
||||||
const { ndk, user } = useNDK();
|
const { ndk, user } = useNDK();
|
||||||
|
|
||||||
const publishFileEvent = async (data: FileEventData): Promise<string> => {
|
const publishFileEvent = async (data: FileEventData): Promise<string> => {
|
||||||
// TODO REupload selected video thumbnail from DVM
|
// TODO where to put video title?
|
||||||
|
|
||||||
const e: NostrEvent = {
|
const e: NostrEvent = {
|
||||||
created_at: dayjs().unix(),
|
created_at: dayjs().unix(),
|
||||||
content: data.content,
|
content: data.content,
|
||||||
tags: [
|
tags: [...uniq(data.url).map(du => ['url', du]), ['x', data.x], ['summary', data.content]],
|
||||||
...uniq(data.url).map(du => ['url', du]),
|
kind: KIND_FILE_META,
|
||||||
['x', data.x],
|
|
||||||
//['summary', data.summary],
|
|
||||||
//['alt', data.alt],
|
|
||||||
],
|
|
||||||
kind: 1063,
|
|
||||||
pubkey: user?.pubkey || '',
|
pubkey: user?.pubkey || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (data.title) {
|
||||||
|
e.tags.push(['alt', `${data.title}`]);
|
||||||
|
}
|
||||||
if (data.size) {
|
if (data.size) {
|
||||||
e.tags.push(['size', `${data.size}`]);
|
e.tags.push(['size', `${data.size}`]);
|
||||||
}
|
}
|
||||||
@ -32,8 +30,10 @@ export const usePublishing = () => {
|
|||||||
if (data.m) {
|
if (data.m) {
|
||||||
e.tags.push(['m', data.m]);
|
e.tags.push(['m', data.m]);
|
||||||
}
|
}
|
||||||
|
if (data.blurHash) {
|
||||||
|
e.tags.push(['blurhash', data.blurHash]);
|
||||||
|
}
|
||||||
if (data.thumbnail) {
|
if (data.thumbnail) {
|
||||||
// TODO upload thumbnail to own storage
|
|
||||||
e.tags.push(['thumb', data.thumbnail]);
|
e.tags.push(['thumb', data.thumbnail]);
|
||||||
e.tags.push(['image', data.thumbnail]);
|
e.tags.push(['image', data.thumbnail]);
|
||||||
}
|
}
|
||||||
@ -55,7 +55,7 @@ export const usePublishing = () => {
|
|||||||
['x', data.x],
|
['x', data.x],
|
||||||
...uniq(data.url).map(du => ['imeta', `url ${du}`, `m ${data.m}`]),
|
...uniq(data.url).map(du => ['imeta', `url ${du}`, `m ${data.m}`]),
|
||||||
],
|
],
|
||||||
kind: 31337, // TODO vertical video event based on dim?!
|
kind: KIND_AUDIO,
|
||||||
pubkey: user?.pubkey || '',
|
pubkey: user?.pubkey || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -81,6 +81,8 @@ export const usePublishing = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const publishVideoEvent = async (data: FileEventData): Promise<string> => {
|
const publishVideoEvent = async (data: FileEventData): Promise<string> => {
|
||||||
|
const videoIsHorizontal = data.width == undefined || data.height == undefined || data.width > data.height;
|
||||||
|
|
||||||
const e: NostrEvent = {
|
const e: NostrEvent = {
|
||||||
created_at: dayjs().unix(),
|
created_at: dayjs().unix(),
|
||||||
content: data.content,
|
content: data.content,
|
||||||
@ -88,14 +90,16 @@ export const usePublishing = () => {
|
|||||||
['d', data.x],
|
['d', data.x],
|
||||||
['x', data.x],
|
['x', data.x],
|
||||||
['url', data.url[0]],
|
['url', data.url[0]],
|
||||||
['title', data.content],
|
['summary', data.content],
|
||||||
// ['summary', data.], TODO add summary
|
|
||||||
['published_at', `${dayjs().unix()}`],
|
['published_at', `${dayjs().unix()}`],
|
||||||
['client', 'bouquet'],
|
['client', 'bouquet'],
|
||||||
],
|
],
|
||||||
kind: 31337,
|
kind: videoIsHorizontal ? KIND_VIDEO_HORIZONTAL : KIND_VIDEO_VERTICAL,
|
||||||
pubkey: user?.pubkey || '',
|
pubkey: user?.pubkey || '',
|
||||||
};
|
};
|
||||||
|
if (data.title) {
|
||||||
|
e.tags.push(['title', data.title]);
|
||||||
|
}
|
||||||
if (data.size) {
|
if (data.size) {
|
||||||
e.tags.push(['size', `${data.size}`]);
|
e.tags.push(['size', `${data.size}`]);
|
||||||
}
|
}
|
||||||
@ -106,7 +110,6 @@ export const usePublishing = () => {
|
|||||||
e.tags.push(['m', data.m]);
|
e.tags.push(['m', data.m]);
|
||||||
}
|
}
|
||||||
if (data.thumbnail) {
|
if (data.thumbnail) {
|
||||||
// TODO upload to own blossom instance
|
|
||||||
e.tags.push(['thumb', data.thumbnail]);
|
e.tags.push(['thumb', data.thumbnail]);
|
||||||
e.tags.push(['preview', data.thumbnail]);
|
e.tags.push(['preview', data.thumbnail]);
|
||||||
}
|
}
|
||||||
|
@ -8,15 +8,15 @@ import {
|
|||||||
import { ServerList } from '../components/ServerList/ServerList';
|
import { ServerList } from '../components/ServerList/ServerList';
|
||||||
import { useServerInfo } from '../utils/useServerInfo';
|
import { useServerInfo } from '../utils/useServerInfo';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { BlobDescriptor, BlossomClient, SignedEvent } from 'blossom-client-sdk';
|
import { BlobDescriptor } from 'blossom-client-sdk';
|
||||||
import { useNDK } from '../utils/ndk';
|
import { useNDK } from '../utils/ndk';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { formatFileSize } from '../utils/utils';
|
import { formatFileSize } from '../utils/utils';
|
||||||
import BlobList from '../components/BlobList/BlobList';
|
import BlobList from '../components/BlobList/BlobList';
|
||||||
import './Transfer.css';
|
import './Transfer.css';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import axios, { AxiosProgressEvent } from 'axios';
|
|
||||||
import ProgressBar from '../components/ProgressBar/ProgressBar';
|
import ProgressBar from '../components/ProgressBar/ProgressBar';
|
||||||
|
import { downloadBlob, uploadBlob } from '../utils/transfer';
|
||||||
|
|
||||||
type TransferStatus = {
|
type TransferStatus = {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
@ -31,6 +31,9 @@ type TransferStatus = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const Transfer = () => {
|
export const Transfer = () => {
|
||||||
|
// TODO add transfer for single files
|
||||||
|
// TODO add support for mirror command (fallback to upload)
|
||||||
|
|
||||||
const { source } = useParams();
|
const { source } = useParams();
|
||||||
const [transferSource, setTransferSource] = useState(source);
|
const [transferSource, setTransferSource] = useState(source);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -60,38 +63,6 @@ export const Transfer = () => {
|
|||||||
return [];
|
return [];
|
||||||
}, [serverInfo, transferSource, transferTarget]);
|
}, [serverInfo, transferSource, transferTarget]);
|
||||||
|
|
||||||
const uploadBlob = async (
|
|
||||||
server: string,
|
|
||||||
file: File,
|
|
||||||
auth?: SignedEvent,
|
|
||||||
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
|
||||||
) => {
|
|
||||||
const headers = {
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': file.type,
|
|
||||||
};
|
|
||||||
|
|
||||||
const res = await axios.put<BlobDescriptor>(`${server}/upload`, file, {
|
|
||||||
headers: auth ? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(auth) } : headers,
|
|
||||||
onUploadProgress,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadBlob = async (
|
|
||||||
server: string,
|
|
||||||
sha256: string,
|
|
||||||
onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
|
|
||||||
) => {
|
|
||||||
const response = await axios.get(`${server}/${sha256}`, {
|
|
||||||
responseType: 'blob',
|
|
||||||
onDownloadProgress,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const performTransfer = async (sourceServer: string, targetServer: string, blobs: BlobDescriptor[]) => {
|
const performTransfer = async (sourceServer: string, targetServer: string, blobs: BlobDescriptor[]) => {
|
||||||
setTransferLog({});
|
setTransferLog({});
|
||||||
setTransferCancelled(false);
|
setTransferCancelled(false);
|
||||||
@ -111,7 +82,7 @@ export const Transfer = () => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const data = await downloadBlob(serverInfo[sourceServer].url, b.sha256, progressEvent => {
|
const result = await downloadBlob(`${serverInfo[sourceServer].url}/${b.sha256}`, progressEvent => {
|
||||||
setTransferLog(ts => ({
|
setTransferLog(ts => ({
|
||||||
...ts,
|
...ts,
|
||||||
[b.sha256]: {
|
[b.sha256]: {
|
||||||
@ -136,12 +107,11 @@ export const Transfer = () => {
|
|||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!data) continue;
|
if (!result) continue;
|
||||||
|
|
||||||
const file = new File([data], b.sha256, { type: b.type, lastModified: b.created });
|
const file = new File([result.data], b.sha256, { type: b.type, lastModified: b.created });
|
||||||
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
|
|
||||||
|
|
||||||
await uploadBlob(serverInfo[targetServer].url, file, uploadAuth, progressEvent => {
|
await uploadBlob(serverInfo[targetServer].url, file, signEventTemplate, progressEvent => {
|
||||||
setTransferLog(ts => ({
|
setTransferLog(ts => ({
|
||||||
...ts,
|
...ts,
|
||||||
[b.sha256]: {
|
[b.sha256]: {
|
||||||
|
@ -13,6 +13,8 @@ import FileEventEditor, { FileEventData } from '../components/FileEventEditor/Fi
|
|||||||
import pLimit from 'p-limit';
|
import pLimit from 'p-limit';
|
||||||
import { Server, useUserServers } from '../utils/useUserServers';
|
import { Server, useUserServers } from '../utils/useUserServers';
|
||||||
import { resizeImage } from '../utils/resize';
|
import { resizeImage } from '../utils/resize';
|
||||||
|
import { getImageSize } from '../utils/image';
|
||||||
|
import { getBlurhashFromFile } from '../utils/blur';
|
||||||
|
|
||||||
type TransferStats = {
|
type TransferStats = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -79,25 +81,6 @@ function Upload() {
|
|||||||
// const [resizeImages, setResizeImages] = useState(false);
|
// const [resizeImages, setResizeImages] = useState(false);
|
||||||
// const [publishToNostr, setPublishToNostr] = useState(false);
|
// const [publishToNostr, setPublishToNostr] = useState(false);
|
||||||
|
|
||||||
type ImageSize = {
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getImageSize = async (imageFile: File): Promise<ImageSize> => {
|
|
||||||
const img = new Image();
|
|
||||||
const objectUrl = URL.createObjectURL(imageFile);
|
|
||||||
const promise = new Promise<ImageSize>((resolve, reject) => {
|
|
||||||
img.onload = () => {
|
|
||||||
resolve({ width: img.width, height: img.height });
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
img.onerror = () => reject();
|
|
||||||
});
|
|
||||||
img.src = objectUrl;
|
|
||||||
return promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function uploadBlob(
|
async function uploadBlob(
|
||||||
server: string,
|
server: string,
|
||||||
file: File,
|
file: File,
|
||||||
@ -147,11 +130,28 @@ function Upload() {
|
|||||||
} as FileEventData;
|
} as FileEventData;
|
||||||
if (file.type.startsWith('image/')) {
|
if (file.type.startsWith('image/')) {
|
||||||
const dimensions = await getImageSize(file);
|
const dimensions = await getImageSize(file);
|
||||||
data = { ...data, dim: `${dimensions.width}x${dimensions.height}` };
|
data = {
|
||||||
|
...data,
|
||||||
|
width: dimensions.width,
|
||||||
|
height: dimensions.height,
|
||||||
|
dim: `${dimensions.width}x${dimensions.height}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// TODO maybe combine fileSize and Hash!
|
||||||
|
const blur = await getBlurhashFromFile(file);
|
||||||
|
if (blur) {
|
||||||
|
data = {
|
||||||
|
...data,
|
||||||
|
blurHash: blur,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fileDimensions[file.name] = data;
|
fileDimensions[file.name] = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO icon to cancel upload
|
||||||
|
// TODO detect if the file already exists? if we have the hash??
|
||||||
|
|
||||||
const startTransfer = async (server: Server, primary: boolean) => {
|
const startTransfer = async (server: Server, primary: boolean) => {
|
||||||
const serverUrl = serverInfo[server.name].url;
|
const serverUrl = serverInfo[server.name].url;
|
||||||
let serverTransferred = 0;
|
let serverTransferred = 0;
|
||||||
|
50
src/utils/blur.ts
Normal file
50
src/utils/blur.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { encode } from 'blurhash';
|
||||||
|
|
||||||
|
const loadImage = async (src: string): Promise<HTMLImageElement> =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = (...args) => reject(args);
|
||||||
|
img.src = src;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getImageData = (image: HTMLImageElement) => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = image.width;
|
||||||
|
canvas.height = image.height;
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (context) {
|
||||||
|
context.drawImage(image, 0, 0);
|
||||||
|
return context.getImageData(0, 0, image.width, image.height);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFileDataURL(file: File): Promise<string | ArrayBuffer | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
|
||||||
|
reader.onload = function (event) {
|
||||||
|
if (event.target) {
|
||||||
|
const dataURL = event.target.result;
|
||||||
|
resolve(dataURL);
|
||||||
|
}
|
||||||
|
reject();
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = function (error) {
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlurhashFromFile(file: File) {
|
||||||
|
const imageUrl = await getFileDataURL(file);
|
||||||
|
if (imageUrl) {
|
||||||
|
const image = await loadImage(imageUrl?.toString());
|
||||||
|
const imageData = getImageData(image);
|
||||||
|
if (imageData) {
|
||||||
|
return encode(imageData.data, imageData.width, imageData.height, 4, 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
18
src/utils/image.ts
Normal file
18
src/utils/image.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
type ImageSize = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getImageSize = async (imageFile: File): Promise<ImageSize> => {
|
||||||
|
const img = new Image();
|
||||||
|
const objectUrl = URL.createObjectURL(imageFile);
|
||||||
|
const promise = new Promise<ImageSize>((resolve, reject) => {
|
||||||
|
img.onload = () => {
|
||||||
|
resolve({ width: img.width, height: img.height });
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
};
|
||||||
|
img.onerror = () => reject();
|
||||||
|
});
|
||||||
|
img.src = objectUrl;
|
||||||
|
return promise;
|
||||||
|
};
|
48
src/utils/transfer.ts
Normal file
48
src/utils/transfer.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import axios, { AxiosProgressEvent } from 'axios';
|
||||||
|
import { BlobDescriptor, BlossomClient, EventTemplate, SignedEvent } from 'blossom-client-sdk';
|
||||||
|
|
||||||
|
export const uploadBlob = async (
|
||||||
|
server: string,
|
||||||
|
file: File,
|
||||||
|
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
|
||||||
|
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
||||||
|
) => {
|
||||||
|
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': file.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await axios.put<BlobDescriptor>(`${server}/upload`, file, {
|
||||||
|
headers: uploadAuth ? { ...headers, authorization: BlossomClient.encodeAuthorizationHeader(uploadAuth) } : headers,
|
||||||
|
onUploadProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const downloadBlob = async (url: string, onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void) => {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: 'blob',
|
||||||
|
onDownloadProgress,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { data: response.data, type: response.headers['Content-Type']?.toString() };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const transferBlob = async (
|
||||||
|
sourceUrl: string,
|
||||||
|
targetServer: string,
|
||||||
|
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>,
|
||||||
|
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void
|
||||||
|
): Promise<BlobDescriptor> => {
|
||||||
|
console.log({ sourceUrl, targetServer });
|
||||||
|
const result = await downloadBlob(sourceUrl, onUploadProgress);
|
||||||
|
|
||||||
|
const fileName = sourceUrl.replace(/.*\//, '');
|
||||||
|
|
||||||
|
const file = new File([result.data], fileName, { type: result.type, lastModified: new Date().getTime() });
|
||||||
|
|
||||||
|
return await uploadBlob(targetServer, file, signEventTemplate, onUploadProgress);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user