feat: Addded server selection

This commit is contained in:
florian 2024-05-25 13:11:03 +02:00
parent fbde04cc5e
commit f49fc74c4d
20 changed files with 573 additions and 306 deletions

View File

@ -1,11 +1,10 @@
import React, { createContext, useReducer, useContext, ReactNode } from 'react';
import { ID3Tag } from './utils/id3';
type Song = {
url: string;
id3?: ID3Tag;
}
url: string;
id3?: ID3Tag;
};
interface State {
currentSong?: Song;
@ -17,10 +16,7 @@ const initialState: State = {
songs: [],
};
type Action =
| { type: 'SET_CURRENT_SONG'; song: Song }
| { type: 'SHUFFLE_SONGS' }
| { type: 'ADD_SONG'; song: Song };
type Action = { type: 'SET_CURRENT_SONG'; song: Song } | { type: 'SHUFFLE_SONGS' } | { type: 'ADD_SONG'; song: Song };
const reducer = (state: State, action: Action): State => {
switch (action.type) {

View File

@ -0,0 +1,99 @@
import { formatFileSize, formatDate } from '../../utils/utils';
import { ClipboardDocumentIcon, TrashIcon, PlayIcon } from '@heroicons/react/24/outline';
import { BlobDescriptor } from 'blossom-client-sdk';
import { fetchId3Tag } from '../../utils/id3';
import { useQueries } from '@tanstack/react-query';
import { useGlobalContext } from '../../GlobalState';
type AudioBlobListProps = {
audioFiles: BlobDescriptor[];
onDelete?: (blob: BlobDescriptor) => void;
};
const AudioBlobList = ({ audioFiles, onDelete }: AudioBlobListProps) => {
const { dispatch } = useGlobalContext();
const audioFilesWithId3 = useQueries({
queries: audioFiles.map(af => ({
queryKey: ['id3', af.sha256],
queryFn: async () => await fetchId3Tag(af),
staleTime: 1000 * 60 * 5,
cacheTime: 1000 * 60 * 5,
})),
});
return (
<div className="blob-list flex flex-wrap justify-center">
{audioFilesWithId3.map(
blob =>
blob.isSuccess && (
<div
key={blob.data.sha256}
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
style={{ width: '24em' }}
>
<div className="flex flex-row gap-4 pb-4">
<div className="cover-image">
<img
width={96}
height={96}
src={blob.data?.id3?.cover || '/music-placeholder.png'}
className="cursor-pointer rounded-md"
onClick={() =>
dispatch({ type: 'SET_CURRENT_SONG', song: { url: blob.data.url, id3: blob.data.id3 } })
}
/>
<PlayIcon
className="play-icon "
onClick={() =>
dispatch({ type: 'SET_CURRENT_SONG', song: { url: blob.data.url, id3: blob.data.id3 } })
}
></PlayIcon>
</div>
{blob.data.id3 && (
<div className="flex flex-col pb-4 flex-grow">
{blob.data.id3.title && <span className=" font-bold">{blob.data.id3.title}</span>}
{blob.data.id3.artist && <span>{blob.data.id3.artist}</span>}
{blob.data.id3.album && (
<span>
{blob.data.id3.album} {blob.data.id3.year ? `(${blob.data.id3.year})` : ''}
</span>
)}
</div>
)}
</div>
<div className="flex flex-grow flex-row text-xs items-end">
<span>{formatFileSize(blob.data.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.data.uploaded)}</span>
</div>
<div className="actions absolute bottom-10 right-2 ">
<span>
<a
className="link link-primary tooltip"
data-tip="Copy link to clipboard"
onClick={() => navigator.clipboard.writeText(blob.data.url)}
>
<ClipboardDocumentIcon />
</a>
</span>
{onDelete && (
<span>
<a
onClick={() => onDelete(blob.data)}
className="link link-primary tooltip"
data-tip="Delete this blob"
>
<TrashIcon />
</a>
</span>
)}
</div>
</div>
)
)}
</div>
);
};
export default AudioBlobList;

View File

@ -0,0 +1,38 @@
import { AddressPointer } from 'nostr-tools/nip19';
import { KIND_BLOSSOM_DRIVE, KIND_FILE_META } from '../../utils/useFileMetaEvents';
import { nip19 } from 'nostr-tools';
import { EventPointer, NDKEvent } from '@nostr-dev-kit/ndk';
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">filemeta</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 <></>;
};
export default Badge;

View File

@ -29,4 +29,3 @@
.blob-list .cover-image .play-icon {
@apply opacity-0 absolute text-white top-8 left-8 w-16 h-16 rounded-full bg-[rgba(0,0,0,.4)] p-2 cursor-pointer;
}

View File

@ -1,29 +1,16 @@
import {
ClipboardDocumentIcon,
DocumentIcon,
ExclamationTriangleIcon,
FilmIcon,
ListBulletIcon,
MusicalNoteIcon,
PhotoIcon,
PlayIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { useState, useMemo } from 'react';
import { BlobDescriptor } from 'blossom-client-sdk';
import { formatDate, formatFileSize } from '../../utils/utils';
import './BlobList.css';
import { useEffect, useMemo, useState } from 'react';
import { Document, Page } from 'react-pdf';
import { useQueries } from '@tanstack/react-query';
import { ClipboardDocumentIcon, DocumentIcon, ExclamationTriangleIcon, TrashIcon } from '@heroicons/react/24/outline';
import { formatFileSize, formatDate } from '../../utils/utils';
import ImageBlobList from '../ImageBlobList/ImageBlobList';
import VideoBlobList from '../VideoBlobList/VideoBlobList';
import AudioBlobList from '../AudioBlobList/AudioBlobList';
import DocumentBlobList from '../DocumentBlobList/DocumentBlobList';
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';
import { useGlobalContext } from '../../GlobalState';
import { fetchId3Tag } from '../../utils/id3';
type ListMode = 'gallery' | 'list' | 'audio' | 'video' | 'docs';
import Badge from './Badge';
import BlobListTypeMenu, { ListMode } from './BlobListTypeMenu';
import useFileMetaEventsByHash from '../../utils/useFileMetaEvents';
import './BlobList.css';
type BlobListProps = {
blobs: BlobDescriptor[];
@ -36,7 +23,6 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
const [mode, setMode] = useState<ListMode>('list');
const { distribution } = useServerInfo();
const fileMetaEventsByHash = useFileMetaEventsByHash();
const { dispatch } = useGlobalContext();
const images = useMemo(
() => blobs.filter(b => b.type?.startsWith('image/')).sort((a, b) => (a.uploaded > b.uploaded ? -1 : 1)), // descending
@ -52,41 +38,12 @@ const BlobList = ({ blobs, onDelete, title, className = '' }: BlobListProps) =>
() => blobs.filter(b => b.type?.startsWith('audio/')).sort((a, b) => (a.uploaded > b.uploaded ? -1 : 1)),
[blobs]
);
console.log(audioFiles);
const audioFilesWithId3 = useQueries({
queries: audioFiles.map(af => ({
queryKey: ['id3', af.sha256],
queryFn: async () => {
return await fetchId3Tag(af);
},
enabled: mode == 'audio' && !!audioFiles && audioFiles.length > 0,
staleTime: 1000 * 60 * 5,
cacheTime: 1000 * 60 * 5,
})),
});
const docs = useMemo(
() => blobs.filter(b => b.type?.startsWith('application/pdf')).sort((a, b) => (a.uploaded > b.uploaded ? -1 : 1)), // descending
[blobs]
);
useEffect(() => {
switch (mode) {
case 'video':
if (videos.length == 0) setMode('list');
break;
case 'audio':
if (audioFiles.length == 0) setMode('list');
break;
case 'gallery':
if (images.length == 0) setMode('list');
break;
case 'docs':
if (docs.length == 0) setMode('list');
break;
}
}, [videos, images, audioFiles, mode, docs]);
const Actions = ({ blob, className }: { blob: BlobDescriptor; className?: string }) => (
<div className={className}>
<span>
@ -110,216 +67,35 @@ console.log(audioFiles);
</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">filemeta</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 events.map(ev => <Badge ev={ev} key={ev.id}></Badge>);
};
return (
<>
<div className={`blog-list-header ${className} ${!title ? 'justify-end' : ''}`}>
<div className={`blog-list-header ${className} ${!title ? 'justify-end' : ''}`}>
{title && <h2>{title}</h2>}
<ul className="menu menu-horizontal menu-active bg-base-200 rounded-box">
<li>
<a
className={' tooltip ' + (mode == 'list' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
data-tip="All content"
onClick={() => setMode('list')}
>
<ListBulletIcon />
</a>
</li>
<li className={images.length == 0 ? 'disabled' : ''}>
<a
className={' tooltip ' + (mode == 'gallery' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('gallery')}
data-tip="Images"
>
<PhotoIcon />
</a>
</li>
<li className={audioFiles.length == 0 ? 'disabled' : ''}>
<a
className={' tooltip ' + (mode == 'audio' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('audio')}
data-tip="Music"
>
<MusicalNoteIcon />
</a>
</li>
<li className={videos.length == 0 ? 'disabled' : ''}>
<a
className={' tooltip ' + (mode == 'video' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('video')}
data-tip="Video"
>
<FilmIcon />
</a>
</li>
<li className={docs.length == 0 ? 'disabled' : ''}>
<a
className={' tooltip ' + (mode == 'docs' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('docs')}
data-tip="PDF documents"
>
<DocumentIcon />
</a>
</li>
</ul>
<BlobListTypeMenu
mode={mode}
setMode={setMode}
hasImages={images.length > 0}
hasVideo={videos.length > 0}
hasAudio={audioFiles.length > 0}
hasDocs={docs.length > 0}
/>
</div>
{mode == 'gallery' && (
<div className="blob-list flex flex-wrap justify-center flex-grow">
{images.map(blob => (
<div key={blob.sha256} className="p-2 rounded-lg bg-base-300 m-2 relative inline-block text-center">
<a href={blob.url} target="_blank">
<div
className="bg-center bg-no-repeat bg-contain cursor-pointer inline-block w-[90vw] md:w-[200px] h-[200px]"
style={{
backgroundImage: `url(https://images.slidestr.net/insecure/f:webp/rs:fill:300/plain/${blob.url})`,
}}
></div>
</a>
<div className="flex flex-row text-xs">
<span>{formatFileSize(blob.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.uploaded)}</span>
</div>
<Actions blob={blob} className="actions absolute bottom-8 right-0"></Actions>
</div>
))}
</div>
)}
{mode == 'gallery' && <ImageBlobList images={images} onDelete={onDelete} />}
{mode == 'video' && (
<div className="blob-list flex flex-wrap justify-center">
{videos.map(blob => (
<div
key={blob.sha256}
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
style={{ width: '340px' }}
>
<video src={blob.url} preload="metadata" width={320} controls playsInline></video>
<div className="flex flex-grow flex-row text-xs pt-12 items-end">
<span>{formatFileSize(blob.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.uploaded)}</span>
</div>
<Actions blob={blob} className="actions absolute bottom-10 right-2 " />
</div>
))}
</div>
)}
{mode == 'video' && <VideoBlobList videos={videos} onDelete={onDelete} />}
{mode == 'audio' && (
<div className="blob-list flex flex-wrap justify-center">
{audioFilesWithId3.map(
blob =>
blob.isSuccess && (
<div
key={blob.data.sha256}
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
style={{ width: '24em' }}
>
<div className="flex flex-row gap-4 pb-4">
<div className="cover-image">
<img
width={96}
height={96}
src={blob.data?.id3?.cover || '/music-placeholder.png'}
className="cursor-pointer rounded-md"
onClick={() => dispatch({ type: 'SET_CURRENT_SONG', song: {url: blob.data.url, id3: blob.data.id3 }})}
/>
<PlayIcon
className="play-icon "
onClick={() => dispatch({ type: 'SET_CURRENT_SONG', song: {url: blob.data.url, id3: blob.data.id3 }})}
></PlayIcon>
</div>
{blob.data.id3 && (
<div className="flex flex-col pb-4 flex-grow">
{blob.data.id3.title && <span className=" font-bold">{blob.data.id3.title}</span>}
{blob.data.id3.artist && <span>{blob.data.id3.artist}</span>}
{blob.data.id3.album && (
<span>
{blob.data.id3.album} {blob.data.id3.year ? `(${blob.data.id3.year})` : ''}
</span>
)}
</div>
)}
</div>
{mode == 'audio' && <AudioBlobList audioFiles={audioFiles} onDelete={onDelete} />}
<div className="flex flex-grow flex-row text-xs items-end">
<span>{formatFileSize(blob.data.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.data.uploaded)}</span>
</div>
<Actions blob={blob.data} className="actions absolute bottom-10 right-2 " />
</div>
)
)}
</div>
)}
{mode == 'docs' && (
<div className="blob-list flex flex-wrap justify-center">
{docs.map(blob => (
<div
key={blob.sha256}
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
style={{ width: '22em' }}
>
<a href={blob.url} target="_blank" className="block overflow-clip text-ellipsis py-2">
<Document file={blob.url}>
<Page
pageIndex={0}
width={300}
renderTextLayer={false}
renderAnnotationLayer={false}
renderForms={false}
/>
</Document>
</a>
<div className="flex flex-grow flex-row text-xs pt-12 items-end">
<span>{formatFileSize(blob.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.uploaded)}</span>
</div>
<Actions blob={blob} className="actions absolute bottom-10 right-2 " />
</div>
))}
</div>
)}
{mode == 'docs' && <DocumentBlobList docs={docs} onDelete={onDelete} />}
{mode == 'list' && (
<div className="blob-list">

View File

@ -0,0 +1,88 @@
import { DocumentIcon, FilmIcon, ListBulletIcon, MusicalNoteIcon, PhotoIcon } from '@heroicons/react/24/outline';
import { useEffect } from 'react';
export type ListMode = 'gallery' | 'list' | 'audio' | 'video' | 'docs';
type BlobListTypeMenuProps = {
setMode: React.Dispatch<React.SetStateAction<ListMode>>;
mode: string;
hasImages: boolean;
hasAudio: boolean;
hasVideo: boolean;
hasDocs: boolean;
};
const BlobListTypeMenu = ({ mode, setMode, hasImages, hasAudio, hasDocs, hasVideo }: BlobListTypeMenuProps) => {
useEffect(() => {
switch (mode) {
case 'video':
if (!hasVideo) setMode('list');
break;
case 'audio':
if (!hasAudio) setMode('list');
break;
case 'gallery':
if (!hasImages) setMode('list');
break;
case 'docs':
if (!hasDocs) setMode('list');
break;
}
}, [hasAudio, hasDocs, hasImages, hasVideo, mode, setMode]);
return (
<ul className="menu menu-horizontal menu-active bg-base-200 rounded-box">
<li>
<a
className={' tooltip ' + (mode == 'list' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
data-tip="All content"
onClick={() => setMode('list')}
>
<ListBulletIcon />
</a>
</li>
<li className={hasImages ? '' : 'disabled'}>
<a
className={' tooltip ' + (mode == 'gallery' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('gallery')}
data-tip="Images"
>
<PhotoIcon />
</a>
</li>
<li className={hasAudio ? '' : 'disabled'}>
<a
className={' tooltip ' + (mode == 'audio' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('audio')}
data-tip="Music"
>
<MusicalNoteIcon />
</a>
</li>
<li className={hasVideo ? '' : 'disabled'}>
<a
className={' tooltip ' + (mode == 'video' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('video')}
data-tip="Video"
>
<FilmIcon />
</a>
</li>
<li className={hasDocs ? '' : 'disabled'}>
<a
className={' tooltip ' + (mode == 'docs' ? 'bg-primary text-primary-content hover:bg-primary ' : '')}
onClick={() => setMode('docs')}
data-tip="PDF documents"
>
<DocumentIcon />
</a>
</li>
</ul>
);
};
export default BlobListTypeMenu;

View File

@ -7,11 +7,9 @@ interface BottomNavbarProps {
const BottomNavbar: React.FC<BottomNavbarProps> = ({ children }) => {
return (
<div className="fixed bottom-0 left-0 w-full bg-base-300 shadow-[0px_0px_4px_0px_rgba(0,0,0,.4)] ">
<div className="navbar" >
{children}
</div>
<div className="navbar">{children}</div>
</div>
);
};
export default BottomNavbar;
export default BottomNavbar;

View File

@ -0,0 +1,51 @@
import { formatFileSize, formatDate } from '../../utils/utils';
import { ClipboardDocumentIcon, TrashIcon } from '@heroicons/react/24/outline';
import { BlobDescriptor } from 'blossom-client-sdk';
import { Document, Page } from 'react-pdf';
type DocumentBlobListProps = {
docs: BlobDescriptor[];
onDelete?: (blob: BlobDescriptor) => void;
};
const DocumentBlobList = ({ docs, onDelete }: DocumentBlobListProps) => (
<div className="blob-list flex flex-wrap justify-center">
{docs.map(blob => (
<div
key={blob.sha256}
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
style={{ width: '22em' }}
>
<a href={blob.url} target="_blank" className="block overflow-clip text-ellipsis py-2">
<Document file={blob.url}>
<Page pageIndex={0} width={300} renderTextLayer={false} renderAnnotationLayer={false} renderForms={false} />
</Document>
</a>
<div className="flex flex-grow flex-row text-xs pt-12 items-end">
<span>{formatFileSize(blob.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.uploaded)}</span>
</div>
<div className="actions absolute bottom-10 right-2 ">
<span>
<a
className="link link-primary tooltip"
data-tip="Copy link to clipboard"
onClick={() => navigator.clipboard.writeText(blob.url)}
>
<ClipboardDocumentIcon />
</a>
</span>
{onDelete && (
<span>
<a onClick={() => onDelete(blob)} className="link link-primary tooltip" data-tip="Delete this blob">
<TrashIcon />
</a>
</span>
)}
</div>
</div>
))}
</div>
);
export default DocumentBlobList;

View File

@ -0,0 +1,49 @@
import { formatFileSize, formatDate } from '../../utils/utils';
import { ClipboardDocumentIcon, TrashIcon } from '@heroicons/react/24/outline';
import { BlobDescriptor } from 'blossom-client-sdk';
type ImageBlobListProps = {
images: BlobDescriptor[];
onDelete?: (blob: BlobDescriptor) => void;
};
const ImageBlobList = ({ images, onDelete }: ImageBlobListProps) => (
<div className="blob-list flex flex-wrap justify-center flex-grow">
{images.map(blob => (
<div key={blob.sha256} className="p-2 rounded-lg bg-base-300 m-2 relative inline-block text-center">
<a href={blob.url} target="_blank">
<div
className="bg-center bg-no-repeat bg-contain cursor-pointer inline-block w-[90vw] md:w-[200px] h-[200px]"
style={{
backgroundImage: `url(https://images.slidestr.net/insecure/f:webp/rs:fill:300/plain/${blob.url})`,
}}
></div>
</a>
<div className="flex flex-row text-xs">
<span>{formatFileSize(blob.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.uploaded)}</span>
</div>
<div className="actions absolute bottom-8 right-0">
<span>
<a
className="link link-primary tooltip"
data-tip="Copy link to clipboard"
onClick={() => navigator.clipboard.writeText(blob.url)}
>
<ClipboardDocumentIcon />
</a>
</span>
{onDelete && (
<span>
<a onClick={() => onDelete(blob)} className="link link-primary tooltip" data-tip="Delete this blob">
<TrashIcon />
</a>
</span>
)}
</div>
</div>
))}
</div>
);
export default ImageBlobList;

View File

@ -59,7 +59,6 @@ export const Layout = () => {
</div>
<div className="navbar-center hidden md:block">{navItems}</div>
<div className="navbar-end">
<ThemeSwitcher />
<div className="avatar px-4">
<div className="w-12 rounded-full">
@ -71,8 +70,7 @@ export const Layout = () => {
<div className="content">{<Outlet />}</div>
<BottomNavbar>
<AudioPlayer />
<AudioPlayer />
</BottomNavbar>
<div className="footer">
<span className="whitespace-nowrap block">

View File

@ -42,23 +42,3 @@
.server-actions a {
@apply cursor-pointer text-center flex flex-col items-center hover:text-white opacity-80 hover:opacity-100 gap-1;
}
.server-list-header {
@apply flex flex-row py-4;
}
.server-list-header h2 {
@apply flex-grow;
}
.server-list-header button {
@apply bg-zinc-800 hover:bg-zinc-700 p-2 ml-2 my-2 text-white rounded-lg disabled:text-zinc-700 disabled:bg-zinc-900;
}
.server-list-header button.selected {
@apply bg-pink-700 text-white;
}
.server-list-header svg {
@apply w-6 opacity-80 hover:opacity-100;
}

View File

@ -1,8 +1,13 @@
import { PlusIcon, ServerIcon } from '@heroicons/react/24/outline';
import { Cog8ToothIcon } from '@heroicons/react/24/outline';
import { useServerInfo } from '../../utils/useServerInfo';
import { Server as ServerType } from '../../utils/useUserServers';
import Server from './Server';
import './ServerList.css';
import ServerListPopup from '../ServerListPopup';
import { useState } from 'react';
import { useNDK } from '../../utils/ndk';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import dayjs from 'dayjs';
type ServerListProps = {
servers: ServerType[];
@ -12,7 +17,7 @@ type ServerListProps = {
onCancel?: () => void;
onCheck?: (server: string) => void;
title?: React.ReactElement;
showAddButton?: boolean;
manageServers?: boolean;
};
export const ServerList = ({
@ -22,27 +27,58 @@ export const ServerList = ({
onTransfer,
onCancel,
title,
showAddButton = false,
manageServers = false,
}: ServerListProps) => {
const { ndk, user } = useNDK();
const { serverInfo, distribution } = useServerInfo();
const blobsWithOnlyOneOccurance = Object.values(distribution)
.filter(d => d.servers.length == 1)
.map(d => ({ ...d.blob, server: d.servers[0] }));
const [isDialogOpen, setIsDialogOpen] = useState(false);
const handleOpenDialog = () => {
setIsDialogOpen(true);
};
const handleCloseDialog = () => {
setIsDialogOpen(false);
};
const handleSaveServers = async (newServers: ServerType[]) => {
const ev = new NDKEvent(ndk, {
kind: 10063,
created_at: dayjs().unix(),
content: '',
pubkey: user?.pubkey || '',
tags: newServers.map(s => ['server', `${s.url}`]),
});
await ev.sign();
console.log(ev.rawEvent());
await ev.publish();
};
return (
<>
<div className={`server-list-header ${!title ? 'justify-end' : ''}`}>
{title && <h2>{title}</h2>}
{showAddButton && (
<div className={`flex flex-row py-4 ${!title ? 'justify-end' : ''}`}>
{title && <h2 className=" flex-grow">{title}</h2>}
{manageServers && (
<div className="content-center">
<button onClick={() => {}} className="flex flex-row gap-2" title="Add server">
<PlusIcon />
<ServerIcon />
<button onClick={handleOpenDialog} className="btn btn-ghost btn-sm" title="Manage servers">
<Cog8ToothIcon className="h-6 w-6" /> Manage servers
</button>
</div>
)}
</div>
<ServerListPopup
isOpen={isDialogOpen}
onClose={handleCloseDialog}
onSave={handleSaveServers}
initialServers={Object.values(serverInfo)}
/>
<div className="server-list">
{servers.map(server => (
<Server

View File

@ -0,0 +1,114 @@
import { ArrowDownIcon, ArrowUpIcon, TrashIcon } from '@heroicons/react/24/outline';
import React, { useState, useEffect, useRef } from 'react';
import { Server } from '../../utils/useUserServers';
interface ServerListPopupProps {
isOpen: boolean;
onClose: () => void;
onSave: (servers: Server[]) => void;
initialServers: Server[];
}
const ServerListPopup: React.FC<ServerListPopupProps> = ({ isOpen, onClose, onSave, initialServers }) => {
const [servers, setServers] = useState<Server[]>([]);
const [newServer, setNewServer] = useState('');
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
setServers(initialServers);
}, [initialServers]);
useEffect(() => {
if (isOpen) {
dialogRef.current?.showModal();
} else {
dialogRef.current?.close();
}
}, [isOpen]);
const handleAddServer = () => {
if (newServer.trim()) {
setServers([...servers, { name: newServer.trim(), url: newServer.trim() }]);
setNewServer('');
}
};
const handleDeleteServer = (url: string) => {
setServers(servers.filter(server => server.url !== url));
};
const handleMoveUp = (index: number) => {
if (index > 0) {
const newServers = [...servers];
[newServers[index], newServers[index - 1]] = [newServers[index - 1], newServers[index]];
setServers(newServers);
}
};
const handleMoveDown = (index: number) => {
if (index < servers.length - 1) {
const newServers = [...servers];
[newServers[index], newServers[index + 1]] = [newServers[index + 1], newServers[index]];
setServers(newServers);
}
};
const handleSave = () => {
onSave(servers);
onClose();
};
return (
<>
{isOpen && <div className="fixed top-0 left-0 w-full h-full bg-black opacity-50 z-50" />}
<dialog ref={dialogRef} className="p-6 bg-base-300 rounded-lg shadow-lg w-full max-w-lg mx-auto">
<div>
<h2 className="text-xl font-semibold">Manage Servers</h2>
<ul className="mt-4">
{servers.map((server, index) => (
<li key={server.url} className="flex items-center justify-between mt-2">
<span>{server.url}</span>
<div className="flex items-center space-x-2">
<button className="btn btn-ghost btn-sm" onClick={() => handleMoveUp(index)}>
<ArrowUpIcon className="h-5 w-5" />
</button>
<button className="btn btn-ghost btn-sm" onClick={() => handleMoveDown(index)}>
<ArrowDownIcon className="h-5 w-5" />
</button>
<button className="btn btn-ghost btn-sm" onClick={() => handleDeleteServer(server.url)}>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</li>
))}
</ul>
<div className="mt-4 flex flex-row gap-2">
<input
type="text"
className="input input-bordered w-full"
placeholder="Enter server URL"
value={newServer}
onChange={e => setNewServer(e.target.value)}
/>
<button className="btn btn-primary" onClick={handleAddServer}>
Add Server
</button>
</div>
<div className="mt-4 flex justify-end space-x-2">
<button className="btn" onClick={onClose}>
Cancel
</button>
<button className="btn btn-primary" onClick={handleSave}>
Save
</button>
</div>
</div>
</dialog>
</>
);
};
export default ServerListPopup;

View File

@ -0,0 +1,46 @@
import { formatFileSize, formatDate } from '../../utils/utils';
import { ClipboardDocumentIcon, TrashIcon } from '@heroicons/react/24/outline';
import { BlobDescriptor } from 'blossom-client-sdk';
type VideoBlobListProps = {
videos: BlobDescriptor[];
onDelete?: (blob: BlobDescriptor) => void;
};
const VideoBlobList = ({ videos, onDelete }: VideoBlobListProps) => (
<div className="blob-list flex flex-wrap justify-center">
{videos.map(blob => (
<div
key={blob.sha256}
className="p-4 rounded-lg bg-base-300 m-2 relative flex flex-col"
style={{ width: '340px' }}
>
<video src={blob.url} preload="metadata" width={320} controls playsInline></video>
<div className="flex flex-grow flex-row text-xs pt-12 items-end">
<span>{formatFileSize(blob.size)}</span>
<span className=" flex-grow text-right">{formatDate(blob.uploaded)}</span>
</div>
<div className="actions absolute bottom-10 right-2 ">
<span>
<a
className="link link-primary tooltip"
data-tip="Copy link to clipboard"
onClick={() => navigator.clipboard.writeText(blob.url)}
>
<ClipboardDocumentIcon />
</a>
</span>
{onDelete && (
<span>
<a onClick={() => onDelete(blob)} className="link link-primary tooltip" data-tip="Delete this blob">
<TrashIcon />
</a>
</span>
)}
</div>
</div>
))}
</div>
);
export default VideoBlobList;

View File

@ -1,3 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body:has(dialog[open]) {
overflow: hidden;
}

View File

@ -61,6 +61,7 @@ function Home() {
onTransfer={() => navigate('/transfer/' + selectedServer)}
onCheck={() => navigate('/check/' + selectedServer)}
title={<>Servers</>}
manageServers={true}
></ServerList>
{selectedServer && serverInfo[selectedServer] && selectedServerBlobs && (

View File

@ -120,7 +120,7 @@ function Upload() {
const authStartTime = Date.now();
// TODO do this only once for each file. Currently this is called for every server
const uploadAuth = await BlossomClient.getUploadAuth(file, signEventTemplate, 'Upload Blob');
console.log(`Created auth event in ${Date.now()-authStartTime} ms`, uploadAuth);
console.log(`Created auth event in ${Date.now() - authStartTime} ms`, uploadAuth);
const newBlob = await uploadBlob(serverUrl, file, uploadAuth, progressEvent => {
setTransfers(ut => ({

View File

@ -144,7 +144,7 @@ export const fetchId3Tag = async (blob: BlobDescriptor): Promise<AudioBlob> => {
}
}
console.log(blob.sha256, tagResult);
// console.log(blob.sha256, tagResult);
await saveID3TagToDB(db, blob.sha256, tagResult);
return { ...blob, id3: tagResult };

View File

@ -30,7 +30,7 @@ console.log(allXTags);
const groupedByX = groupBy(allXTags, item => item.x);
return mapValues(groupedByX, v => v.map(e => e.ev));
}, [fileMetaSub]);
// console.log(fileMetaEventsByHash);
// console.log(fileMetaEventsByHash);
return fileMetaEventsByHash;
};

View File

@ -1,12 +1,9 @@
import { useMemo } from 'react';
import { uniqAndSort } from '../utils/utils';
import { useNDK } from '../utils/ndk';
import { nip19 } from 'nostr-tools';
import { NDKKind } from '@nostr-dev-kit/ndk';
import useEvent from '../utils/useEvent';
const additionalServers = ['https://cdn.satellite.earth'];
export type Server = {
name: string;
url: string;
@ -20,13 +17,10 @@ export const useUserServers = (): Server[] => {
const serverListEvent = useEvent({ kinds: [10063 as NDKKind], authors: [pubkey!] }, { disable: !pubkey });
const servers = useMemo(() => {
const serverUrls = uniqAndSort(
[
...(serverListEvent?.getMatchingTags('r').map(t => t[1]) || []), // TODO 'r' is deprecated
...(serverListEvent?.getMatchingTags('server').map(t => t[1]) || []),
...additionalServers,
].map(s => s.toLocaleLowerCase().replace(/\/$/, ''))
const serverUrls = (serverListEvent?.getMatchingTags('server').map(t => t[1]) || []).map(s =>
s.toLocaleLowerCase().replace(/\/$/, '')
);
return serverUrls.map(s => ({
name: s.replace(/https?:\/\//, ''),
url: s,