feat: Improved upload and image post editor

This commit is contained in:
florian 2024-04-17 09:34:49 +02:00
parent 5b399482ed
commit caf95a2eeb
12 changed files with 408 additions and 159 deletions

View File

@ -1,30 +1,48 @@
# React + TypeScript + Vite # Bouquet
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. A tool to manage your content on blossom severs (Upload, Distribution, Posting)
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration ## TODO / Ideas
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: Add Blurhash
https://github.com/verbiricha/filestr/blob/master/src/blur.tsx
- Configure the top-level `parserOptions` property like this: Upload
```js Audio
export default { Audio Event 31337, maybe Podcast 31338
// other rules... Album Event 30029
parserOptions: { Read ID3 INfo
ecmaVersion: 'latest', Display in Metadata Editor
sourceType: 'module', Post Audio Events
project: ['./tsconfig.json', './tsconfig.node.json'], Option to select "Full Album" and POst Album (Playlist) Event
tsconfigRootDir: __dirname, Upload of Album Art from Disc
}, Usage of Album Art from ID3
} --> Album art as new blob
```
Video
FileMeta Data Event 1063
Video Preview
PDF
FileMeta Data Event 1063
Images
FileMeta Data Event 1063
dimensions
blurhash
Nav
Add AUdio Player like Soundcloud
Blob List
- Selection -> Delete Selected
Audio List
Audio List, mit Mini Thumnnail (Artitst / Title )
Join von Album/year aus Album (Playlist) Event ????
Display blob as "published" when in Audio Event, else as "unlisted"
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

BIN
bun.lockb

Binary file not shown.

107
package-lock.json generated
View File

@ -20,7 +20,9 @@
"blossom-client-sdk": "^0.4.0", "blossom-client-sdk": "^0.4.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"id3js": "^2.1.1", "id3js": "^2.1.1",
"lodash": "^4.17.21",
"nostr-tools": "^2.4.0", "nostr-tools": "^2.4.0",
"p-limit": "^5.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-pdf": "^7.7.1", "react-pdf": "^7.7.1",
@ -28,6 +30,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tanstack/eslint-plugin-query": "^5.28.6", "@tanstack/eslint-plugin-query": "^5.28.6",
"@types/lodash": "^4.17.0",
"@types/react": "^18.2.74", "@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24", "@types/react-dom": "^18.2.24",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
@ -1667,6 +1670,12 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true "dev": true
}, },
"node_modules/@types/lodash": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==",
"dev": true
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.12", "version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
@ -3787,6 +3796,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -4246,6 +4260,35 @@
} }
}, },
"node_modules/p-limit": { "node_modules/p-limit": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
"integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
"dependencies": {
"yocto-queue": "^1.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
"dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate/node_modules/p-limit": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
@ -4260,14 +4303,11 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-locate": { "node_modules/p-locate/node_modules/yocto-queue": {
"version": "5.0.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true, "dev": true,
"dependencies": {
"p-limit": "^3.0.2"
},
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -5847,12 +5887,11 @@
} }
}, },
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==",
"dev": true,
"engines": { "engines": {
"node": ">=10" "node": ">=12.20"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@ -6809,6 +6848,12 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"dev": true "dev": true
}, },
"@types/lodash": {
"version": "4.17.0",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==",
"dev": true
},
"@types/prop-types": { "@types/prop-types": {
"version": "15.7.12", "version": "15.7.12",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
@ -8340,6 +8385,11 @@
"p-locate": "^5.0.0" "p-locate": "^5.0.0"
} }
}, },
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.merge": { "lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -8649,12 +8699,11 @@
} }
}, },
"p-limit": { "p-limit": {
"version": "3.1.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
"dev": true,
"requires": { "requires": {
"yocto-queue": "^0.1.0" "yocto-queue": "^1.0.0"
} }
}, },
"p-locate": { "p-locate": {
@ -8664,6 +8713,23 @@
"dev": true, "dev": true,
"requires": { "requires": {
"p-limit": "^3.0.2" "p-limit": "^3.0.2"
},
"dependencies": {
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"requires": {
"yocto-queue": "^0.1.0"
}
},
"yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true
}
} }
}, },
"parent-module": { "parent-module": {
@ -9711,10 +9777,9 @@
"dev": true "dev": true
}, },
"yocto-queue": { "yocto-queue": {
"version": "0.1.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g=="
"dev": true
} }
} }
} }

