mirror of
https://git.v0l.io/florian/bouquet.git
synced 2025-03-18 01:13:00 +01:00
feat: Addded server selection
This commit is contained in:
parent
fbde04cc5e
commit
f49fc74c4d
@ -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) {
|
||||
|
99
src/components/AudioBlobList/AudioBlobList.tsx
Normal file
99
src/components/AudioBlobList/AudioBlobList.tsx
Normal 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;
|
38
src/components/BlobList/Badge.tsx
Normal file
38
src/components/BlobList/Badge.tsx
Normal 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;
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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">
|
||||
|
88
src/components/BlobList/BlobListTypeMenu.tsx
Normal file
88
src/components/BlobList/BlobListTypeMenu.tsx
Normal 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;
|
@ -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;
|
||||
|
51
src/components/DocumentBlobList/DocumentBlobList.tsx
Normal file
51
src/components/DocumentBlobList/DocumentBlobList.tsx
Normal 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;
|
49
src/components/ImageBlobList/ImageBlobList.tsx
Normal file
49
src/components/ImageBlobList/ImageBlobList.tsx
Normal 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;
|
@ -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">
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
|
114
src/components/ServerListPopup/index.tsx
Normal file
114
src/components/ServerListPopup/index.tsx
Normal 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;
|
46
src/components/VideoBlobList/VideoBlobList.tsx
Normal file
46
src/components/VideoBlobList/VideoBlobList.tsx
Normal 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;
|
@ -1,3 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body:has(dialog[open]) {
|
||||
overflow: hidden;
|
||||
}
|
@ -61,6 +61,7 @@ function Home() {
|
||||
onTransfer={() => navigate('/transfer/' + selectedServer)}
|
||||
onCheck={() => navigate('/check/' + selectedServer)}
|
||||
title={<>Servers</>}
|
||||
manageServers={true}
|
||||
></ServerList>
|
||||
|
||||
{selectedServer && serverInfo[selectedServer] && selectedServerBlobs && (
|
||||
|
@ -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 => ({
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user