mirror of
https://git.v0l.io/florian/bouquet.git
synced 2025-03-17 16:43:00 +01:00
chore: Initial commit
This commit is contained in:
commit
3a8ba07903
18
.eslintrc.cjs
Normal file
18
.eslintrc.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
.vercel
|
16
.prettierrc
Normal file
16
.prettierrc
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "avoid",
|
||||
"rangeStart": 0,
|
||||
"rangeEnd": 9007199254740991,
|
||||
"requirePragma": false,
|
||||
"insertPragma": false,
|
||||
"proseWrap": "preserve"
|
||||
}
|
30
README.md
Normal file
30
README.md
Normal file
@ -0,0 +1,30 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
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
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default {
|
||||
// other rules...
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
tsconfigRootDir: __dirname,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- 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
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/bouquet.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bouquet</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
7338
package-lock.json
generated
Normal file
7338
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
package.json
Normal file
46
package.json
Normal file
@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "blob-manager",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier -w src/",
|
||||
"analyze": "vite-bundle-visualizer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.18",
|
||||
"@heroicons/react": "^2.1.3",
|
||||
"@noble/hashes": "^1.4.0",
|
||||
"@nostr-dev-kit/ndk": "^2.5.1",
|
||||
"@nostr-dev-kit/ndk-cache-dexie": "^2.2.8",
|
||||
"@tanstack/react-query": "^5.28.6",
|
||||
"@tanstack/react-query-devtools": "^5.28.6",
|
||||
"blossom-client-sdk": "^0.4.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"nostr-tools": "^2.3.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "^5.28.6",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.0",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.2.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0",
|
||||
"vite-bundle-visualizer": "^1.1.0"
|
||||
}
|
||||
}
|
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
BIN
public/bouquet.png
Normal file
BIN
public/bouquet.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 26 KiB |
7
src/App.css
Normal file
7
src/App.css
Normal file
@ -0,0 +1,7 @@
|
||||
body h2 {
|
||||
@apply text-2xl text-white py-4;
|
||||
}
|
||||
|
||||
body h2 svg {
|
||||
@apply w-8 inline align-text-bottom;
|
||||
}
|
116
src/App.tsx
Normal file
116
src/App.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import './App.css';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
|
||||
import { useNDK } from './ndk';
|
||||
import BlobList from './components/BlobList/BlobList';
|
||||
import { useServerInfo } from './utils/useServerInfo';
|
||||
import { ServerList } from './components/ServerList/ServerList';
|
||||
import { Layout } from './components/Layout/Layout';
|
||||
import { Transfer } from './components/Transfer/Transfer';
|
||||
|
||||
/* BOUQUET Blob Organizer Update Quality Use Enhancement Tool */
|
||||
|
||||
// TODOs
|
||||
/*
|
||||
- multi threaded sync
|
||||
- upload to single/multi servers
|
||||
- upload exif data removal
|
||||
- upload image resize
|
||||
- upload & publish as file event to nostr
|
||||
- thumbnail gallery
|
||||
- check blobs (download & sha256 sum check), maybe limit max size
|
||||
*/
|
||||
function App() {
|
||||
const { loginWithExtension, signEventTemplate } = useNDK();
|
||||
const [selectedServer, setSelectedServer] = useState<string | undefined>();
|
||||
const [transferSource, setTransferSource] = useState<string | undefined>();
|
||||
const serverInfo = useServerInfo();
|
||||
|
||||
useEffect(() => {
|
||||
loginWithExtension();
|
||||
}, []);
|
||||
|
||||
/*,
|
||||
combine: (results) => {
|
||||
const dict: BlobDictionary = {};
|
||||
|
||||
results.forEach((server) =>
|
||||
server.data && server.data.forEach((blob: BlobDescriptor) => {
|
||||
if (dict[blob.sha256]) {
|
||||
dict[blob.sha256].urls.push(blob.url);
|
||||
} else {
|
||||
dict[blob.sha256] = {
|
||||
...blob,
|
||||
urls: [blob.url],
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
data: dict,
|
||||
};
|
||||
},*/
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const deleteBlob = useMutation({
|
||||
mutationFn: async ({ serverUrl, hash }: { serverName: string; serverUrl: string; hash: string }) => {
|
||||
const deleteAuth = await BlossomClient.getDeleteAuth(hash, signEventTemplate, 'Delete Blob');
|
||||
return BlossomClient.deleteBlob(serverUrl, hash, deleteAuth);
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
queryClient.setQueryData(['blobs', variables.serverName], (oldData: BlobDescriptor[]) =>
|
||||
oldData ? oldData.filter(b => b.sha256 !== variables.hash) : oldData
|
||||
);
|
||||
console.log({ key: ['blobs', variables.serverName] });
|
||||
},
|
||||
});
|
||||
|
||||
const selectedServerBlobs = useMemo(
|
||||
() =>
|
||||
selectedServer != undefined
|
||||
? serverInfo[selectedServer].blobs?.sort(
|
||||
(a, b) => (a.created > b.created ? -1 : 1) // descending
|
||||
)
|
||||
: undefined,
|
||||
[serverInfo, selectedServer]
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
{transferSource ? (
|
||||
<Transfer transferSource={transferSource} onCancel={() => setTransferSource(undefined)} />
|
||||
) : (
|
||||
<>
|
||||
<h2>Servers</h2>
|
||||
<ServerList
|
||||
servers={Object.values(serverInfo).sort()}
|
||||
selectedServer={selectedServer}
|
||||
setSelectedServer={setSelectedServer}
|
||||
onTransfer={server => setTransferSource(server)}
|
||||
></ServerList>
|
||||
|
||||
{selectedServer && serverInfo[selectedServer] && selectedServerBlobs && (
|
||||
<>
|
||||
<h2>Your objects on {serverInfo[selectedServer].name}</h2>
|
||||
<BlobList
|
||||
blobs={selectedServerBlobs}
|
||||
onDelete={blob =>
|
||||
deleteBlob.mutate({
|
||||
serverName: serverInfo[selectedServer].name,
|
||||
serverUrl: serverInfo[selectedServer].url,
|
||||
hash: blob.sha256,
|
||||
})
|
||||
}
|
||||
></BlobList>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
24
src/components/BlobList/BlobList.css
Normal file
24
src/components/BlobList/BlobList.css
Normal file
@ -0,0 +1,24 @@
|
||||
.blob-list svg {
|
||||
@apply w-5 inline align-text-bottom mr-1;
|
||||
}
|
||||
|
||||
.blob-list a {
|
||||
@apply text-pink-500 hover:text-white;
|
||||
}
|
||||
|
||||
.blob-list {
|
||||
@apply bg-neutral-800 p-4 text-neutral-300 rounded-lg;
|
||||
}
|
||||
|
||||
.blob-list .blob {
|
||||
@apply p-1 hover:bg-neutral-700 rounded-md grid pr-4;
|
||||
grid-template-columns: 2em auto 6em 10em 7em 1em;
|
||||
}
|
||||
|
||||
.blob-list .blob span {
|
||||
@apply overflow-ellipsis overflow-hidden text-nowrap;
|
||||
}
|
||||
|
||||
.blob-list .blob a {
|
||||
@apply cursor-pointer;
|
||||
}
|
39
src/components/BlobList/BlobList.tsx
Normal file
39
src/components/BlobList/BlobList.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { DocumentIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { BlobDescriptor } from 'blossom-client-sdk';
|
||||
import { formatDate, formatFileSize } from '../../utils';
|
||||
import './BlobList.css';
|
||||
|
||||
type BlobListProps = {
|
||||
blobs: BlobDescriptor[];
|
||||
onDelete?: (blob: BlobDescriptor) => void;
|
||||
};
|
||||
const BlobList = ({ blobs, onDelete }: BlobListProps) => {
|
||||
return (
|
||||
<div className="blob-list">
|
||||
{blobs.map((blob: BlobDescriptor) => (
|
||||
<div className="blob" key={blob.sha256}>
|
||||
<span>
|
||||
<DocumentIcon />
|
||||
</span>
|
||||
<span>
|
||||
<a href={blob.url} target="_blank">
|
||||
{blob.sha256}
|
||||
</a>
|
||||
</span>
|
||||
<span>{formatFileSize(blob.size)}</span>
|
||||
<span>{blob.type && `${blob.type}`}</span>
|
||||
<span>{formatDate(blob.created)}</span>
|
||||
{onDelete && (
|
||||
<span>
|
||||
<a onClick={() => onDelete(blob)}>
|
||||
<TrashIcon />
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlobList;
|
31
src/components/Layout/Layout.css
Normal file
31
src/components/Layout/Layout.css
Normal file
@ -0,0 +1,31 @@
|
||||
.main {
|
||||
@apply flex flex-col justify-center;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply flex flex-col self-center sm:w-4/6 w-full;
|
||||
}
|
||||
|
||||
.title {
|
||||
@apply text-white text-4xl flex flex-row items-center gap-2 p-4 w-4/6 self-center;
|
||||
}
|
||||
|
||||
.title img {
|
||||
@apply w-10;
|
||||
}
|
||||
|
||||
.title span {
|
||||
@apply flex-grow;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@apply flex-shrink;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
@apply w-10 h-10 rounded-full;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@apply self-center text-neutral-600 pt-12 pb-6;
|
||||
}
|
22
src/components/Layout/Layout.tsx
Normal file
22
src/components/Layout/Layout.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { useNDK } from '../../ndk';
|
||||
import './Layout.css';
|
||||
|
||||
export const Layout = ({ children }: { children: React.ReactElement }) => {
|
||||
const { user } = useNDK();
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div className="title">
|
||||
<img src="/bouquet.png" /> <span>bouquet</span>
|
||||
<div className="avatar">
|
||||
<img src={user?.profile?.image} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="content">{children}</div>
|
||||
<div className="footer">
|
||||
made with 💜 by{' '}
|
||||
<a href="https://njump.me/npub1klr0dy2ul2dx9llk58czvpx73rprcmrvd5dc7ck8esg8f8es06qs427gxc">florian</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
51
src/components/ServerList/ServerList.css
Normal file
51
src/components/ServerList/ServerList.css
Normal file
@ -0,0 +1,51 @@
|
||||
.server-list {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.server {
|
||||
@apply bg-neutral-800 text-neutral-300 rounded-lg p-4 gap-4 cursor-pointer hover:bg-neutral-700 flex flex-row items-center;
|
||||
}
|
||||
|
||||
.server.selected {
|
||||
@apply bg-pink-700 cursor-default;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
@apply text-2xl mb-2 text-white;
|
||||
}
|
||||
|
||||
.server-stats {
|
||||
@apply flex flex-row gap-8;
|
||||
}
|
||||
|
||||
.server-stat svg {
|
||||
@apply w-5 inline align-text-bottom;
|
||||
}
|
||||
|
||||
.server-icon {
|
||||
@apply shrink-0;
|
||||
}
|
||||
|
||||
.server-icon svg,
|
||||
.server-actions svg {
|
||||
@apply w-10;
|
||||
}
|
||||
|
||||
.server-actions a {
|
||||
@apply cursor-pointer text-center flex flex-col items-center hover:text-white opacity-80 hover:opacity-100;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
@apply w-6 ml-2 inline align-text-bottom;
|
||||
transform-origin: center;
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
72
src/components/ServerList/ServerList.tsx
Normal file
72
src/components/ServerList/ServerList.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUpOnSquareStackIcon,
|
||||
ClockIcon,
|
||||
CubeIcon,
|
||||
DocumentDuplicateIcon,
|
||||
ServerIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { formatDate, formatFileSize } from '../../utils';
|
||||
import { useServerInfo } from '../../utils/useServerInfo';
|
||||
import './ServerList.css';
|
||||
import { Server } from '../../utils/useServers';
|
||||
|
||||
type ServerListProps = {
|
||||
servers: Server[];
|
||||
selectedServer?: string | undefined;
|
||||
setSelectedServer?: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
onTransfer?: (server: string) => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export const ServerList = ({ servers, selectedServer, setSelectedServer, onTransfer, onCancel }: ServerListProps) => {
|
||||
const serverInfo = useServerInfo();
|
||||
//
|
||||
return (
|
||||
<div className="server-list">
|
||||
{servers.map((server, sx) => (
|
||||
<div
|
||||
className={`server ${selectedServer == server.name ? 'selected' : ''}`}
|
||||
key={sx}
|
||||
onClick={() => setSelectedServer && setSelectedServer(server.name)}
|
||||
>
|
||||
<div className="server-icon">
|
||||
<ServerIcon />
|
||||
</div>
|
||||
<div className="flex flex-col grow">
|
||||
<div className="server-name">
|
||||
{server.name}
|
||||
{serverInfo[server.name].isLoading && <ArrowPathIcon className="loading" />}
|
||||
</div>
|
||||
<div className="server-stats">
|
||||
<div className="server-stat">
|
||||
<DocumentDuplicateIcon /> {serverInfo[server.name].count}
|
||||
</div>
|
||||
<div className="server-stat">
|
||||
<CubeIcon /> {formatFileSize(serverInfo[server.name].size)}
|
||||
</div>
|
||||
<div className="server-stat">
|
||||
<ClockIcon /> {formatDate(serverInfo[server.name].lastChange)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{((selectedServer == server.name && onTransfer) || onCancel) && (
|
||||
<div className="server-actions">
|
||||
{selectedServer == server.name && onTransfer && (
|
||||
<a onClick={() => onTransfer(server.name)}>
|
||||
<ArrowUpOnSquareStackIcon /> Transfer
|
||||
</a>
|
||||
)}
|
||||
{onCancel && (
|
||||
<a onClick={() => onCancel()}>
|
||||
<XMarkIcon />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
54
src/components/Transfer/Transfer.css
Normal file
54
src/components/Transfer/Transfer.css
Normal file
@ -0,0 +1,54 @@
|
||||
.message {
|
||||
@apply text-3xl text-center text-white p-12;
|
||||
}
|
||||
|
||||
.message svg {
|
||||
@apply w-8 inline;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
@apply bg-white text-lg text-black hover:bg-pink-600 hover:text-white inline-block rounded-lg p-2 pr-4 pl-3;
|
||||
}
|
||||
|
||||
.action-button svg {
|
||||
@apply w-5 inline align-text-bottom;
|
||||
}
|
||||
|
||||
.error-log {
|
||||
@apply bg-neutral-800 p-4 text-neutral-300 rounded-lg;
|
||||
}
|
||||
|
||||
.error-log svg {
|
||||
@apply w-5 inline align-text-bottom mr-1;
|
||||
}
|
||||
|
||||
.error-log div {
|
||||
@apply p-1 hover:bg-neutral-700 rounded-md grid pr-4;
|
||||
grid-template-columns: 2em auto 6em 10em 7em 1em;
|
||||
}
|
||||
.error-log div span {
|
||||
@apply overflow-ellipsis overflow-hidden text-nowrap;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.blob-list svg {
|
||||
@apply w-5 inline align-text-bottom mr-1;
|
||||
}
|
||||
|
||||
.blob-list a {
|
||||
@apply text-pink-500 hover:text-white;
|
||||
}
|
||||
|
||||
.blob-list {
|
||||
}
|
||||
|
||||
.blob-list .blob {
|
||||
|
||||
}
|
||||
|
||||
|
||||
.blob-list .blob a {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
171
src/components/Transfer/Transfer.tsx
Normal file
171
src/components/Transfer/Transfer.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { ArrowDownOnSquareIcon, ArrowUpOnSquareIcon, CheckBadgeIcon, DocumentIcon } from '@heroicons/react/24/outline';
|
||||
import { ServerList } from '../ServerList/ServerList';
|
||||
import { useServerInfo } from '../../utils/useServerInfo';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
|
||||
import { useNDK } from '../../ndk';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { formatFileSize } from '../../utils';
|
||||
import BlobList from '../BlobList/BlobList';
|
||||
import './Transfer.css';
|
||||
|
||||
type TransferStatus = {
|
||||
[key: string]: {
|
||||
sha256: string;
|
||||
status: 'pending' | 'done' | 'error';
|
||||
message?: string;
|
||||
size: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const Transfer = ({ transferSource, onCancel }: { transferSource: string; onCancel?: () => void }) => {
|
||||
const serverInfo = useServerInfo();
|
||||
const [transferTarget, setTransferTarget] = useState<string | undefined>();
|
||||
const { signEventTemplate } = useNDK();
|
||||
const queryClient = useQueryClient();
|
||||
const [started, setStarted] = useState(false);
|
||||
|
||||
const [transferLog, setTransferLog] = useState<TransferStatus>({});
|
||||
|
||||
const closeTransferMode = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['blobs', transferTarget] });
|
||||
setTransferTarget(undefined);
|
||||
setTransferLog({});
|
||||
setStarted(false);
|
||||
onCancel && onCancel();
|
||||
};
|
||||
|
||||
const transferJobs = useMemo(() => {
|
||||
if (transferSource && transferTarget) {
|
||||
const sourceBlobs = serverInfo[transferSource].blobs;
|
||||
const targetBlobs = serverInfo[transferTarget].blobs;
|
||||
return sourceBlobs?.filter(src => targetBlobs?.find(tgt => tgt.sha256 == src.sha256) == undefined);
|
||||
}
|
||||
return [];
|
||||
}, [serverInfo, transferSource, transferTarget]);
|
||||
// https://github.com/sindresorhus/p-limit
|
||||
//
|
||||
const performTransfer = async (sourceServer: string, targetServer: string, blobs: BlobDescriptor[]) => {
|
||||
setTransferLog({});
|
||||
setStarted(true);
|
||||
for (const b of blobs) {
|
||||
try {
|
||||
// BlossomClient.getGetAuth()
|
||||
setTransferLog(ts => ({ ...ts, [b.sha256]: { sha256: b.sha256, status: 'pending', size: b.size } }));
|
||||
|
||||
const data = await BlossomClient.getBlob(serverInfo[sourceServer].url, b.sha256);
|
||||
const file = new File([data], b.sha256, { type: b.type, lastModified: b.created });
|
||||
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
|
||||
await BlossomClient.uploadBlob(serverInfo[targetServer].url, file, uploadAuth);
|
||||
setTransferLog(ts => ({ ...ts, [b.sha256]: { sha256: b.sha256, status: 'done', size: b.size } }));
|
||||
} catch (e) {
|
||||
setTransferLog(ts => ({
|
||||
...ts,
|
||||
[b.sha256]: { sha256: b.sha256, status: 'error', message: (e as Error).message, size: blobs.length },
|
||||
}));
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
// if (Object.values(transferLog).filter(b => b.status == 'error').length == 0) {
|
||||
// closeTransferMode();
|
||||
// }
|
||||
};
|
||||
|
||||
const transferStatus = useMemo(() => {
|
||||
const stats = Object.values(transferLog).reduce(
|
||||
(acc, t) => {
|
||||
if (t.status === 'done') {
|
||||
acc.done += 1;
|
||||
} else if (t.status === 'error') {
|
||||
acc.error += 1;
|
||||
} else {
|
||||
acc.pending += 1;
|
||||
}
|
||||
acc.size += t.size;
|
||||
return acc;
|
||||
},
|
||||
{ pending: 0, done: 0, error: 0, size: 0 }
|
||||
);
|
||||
return { ...stats, fullSize: transferJobs?.reduce((acc, b) => acc + b.size, 0) || 0 };
|
||||
}, [transferLog, transferJobs]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>
|
||||
<ArrowUpOnSquareIcon /> Transfer Source
|
||||
</h2>
|
||||
<ServerList
|
||||
servers={Object.values(serverInfo).filter(s => s.name == transferSource)}
|
||||
onCancel={() => closeTransferMode()}
|
||||
></ServerList>
|
||||
<h2>
|
||||
<ArrowDownOnSquareIcon /> Transfer Target
|
||||
</h2>
|
||||
<ServerList
|
||||
servers={Object.values(serverInfo)
|
||||
.filter(s => s.name != transferSource)
|
||||
.sort()}
|
||||
selectedServer={transferTarget}
|
||||
setSelectedServer={setTransferTarget}
|
||||
></ServerList>
|
||||
{transferTarget && transferJobs && transferJobs.length > 0 ? (
|
||||
<>
|
||||
<div className="message">
|
||||
{transferJobs.length} object{transferJobs.length > 1 ? 's' : ''} to transfer{' '}
|
||||
{!started && (
|
||||
<button
|
||||
className="action-button"
|
||||
onClick={() => performTransfer(transferSource, transferTarget, transferJobs)}
|
||||
>
|
||||
<ArrowUpOnSquareIcon />
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="w-full bg-gray-200 rounded-lg dark:bg-neutral-800">
|
||||
<div
|
||||
className="bg-pink-600 text-sm font-medium text-pink-100 text-center p-1 leading-none rounded-lg"
|
||||
style={{ width: `${Math.floor((transferStatus.size * 100) / transferStatus.fullSize)}%` }}
|
||||
>
|
||||
{Math.floor((transferStatus.size * 100) / transferStatus.fullSize)} %
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
<div className="message">
|
||||
{formatFileSize(transferStatus.size)} / {formatFileSize(transferStatus.fullSize)} transfered.
|
||||
</div>
|
||||
}
|
||||
<div className="error-log">
|
||||
{Object.values(transferLog)
|
||||
.filter(b => b.status == 'error')
|
||||
.map(t => (
|
||||
<div>
|
||||
<span>
|
||||
<DocumentIcon />
|
||||
</span>
|
||||
<span>{t.sha256}</span>
|
||||
<span>{formatFileSize(t.size)}</span>
|
||||
<span>{t.status && `${t.status}`}</span>
|
||||
<span>{t.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!started && <BlobList blobs={transferJobs}></BlobList>}
|
||||
</>
|
||||
) : (
|
||||
<div className="message">
|
||||
{transferTarget ? (
|
||||
<>
|
||||
<CheckBadgeIcon /> no missing objects to transfer
|
||||
</>
|
||||
) : (
|
||||
<>choose a transfer target above</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
47
src/exif.ts
Normal file
47
src/exif.ts
Normal file
@ -0,0 +1,47 @@
|
||||
/* Source: https://stackoverflow.com/a/77472484/47324 */
|
||||
|
||||
const cleanBuffer = (arrayBuffer: ArrayBuffer) => {
|
||||
let dataView = new DataView(arrayBuffer);
|
||||
const exifMarker = 0xffe1;
|
||||
let offset = 2; // Skip the first two bytes (0xFFD8)
|
||||
|
||||
while (offset < dataView.byteLength) {
|
||||
if (dataView.getUint16(offset) === exifMarker) {
|
||||
// Found an EXIF marker
|
||||
const segmentLength = dataView.getUint16(offset + 2, false) + 2;
|
||||
|
||||
// Update the arrayBuffer and dataView
|
||||
arrayBuffer = removeSegment(arrayBuffer, offset, segmentLength);
|
||||
dataView = new DataView(arrayBuffer);
|
||||
} else {
|
||||
// Move to the next marker
|
||||
offset += 2 + dataView.getUint16(offset + 2, false);
|
||||
}
|
||||
}
|
||||
|
||||
return arrayBuffer;
|
||||
};
|
||||
|
||||
const removeSegment = (buffer: ArrayBuffer, offset: number, length: number) => {
|
||||
// Create a new buffer without the specified segment
|
||||
const modifiedBuffer = new Uint8Array(buffer.byteLength - length);
|
||||
modifiedBuffer.set(new Uint8Array(buffer.slice(0, offset)), 0);
|
||||
modifiedBuffer.set(new Uint8Array(buffer.slice(offset + length)), offset);
|
||||
|
||||
return modifiedBuffer.buffer;
|
||||
};
|
||||
|
||||
export const removeExifData = (file: File): Promise<File> => {
|
||||
return new Promise(resolve => {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const fr = new FileReader();
|
||||
fr.onload = function (this: FileReader) {
|
||||
const cleanedBuffer = cleanBuffer(this.result as ArrayBuffer);
|
||||
const blob = new Blob([cleanedBuffer], { type: file.type });
|
||||
const newFile = new File([blob], file.name, { type: file.type });
|
||||
resolve(newFile);
|
||||
};
|
||||
fr.readAsArrayBuffer(file);
|
||||
} else resolve(file);
|
||||
});
|
||||
};
|
7
src/index.css
Normal file
7
src/index.css
Normal file
@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-neutral-900 text-slate-500 box-border;
|
||||
}
|
20
src/main.tsx
Normal file
20
src/main.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { NDKContextProvider } from './ndk.tsx';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<NDKContextProvider>
|
||||
<App />
|
||||
</NDKContextProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
184
src/ndk.tsx
Normal file
184
src/ndk.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import type { EventTemplate, SignedEvent } from 'blossom-client-sdk';
|
||||
import NDK, { NDKEvent, NDKNip07Signer, NDKNip46Signer, NDKPrivateKeySigner, NDKUser } from '@nostr-dev-kit/ndk';
|
||||
import { generateSecretKey, nip19 } from 'nostr-tools';
|
||||
import { decrypt } from 'nostr-tools/nip49';
|
||||
import { bytesToHex } from '@noble/hashes/utils';
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import NDKCacheAdapterDexie from '@nostr-dev-kit/ndk-cache-dexie';
|
||||
|
||||
type NDKContextType = {
|
||||
ndk: NDK;
|
||||
user?: NDKUser;
|
||||
logout: () => void;
|
||||
loginWithExtension: () => Promise<void>;
|
||||
loginWithNostrAddress: (connectionString: string) => Promise<void>;
|
||||
loginWithPrivateKey: (key: string) => Promise<void>;
|
||||
signEventTemplate: (template: EventTemplate) => Promise<SignedEvent>;
|
||||
publishSignedEvent: (signedEvent: SignedEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
export const NDKContext = createContext<NDKContextType>({
|
||||
ndk: new NDK({ explicitRelayUrls: [] }),
|
||||
logout: () => {},
|
||||
loginWithExtension: () => Promise.reject(),
|
||||
loginWithNostrAddress: () => Promise.reject(),
|
||||
loginWithPrivateKey: () => Promise.reject(),
|
||||
signEventTemplate: () => Promise.reject(),
|
||||
publishSignedEvent: () => Promise.reject(),
|
||||
});
|
||||
const cacheAdapter = new NDKCacheAdapterDexie({ dbName: 'ndk-cache' });
|
||||
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: ['wss://nostrue.com/', 'wss://relay.damus.io/', 'wss://nos.lol/'],
|
||||
cacheAdapter,
|
||||
});
|
||||
|
||||
export const NDKContextProvider = ({ children }: { children: React.ReactElement }) => {
|
||||
const [user, setUser] = useState(ndk.activeUser);
|
||||
|
||||
const fetchUserData = async function () {
|
||||
if (!ndk.signer) return;
|
||||
|
||||
console.log('Fetching user');
|
||||
const user = await ndk.signer.user();
|
||||
console.log('Fetching profile');
|
||||
user.fetchProfile();
|
||||
setUser(user);
|
||||
};
|
||||
|
||||
const loginWithExtension = async function () {
|
||||
const signer: NDKNip07Signer = new NDKNip07Signer();
|
||||
console.log('Waiting for NIP-07 signer');
|
||||
await signer.blockUntilReady();
|
||||
await signer.user();
|
||||
ndk.signer = signer;
|
||||
|
||||
await fetchUserData();
|
||||
};
|
||||
|
||||
const loginWithNostrAddress = async function (connectionString: string) {
|
||||
const localKey = localStorage.getItem('local-signer') || bytesToHex(generateSecretKey());
|
||||
const localSigner = new NDKPrivateKeySigner(localKey);
|
||||
|
||||
let signer: NDKNip46Signer;
|
||||
|
||||
// manually set remote user and pubkey if using NIP05
|
||||
if (connectionString.includes('@')) {
|
||||
const user = await ndk.getUserFromNip05(connectionString);
|
||||
if (!user?.pubkey) throw new Error('Cant find user');
|
||||
console.log('Found user', user);
|
||||
|
||||
signer = new NDKNip46Signer(ndk, connectionString, localSigner);
|
||||
|
||||
signer.remoteUser = user;
|
||||
signer.remotePubkey = user.pubkey;
|
||||
} else if (connectionString.startsWith('bunker://')) {
|
||||
const uri = new URL(connectionString);
|
||||
|
||||
const pubkey = uri.host || uri.pathname.replace('//', '');
|
||||
const relays = uri.searchParams.getAll('relay');
|
||||
for (const relay of relays) ndk.addExplicitRelay(relay);
|
||||
if (relays.length === 0) throw new Error('Missing relays');
|
||||
signer = new NDKNip46Signer(ndk, pubkey, localSigner);
|
||||
signer.relayUrls = relays;
|
||||
} else {
|
||||
signer = new NDKNip46Signer(ndk, connectionString, localSigner);
|
||||
}
|
||||
|
||||
signer.rpc.on('authUrl', (url: string) => {
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
|
||||
await signer.blockUntilReady();
|
||||
await signer.user();
|
||||
ndk.signer = signer;
|
||||
localStorage.setItem('local-signer', localSigner.privateKey ?? '');
|
||||
|
||||
await fetchUserData();
|
||||
};
|
||||
|
||||
const loginWithPrivateKey = async function (key: string) {
|
||||
if (key.startsWith('ncryptsec')) {
|
||||
const password = prompt('Enter your private key password');
|
||||
if (password === null) throw new Error('No password provided');
|
||||
const plaintext = bytesToHex(decrypt(key, password));
|
||||
console.log(plaintext);
|
||||
|
||||
ndk.signer = new NDKPrivateKeySigner(plaintext);
|
||||
await ndk.signer.blockUntilReady();
|
||||
localStorage.setItem('private-key', key);
|
||||
} else if (key.startsWith('nsec')) {
|
||||
const decoded = nip19.decode(key);
|
||||
if (decoded.type !== 'nsec') throw new Error('Not nsec');
|
||||
ndk.signer = new NDKPrivateKeySigner(bytesToHex(decoded.data));
|
||||
await ndk.signer.blockUntilReady();
|
||||
} else throw new Error('Unknown private format');
|
||||
|
||||
await fetchUserData();
|
||||
};
|
||||
|
||||
const logout = function logout() {
|
||||
localStorage.clear();
|
||||
location.reload();
|
||||
};
|
||||
|
||||
const signEventTemplate = async function signEventTemplate(template: EventTemplate): Promise<SignedEvent> {
|
||||
console.log('signEventTemplate called');
|
||||
const e = new NDKEvent(ndk);
|
||||
e.kind = template.kind;
|
||||
e.content = template.content;
|
||||
e.tags = template.tags;
|
||||
e.created_at = template.created_at;
|
||||
await e.sign();
|
||||
return e.rawEvent() as SignedEvent;
|
||||
};
|
||||
|
||||
const publishSignedEvent = async function (signedEvent: SignedEvent) {
|
||||
const e = new NDKEvent(ndk);
|
||||
e.content = signedEvent.content;
|
||||
e.tags = signedEvent.tags;
|
||||
e.created_at = signedEvent.created_at;
|
||||
e.kind = signedEvent.kind;
|
||||
e.id = signedEvent.id;
|
||||
e.pubkey = signedEvent.pubkey;
|
||||
e.sig = signedEvent.sig;
|
||||
await e.publish();
|
||||
};
|
||||
|
||||
ndk.connect();
|
||||
|
||||
const performAutoLogin = async () => {
|
||||
const autoLogin = localStorage.getItem('auto-login');
|
||||
if (autoLogin) {
|
||||
try {
|
||||
if (autoLogin === 'nip07') {
|
||||
await loginWithExtension().catch(() => {});
|
||||
} else if (autoLogin === 'nsec') {
|
||||
const key = localStorage.getItem('private-key');
|
||||
if (key) await loginWithPrivateKey(key);
|
||||
} else if (autoLogin.includes('@') || autoLogin.startsWith('bunker://') || autoLogin.includes('#')) {
|
||||
await loginWithNostrAddress(autoLogin).catch(() => {});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
performAutoLogin();
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
ndk,
|
||||
user,
|
||||
logout,
|
||||
loginWithExtension,
|
||||
loginWithNostrAddress,
|
||||
loginWithPrivateKey,
|
||||
signEventTemplate,
|
||||
publishSignedEvent,
|
||||
};
|
||||
|
||||
return <NDKContext.Provider value={value}>{children}</NDKContext.Provider>;
|
||||
};
|
||||
|
||||
export const useNDK = () => useContext(NDKContext);
|
41
src/useEvent.ts
Normal file
41
src/useEvent.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
NDKEvent,
|
||||
NDKFilter,
|
||||
NDKRelaySet,
|
||||
NDKSubscriptionCacheUsage,
|
||||
NDKSubscriptionOptions,
|
||||
} from '@nostr-dev-kit/ndk';
|
||||
import { useNDK } from './ndk';
|
||||
import { useMemo } from 'react';
|
||||
import { UseQueryResult, useQuery } from '@tanstack/react-query';
|
||||
import { hashSha256 } from './utils';
|
||||
|
||||
export interface SubscriptionOptions extends NDKSubscriptionOptions {
|
||||
disable?: boolean;
|
||||
}
|
||||
|
||||
export default function useEvent(filter: NDKFilter, opts?: SubscriptionOptions, relays?: string[]) {
|
||||
const { ndk } = useNDK();
|
||||
const id = useMemo(() => {
|
||||
return hashSha256(filter);
|
||||
}, [filter]);
|
||||
|
||||
const query: UseQueryResult<NDKEvent, any> = useQuery({
|
||||
queryKey: ['use-event', id],
|
||||
queryFn: () => {
|
||||
const relaySet = relays?.length ?? 0 > 0 ? NDKRelaySet.fromRelayUrls(relays as string[], ndk) : undefined;
|
||||
return ndk.fetchEvent(
|
||||
filter,
|
||||
{
|
||||
groupable: true,
|
||||
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
|
||||
...(opts ? opts : {}),
|
||||
},
|
||||
relaySet
|
||||
);
|
||||
},
|
||||
enabled: !opts?.disable,
|
||||
});
|
||||
|
||||
return query.data;
|
||||
}
|
46
src/utils.ts
Normal file
46
src/utils.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const formatFileSize = (size: number) => {
|
||||
if (size < 1024) {
|
||||
return size + ' B';
|
||||
} else if (size < 1024 * 1024) {
|
||||
return (size / 1024).toFixed(1) + ' KB';
|
||||
} else if (size < 1024 * 1024 * 1024) {
|
||||
return (size / 1024 / 1024).toFixed(1) + ' MB';
|
||||
} else {
|
||||
return (size / 1024 / 1024 / 1024).toFixed(1) + ' GB';
|
||||
}
|
||||
};
|
||||
|
||||
interface MyObject {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function hashSha256(obj: MyObject): string {
|
||||
const jsonString = JSON.stringify(obj);
|
||||
|
||||
const hashBuffer = sha256(new TextEncoder().encode(jsonString));
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return hashHex;
|
||||
}
|
||||
|
||||
export const uniqAndSort = (values: string[]): string[] => {
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
export const pr = (value: string | number, len: number) => {
|
||||
return value.toString().padEnd(len);
|
||||
};
|
||||
|
||||
export const pl = (value: string | number, len: number) => {
|
||||
return `${value}`.padStart(len);
|
||||
};
|
||||
|
||||
export const formatDate = (unixTimeStamp: number): string => {
|
||||
const ts = unixTimeStamp > 1711200000000 ? unixTimeStamp / 1000 : unixTimeStamp;
|
||||
if (ts == 0) return 'never';
|
||||
return dayjs(ts * 1000).format('YYYY-MM-DD');
|
||||
};
|
62
src/utils/useServerInfo.ts
Normal file
62
src/utils/useServerInfo.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useServers } from './useServers';
|
||||
import { useQueries } from '@tanstack/react-query';
|
||||
import { BlobDescriptor, BlossomClient } from 'blossom-client-sdk';
|
||||
import { useNDK } from '../ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
export type ServerInfo = {
|
||||
count: number;
|
||||
size: number;
|
||||
lastChange: number;
|
||||
isLoading: boolean;
|
||||
name: string;
|
||||
url: string;
|
||||
blobs?: BlobDescriptor[];
|
||||
};
|
||||
|
||||
export const useServerInfo = () => {
|
||||
const servers = useServers();
|
||||
const { user, signEventTemplate } = useNDK();
|
||||
|
||||
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type
|
||||
|
||||
const blobs = useQueries({
|
||||
queries: servers.map(server => ({
|
||||
queryKey: ['blobs', server.name],
|
||||
queryFn: async () => {
|
||||
const listAuthEvent = await BlossomClient.getListAuth(signEventTemplate, 'List Blobs');
|
||||
return await BlossomClient.listBlobs(server.url, pubkey!, undefined, listAuthEvent);
|
||||
},
|
||||
enabled: !!pubkey && servers.length > 0,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
})),
|
||||
});
|
||||
|
||||
const serverInfo = useMemo(() => {
|
||||
const info: { [key: string]: ServerInfo } = {};
|
||||
servers.forEach((server, sx) => {
|
||||
info[server.name] = {
|
||||
...server,
|
||||
blobs: blobs[sx].data,
|
||||
isLoading: blobs[sx].isLoading,
|
||||
count: blobs[sx].data?.length || 0,
|
||||
size: blobs[sx].data?.reduce((acc, blob) => acc + blob.size, 0) || 0,
|
||||
lastChange:
|
||||
blobs[sx].data?.reduce(
|
||||
(acc, blob) =>
|
||||
Math.max(
|
||||
acc,
|
||||
blob.created > 1711200000000 // fix for wrong timestamps on media-server.slidestr.net (remove)
|
||||
? blob.created / 1000
|
||||
: blob.created
|
||||
),
|
||||
0
|
||||
) || 0,
|
||||
};
|
||||
});
|
||||
return info;
|
||||
}, [servers, blobs]);
|
||||
|
||||
return serverInfo;
|
||||
};
|
39
src/utils/useServers.ts
Normal file
39
src/utils/useServers.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import { uniqAndSort } from '../utils';
|
||||
import { useNDK } from '../ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { NDKKind } from '@nostr-dev-kit/ndk';
|
||||
import useEvent from '../useEvent';
|
||||
|
||||
const additionalServers = [
|
||||
'https://media-server.slidestr.net',
|
||||
//'https://cdn.hzrd149.com',
|
||||
'https://cdn.satellite.earth',
|
||||
];
|
||||
|
||||
export type Server = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const useServers = (): Server[] => {
|
||||
const { user } = useNDK();
|
||||
|
||||
const pubkey = user?.npub && (nip19.decode(user?.npub).data as string); // TODO validate type
|
||||
|
||||
const serverListEvent = useEvent({ kinds: [10063 as NDKKind], authors: [pubkey!] }, { disable: !pubkey });
|
||||
console.log(serverListEvent);
|
||||
const servers = useMemo(() => {
|
||||
const serverUrls = uniqAndSort(
|
||||
[...(serverListEvent?.getMatchingTags('r').map(t => t[1]) || []), ...additionalServers].map(s =>
|
||||
s.toLocaleLowerCase().replace(/\/$/, '')
|
||||
)
|
||||
);
|
||||
return serverUrls.map(s => ({
|
||||
name: s.replace(/https?:\/\//, ''),
|
||||
url: s,
|
||||
}));
|
||||
}, [serverListEvent]);
|
||||
|
||||
return servers;
|
||||
};
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
12
tailwind.config.js
Normal file
12
tailwind.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
11
tsconfig.node.json
Normal file
11
tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
vite.config.ts
Normal file
7
vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user