View File

@ -24,7 +24,9 @@
"blossom-client-sdk": "^0.4.0", "blossom-client-sdk": "^0.4.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"id3js": "^2.1.1", "id3js": "^2.1.1",
"lodash": "^4.17.21",
"nostr-tools": "^2.4.0", "nostr-tools": "^2.4.0",
"p-limit": "^5.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-pdf": "^7.7.1", "react-pdf": "^7.7.1",
@ -32,6 +34,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tanstack/eslint-plugin-query": "^5.28.6", "@tanstack/eslint-plugin-query": "^5.28.6",
"@types/lodash": "^4.17.0",
"@types/react": "^18.2.74", "@types/react": "^18.2.74",
"@types/react-dom": "^18.2.24", "@types/react-dom": "^18.2.24",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",

View File

@ -3,16 +3,7 @@
} }
.blob-list { .blob-list {
@apply bg-base-200 p-4 text-neutral-content rounded-lg; @apply p-4 text-neutral-content rounded-lg;
}
.blob-list .blob {
@apply p-1 hover:bg-base-200 rounded-md grid pr-4;
grid-template-columns: 2em auto /*auto*/ 2em 6em 10em 7em 3em;
}
.blob-list .blob span {
@apply overflow-ellipsis overflow-hidden text-nowrap;
} }
.blog-list-header { .blog-list-header {
@ -23,18 +14,10 @@
@apply flex-grow; @apply flex-grow;
} }
.blog-list-header button {
@apply btn p-2 ml-2 my-2 text-neutral-content rounded-lg;
}
.blog-list-header button.selected {
@apply btn-primary text-primary-content;
}
.blog-list-header svg { .blog-list-header svg {
@apply w-6 opacity-80 hover:opacity-100; @apply w-6 opacity-80 hover:opacity-100;
} }
.blob-list .blob span a.pill { .blob-list .table :where(th, td) {
@apply bg-base-200 p-1 px-2 rounded-2xl text-white; padding: .25em;
} }

View File

@ -17,6 +17,10 @@ import * as id3 from 'id3js';
import { ID3Tag, ID3TagV2 } from 'id3js/lib/id3Tag'; import { ID3Tag, ID3TagV2 } from 'id3js/lib/id3Tag';
import { useQueries } from '@tanstack/react-query'; import { useQueries } from '@tanstack/react-query';
import { useServerInfo } from '../../utils/useServerInfo'; import { useServerInfo } from '../../utils/useServerInfo';
import useFileMetaEventsByHash, { KIND_BLOSSOM_DRIVE, KIND_FILE_META } from '../../utils/useFileMetaEvents';
import { nip19 } from 'nostr-tools';
import { AddressPointer, EventPointer } from 'nostr-tools/nip19';
import { NDKEvent } from '@nostr-dev-kit/ndk';
type ListMode = 'gallery' | 'list' | 'audio' | 'video' | 'docs'; type ListMode = 'gallery' | 'list' | 'audio' | 'video' | 'docs';
@ -31,6 +35,7 @@ type AudioBlob = BlobDescriptor & { id3?: ID3Tag; imageData?: string };
const BlobList = ({ blobs, onDelete, title }: BlobListProps) => { const BlobList = ({ blobs, onDelete, title }: BlobListProps) => {
const [mode, setMode] = useState<ListMode>('list'); const [mode, setMode] = useState<ListMode>('list');
const { distribution } = useServerInfo(); const { distribution } = useServerInfo();
const fileMetaEventsByHash = useFileMetaEventsByHash();
const images = useMemo( const images = useMemo(
() => blobs.filter(b => b.type?.startsWith('image/')).sort((a, b) => (a.created > b.created ? -1 : 1)), // descending () => blobs.filter(b => b.type?.startsWith('image/')).sort((a, b) => (a.created > b.created ? -1 : 1)), // descending
@ -121,6 +126,46 @@ const BlobList = ({ blobs, onDelete, title }: BlobListProps) => {
</div> </div>
); );
const Badge = ({ ev }: { ev: NDKEvent }) => {
if (ev.kind == KIND_FILE_META) {
const nevent = nip19.neventEncode({
kind: ev.kind,
id: ev.id,
author: ev.author.pubkey,
relays: ev.onRelays.map(r => r.url),
} as EventPointer);
return (
<a target="_blank" href={`https://filestr.vercel.app/e/${nevent}`}>
<div className="badge badge-primary mr-2">published</div>
</a>
);
}
if (ev.kind == KIND_BLOSSOM_DRIVE) {
const naddr = nip19.naddrEncode({
kind: ev.kind,
identifier: ev.tagValue('d'),
pubkey: ev.author.pubkey,
relays: ev.onRelays.map(r => r.url),
} as AddressPointer);
return (
<a target="_blank" className="badge badge-primary mr-2" href={`https://blossom.hzrd149.com/#/drive/${naddr}`}>
🌸 drive
</a>
);
}
return <></>;
}
const Badges = ({ blob }: { blob: BlobDescriptor }) => {
const events = fileMetaEventsByHash[blob.sha256];
if (!events) return;
return events.map(ev => <Badge ev={ev}></Badge>)
};
return ( return (
<> <>
<div className={`blog-list-header ${!title ? 'justify-end' : ''}`}> <div className={`blog-list-header ${!title ? 'justify-end' : ''}`}>
@ -293,34 +338,42 @@ const BlobList = ({ blobs, onDelete, title }: BlobListProps) => {
{mode == 'list' && ( {mode == 'list' && (
<div className="blob-list"> <div className="blob-list">
{blobs.map((blob: BlobDescriptor) => ( <table className="table hover">
<div className="blob" key={blob.sha256}> <thead>
<span> <tr>
<DocumentIcon /> <th>Hash</th>
</span> <th>Uses</th>
<span> <th>Size</th>
<a className="link link-primary" href={blob.url} target="_blank"> <th>Type</th>
{blob.sha256} <th>Date</th>
</a> <th>Actions</th>
</span> </tr>
{/* </thead>
<span> <tbody>
<a className="pill">🌸 drive</a> <a className="pill">📝 post</a> {blobs.map((blob: BlobDescriptor) => (
</span> <tr className="hover" key={blob.sha256}>
*/} <td className=" whitespace-nowrap">
<span> <DocumentIcon />
{distribution[blob.sha256].servers.length == 1 ? ( <a className="link link-primary" href={blob.url} target="_blank">
<ExclamationTriangleIcon title="Not distributed to any other server" /> {blob.sha256.slice(0, 15)}
) : ( </a>
'' </td>
)} <td>
</span> <Badges blob={blob} />
<span>{formatFileSize(blob.size)}</span> <span className="text-warning tooltip" data-tip="Not distributed to any other server">
<span>{blob.type && `${blob.type}`}</span> {distribution[blob.sha256].servers.length == 1 && <ExclamationTriangleIcon />}
<span>{formatDate(blob.created)}</span> </span>
<Actions blob={blob}></Actions> </td>
</div> <td>{formatFileSize(blob.size)}</td>
))} <td>{blob.type && `${blob.type}`}</td>
<td>{formatDate(blob.created)}</td>
<td className=" whitespace-nowrap">
<Actions blob={blob}></Actions>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}
</> </>

View File

@ -2,14 +2,16 @@ import { NDKEvent, NostrEvent } from '@nostr-dev-kit/ndk';
import { useNDK } from '../../ndk'; import { useNDK } from '../../ndk';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useState } from 'react'; import { useState } from 'react';
import uniq from 'lodash/uniq';
import { formatFileSize } from '../../utils';
export type FileEventData = { export type FileEventData = {
content: string; content: string;
url: string; url: string[];
dim?: string; dim?: string;
x: string; x: string;
m?: string; m?: string;
size?: string; size: number;
//summary: string; //summary: string;
//alt: string; //alt: string;
}; };
@ -23,8 +25,8 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
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], ['x', data.x],
['url', data.url],
//['summary', data.summary], //['summary', data.summary],
//['alt', data.alt], //['alt', data.alt],
], ],
@ -33,7 +35,7 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
}; };
if (data.size) { if (data.size) {
e.tags.push(['size', data.size]); e.tags.push(['size', `${data.size}`]);
} }
if (data.dim) { if (data.dim) {
e.tags.push(['dim', data.dim]); e.tags.push(['dim', data.dim]);
@ -45,21 +47,45 @@ const FileEventEditor = ({ data }: { data: FileEventData }) => {
const ev = new NDKEvent(ndk, e); const ev = new NDKEvent(ndk, e);
await ev.sign(); await ev.sign();
console.log(ev.rawEvent()); console.log(ev.rawEvent());
await ev.publish(); // await ev.publish();
}; };
return ( return (
<div> <div className=" bg-base-200 rounded-xl p-4 text-neutral-content gap-4 flex flex-row">
<pre>{JSON.stringify(fileEventData, null, 2)}</pre> {fileEventData.m?.startsWith('image/') && (
<img src={`https://images.slidestr.net/insecure/f:webp/rs:fill:300/plain/${fileEventData.url}`}></img> <div className="p-4 bg-base-300">
{fileEventData.dim ? `(${fileEventData.dim})` : ''} <img
<div className="flex flex-col gap-4"> width={200}
height={200}
src={`https://images.slidestr.net/insecure/f:webp/rs:fill:300/plain/${fileEventData.url[0]}`}
></img>
</div>
)}
<div className="grid gap-4" style={{ gridTemplateColumns: '1fr 30em' }}>
<span className="font-bold">Type</span>
<span>{fileEventData.m}</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">Content / Description</span>
<textarea <textarea
value={fileEventData.content} value={fileEventData.content}
onChange={e => setFileEventData(ed => ({ ...ed, content: e.target.value }))} onChange={e => setFileEventData(ed => ({ ...ed, content: e.target.value }))}
className="textarea textarea-secondary" className="textarea"
placeholder="Caption" placeholder="Caption"
></textarea> ></textarea>
<span className="font-bold">URL</span>
<textarea
value={fileEventData.url.join('\n')}
className="textarea"
placeholder="URL"/>
<button className="btn btn-primary" onClick={() => publishFileEvent(fileEventData)}> <button className="btn btn-primary" onClick={() => publishFileEvent(fileEventData)}>
Publish Publish
</button> </button>

View File

@ -3,11 +3,11 @@
} }
.content { .content {
@apply flex flex-col self-center sm:w-10/12 w-full min-h-[80vh]; @apply flex flex-col self-center md:w-10/12 w-full min-h-[80vh] px-4 md:px-0;
} }
.title { .title {
@apply text-neutral-content text-4xl flex flex-row items-center gap-2 p-4 sm:w-10/12 w-full self-center; @apply text-neutral-content text-4xl flex flex-row items-center gap-2 p-4 md:w-10/12 w-full self-center;
} }
.title img { .title img {

View File

@ -1,13 +1,13 @@
const ProgressBar = ({ value, max, description = '' }: { value: number; max: number; description?: string }) => { const ProgressBar = ({ value, max, description = '' }: { value: number; max: number; description?: string }) => {
const percent = Math.floor((value * 100) / max); const percent = Math.floor((value * 100) / max);
const showDescription = percent > 10 && percent < 100; const showDescription = description.length > 0;
return ( return (
<div className="w-full bg-base-200 rounded-lg"> <div className="w-full bg-base-200 rounded-lg">
{max !== undefined && value !== undefined && max > 0 && ( {max !== undefined && value !== undefined && max > 0 && (
<div className="grid items-center gap-4" style={{gridTemplateColumns:'8fr 5em minmax(0, 1fr)'}}> <div className="grid items-center gap-4" style={{ gridTemplateColumns: '8fr 4em 6em' }}>
<progress className="progress w-full accent-primary" value={percent} max="100" /> <progress className="progress w-full accent-primary" value={percent} max="100" />
<span>{percent}%</span> <span>{percent}%</span>
<span>{showDescription ? description : ''}</span> <span className="whitespace-nowrap overflow-ellipsis">{showDescription ? description : ''}</span>
</div> </div>
)} )}
</div> </div>

View File

@ -1,5 +1,5 @@
import { ChangeEvent, DragEvent, useEffect, useMemo, useState } from 'react'; import { ChangeEvent, DragEvent, useEffect, useMemo, useState } from 'react';
import { useServers } from '../utils/useServers'; import { Server, useServers } from '../utils/useServers';
import { BlobDescriptor, BlossomClient, SignedEvent } from 'blossom-client-sdk'; import { BlobDescriptor, BlossomClient, SignedEvent } from 'blossom-client-sdk';
import { useNDK } from '../ndk'; import { useNDK } from '../ndk';
import { useServerInfo } from '../utils/useServerInfo'; import { useServerInfo } from '../utils/useServerInfo';
@ -11,11 +11,13 @@ import CheckBox from '../components/CheckBox/CheckBox';
import ProgressBar from '../components/ProgressBar/ProgressBar'; import ProgressBar from '../components/ProgressBar/ProgressBar';
import { formatFileSize } from '../utils'; import { formatFileSize } from '../utils';
import FileEventEditor, { FileEventData } from '../components/FileEventEditor/FileEventEditor'; import FileEventEditor, { FileEventData } from '../components/FileEventEditor/FileEventEditor';
import pLimit from 'p-limit';
type TransferStats = { type TransferStats = {
enabled: boolean; enabled: boolean;
size: number; size: number;
transferred: number; transferred: number;
rate: number;
}; };
/* /*
@ -41,7 +43,7 @@ function Upload() {
const [transfers, setTransfers] = useState<{ [key: string]: TransferStats }>({}); const [transfers, setTransfers] = useState<{ [key: string]: TransferStats }>({});
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [cleanPrivateData, setCleanPrivateData] = useState(true); const [cleanPrivateData, setCleanPrivateData] = useState(true);
const [transferSpeed, setTransferSpeed] = useState<number | undefined>(); const limit = pLimit(3);
const [fileEventsToPublish, setFileEventsToPublish] = useState<FileEventData[]>([]); const [fileEventsToPublish, setFileEventsToPublish] = useState<FileEventData[]>([]);
@ -98,14 +100,50 @@ function Upload() {
// TODO use https://github.com/davejm/client-compress // TODO use https://github.com/davejm/client-compress
// for image resizing // for image resizing
const fileDimensions: { [key: string]: ImageSize } = {}; const fileDimensions: { [key: string]: FileEventData } = {};
for (const file of filesToUpload) { for (const file of filesToUpload) {
let data = { content: file.name, url: [] as string[] } as FileEventData;
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
const dimensions = await getImageSize(file); const dimensions = await getImageSize(file);
fileDimensions[file.name] = dimensions; data = { ...data, dim: `${dimensions.width}x${dimensions.height}` };
} }
fileDimensions[file.name] = data;
} }
const startTransfer = async (server: Server, primary: boolean) => {
const serverUrl = serverInfo[server.name].url;
let serverTransferred = 0;
for (const file of filesToUpload) {
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
const newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => {
setTransfers(ut => ({
...ut,
[server.name]: {
...ut[server.name],
transferred: serverTransferred + progressEvent.loaded,
rate: progressEvent.rate || 0,
},
}));
});
serverTransferred += file.size;
setTransfers(ut => ({
...ut,
[server.name]: { ...ut[server.name], transferred: serverTransferred, rate: 0 },
}));
fileDimensions[file.name] = {
...fileDimensions[file.name],
x: newBlob.sha256,
url: primary ? [newBlob.url, ...fileDimensions[file.name].url] : [...fileDimensions[file.name].url, newBlob.url],
size: newBlob.size,
m: newBlob.type,
};
}
queryClient.invalidateQueries({ queryKey: ['blobs', server.name] });
};
if (filesToUpload && filesToUpload.length) { if (filesToUpload && filesToUpload.length) {
// sum files sizes // sum files sizes
const totalSize = filesToUpload.reduce((acc, f) => acc + f.size, 0); const totalSize = filesToUpload.reduce((acc, f) => acc + f.size, 0);
@ -121,51 +159,14 @@ function Upload() {
return newTransfers; return newTransfers;
}); });
for (const server of servers) { const enabledServers = servers.filter(s => transfers[s.name]?.enabled);
if (!transfers[server.name]?.enabled) { const primaryServerName = servers[0].name;
continue;
}
const serverUrl = serverInfo[server.name].url;
let serverTransferred = 0;
for (const file of filesToUpload) {
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
const newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => { await Promise.all(enabledServers.map(s => limit(() => startTransfer(s, s.name == primaryServerName))));
setTransferSpeed(progressEvent.rate);
setTransfers(ut => ({
...ut,
[server.name]: { ...ut[server.name], transferred: serverTransferred + progressEvent.loaded },
}));
});
serverTransferred += file.size; setFiles([]);
setTransfers(ut => ({ // TODO reset input control value??
...ut, setFileEventsToPublish(Object.values(fileDimensions));
[server.name]: { ...ut[server.name], transferred: serverTransferred },
}));
console.log(newBlob);
const dim = fileDimensions[file.name];
const fed: FileEventData = {
content: file.name,
x: newBlob.sha256,
url: newBlob.url,
size: `${newBlob.size}`,
};
if (newBlob.type) {
fed.m = newBlob.type;
}
if (dim) {
fed.dim = `${dim.width}x${dim.height}`;
}
setFileEventsToPublish(fetp => [...fetp, fed]);
}
queryClient.invalidateQueries({ queryKey: ['blobs', server.name] });
setFiles([]);
// TODO reset input control value??
}
} }
}; };
@ -176,6 +177,23 @@ function Upload() {
useEffect(() => { useEffect(() => {
clearTransfers(); clearTransfers();
/*
setFileEventsToPublish([
{
content: '_DSF3852.jpg',
dim: '1365x2048',
m: 'image/jpeg',
size: 599988,
url: [
'https://test-store.slidestr.net/d32b7eff53919bc38b59e05b2fe4bda3067c46589eeee743a46649ae71f4b659',
'https://media-server.slidestr.net/d32b7eff53919bc38b59e05b2fe4bda3067c46589eeee743a46649ae71f4b659',
'https://cdn.satellite.earth/d32b7eff53919bc38b59e05b2fe4bda3067c46589eeee743a46649ae71f4b659.jpg',
],
x: 'd32b7eff53919bc38b59e05b2fe4bda3067c46589eeee743a46649ae71f4b659',
},
]);*/
}, [servers]); }, [servers]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
@ -198,7 +216,6 @@ function Upload() {
}; };
const sizeOfFilesToUpload = useMemo(() => files.reduce((acc, file) => (acc += file.size), 0), [files]); const sizeOfFilesToUpload = useMemo(() => files.reduce((acc, file) => (acc += file.size), 0), [files]);
return ( return (
<> <>
<h2 className=" py-4">Upload</h2> <h2 className=" py-4">Upload</h2>
@ -219,14 +236,16 @@ function Upload() {
<CheckBox <CheckBox
name={s.name} name={s.name}
checked={transfers[s.name]?.enabled || false} checked={transfers[s.name]?.enabled || false}
setChecked={c => setTransfers(ut => ({ ...ut, [s.name]: { enabled: c, transferred: 0, size: 0 } }))} setChecked={c =>
setTransfers(ut => ({ ...ut, [s.name]: { enabled: c, transferred: 0, size: 0, rate: 0 } }))
}
label={s.name} label={s.name}
></CheckBox> ></CheckBox>
{transfers[s.name]?.enabled ? ( {transfers[s.name]?.enabled ? (
<ProgressBar <ProgressBar
value={transfers[s.name].transferred} value={transfers[s.name].transferred}
max={transfers[s.name].size} max={transfers[s.name].size}
description={transferSpeed ? '' + formatFileSize(transferSpeed) + '/s' : ''} description={transfers[s.name].rate > 0 ? '' + formatFileSize(transfers[s.name].rate) + '/s' : ''}
/> />
) : ( ) : (
<div></div> <div></div>
@ -276,7 +295,7 @@ function Upload() {
</div> </div>
{fileEventsToPublish.length > 0 && ( {fileEventsToPublish.length > 0 && (
<> <>
<h2>Publish events</h2> <h2 className="py-4">Publish events</h2>
{fileEventsToPublish.map(fe => ( {fileEventsToPublish.map(fe => (
<FileEventEditor data={fe} /> <FileEventEditor data={fe} />
))} ))}

40
src/useEvents.ts Normal file
View File

@ -0,0 +1,40 @@
import { useState, useEffect, useMemo } from 'react';
import { NDKEvent, NDKFilter, NDKRelaySet, NDKSubscriptionOptions } from '@nostr-dev-kit/ndk';
import uniqBy from 'lodash/uniqBy';
import { useNDK } from './ndk';
import { sha256 } from '@noble/hashes/sha256';
export interface SubscriptionOptions extends NDKSubscriptionOptions {
disable?: boolean;
}
export default function useEvents(filter: NDKFilter | NDKFilter[], opts?: SubscriptionOptions, relays?: string[]) {
const { ndk } = useNDK();
const [eose, setEose] = useState(false);
const [events, setEvents] = useState<NDKEvent[]>([]);
const id = useMemo(() => {
return sha256(new TextEncoder().encode(JSON.stringify(filter)));
}, [filter]);
useEffect(() => {
if (filter && !opts?.disable) {
const relaySet = relays?.length ?? 0 > 0 ? NDKRelaySet.fromRelayUrls(relays as string[], ndk) : undefined;
const sub = ndk.subscribe(filter, opts, relaySet);
sub.on('event', (ev: NDKEvent) => {
setEvents(evs => {
const newEvents = evs.concat([ev]).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
return uniqBy(newEvents, (e: NDKEvent) => e.tagId());
});
});
sub.on('eose', () => {
setEose(true);
});
return () => {
sub.stop();
};
}
}, [id, opts?.disable]);
return { id, eose, events };
}

View File

@ -0,0 +1,42 @@
import { useMemo } from 'react';
import useEvents from '../useEvents';
import groupBy from 'lodash/groupBy';
import { NDKFilter } from '@nostr-dev-kit/ndk';
import { useNDK } from '../ndk';
import { mapValues } from 'lodash';
export const KIND_FILE_META = 1063;
export const KIND_BLOSSOM_DRIVE = 30563;
const useFileMetaEventsByHash = () => {
const { user } = useNDK();
const fileMetaFilter = useMemo(
() => ({ kinds: [KIND_FILE_META, KIND_BLOSSOM_DRIVE], authors: [user?.pubkey] }) as NDKFilter,
[user?.pubkey]
);
const fileMetaSub = useEvents(fileMetaFilter);
/*
const fileMetaEventsByHash = useMemo(() => {
const allXTags = fileMetaSub.events.flatMap(ev => ev.tags.filter(t => t[0]=='x').flatMap(t => ({x:t[1], ev})));
console.log(allXTags);
return groupBy(allXTags, item => item.x)
}, [fileMetaSub.events]);
*/
const fileMetaEventsByHash = useMemo(
() => {
const allXTags = fileMetaSub.events.flatMap(ev => ev.tags.filter(t => t[0]=='x').flatMap(t => ({x:t[1], ev})));
const groupedByX= groupBy(allXTags, item => item.x);
return mapValues(groupedByX, v => v.map(e => e.ev));
},
[fileMetaSub]
);
console.log(fileMetaEventsByHash)
return fileMetaEventsByHash;
};
export default useFileMetaEventsByHash;