mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-03 16:09:52 +02:00
Add Simple Satellite CDN view
This commit is contained in:
5
.changeset/weak-shirts-help.md
Normal file
5
.changeset/weak-shirts-help.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add Simple Satellite CDN view
|
@@ -77,6 +77,7 @@ import LoginNostrConnectView from "./views/signin/nostr-connect";
|
|||||||
import ThreadsNotificationsView from "./views/notifications/threads";
|
import ThreadsNotificationsView from "./views/notifications/threads";
|
||||||
import DVMFeedView from "./views/dvm-feed/feed";
|
import DVMFeedView from "./views/dvm-feed/feed";
|
||||||
import TransformNoteView from "./views/tools/transform-note";
|
import TransformNoteView from "./views/tools/transform-note";
|
||||||
|
import SatelliteCDNView from "./views/tools/satellite-cdn";
|
||||||
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
const UserTracksTab = lazy(() => import("./views/user/tracks"));
|
||||||
|
|
||||||
const ToolsHomeView = lazy(() => import("./views/tools"));
|
const ToolsHomeView = lazy(() => import("./views/tools"));
|
||||||
@@ -264,6 +265,7 @@ const router = createHashRouter([
|
|||||||
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
|
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
|
||||||
{ path: "dm-feed", element: <DMFeedView /> },
|
{ path: "dm-feed", element: <DMFeedView /> },
|
||||||
{ path: "transform/:id", element: <TransformNoteView /> },
|
{ path: "transform/:id", element: <TransformNoteView /> },
|
||||||
|
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
85
src/helpers/satellite-cdn.ts
Normal file
85
src/helpers/satellite-cdn.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import { DraftNostrEvent, NostrEvent, Tag } from "../types/nostr-event";
|
||||||
|
|
||||||
|
const ROOT_URL = "https://api.satellite.earth/v1/media";
|
||||||
|
type Signer = (draft: DraftNostrEvent) => Promise<NostrEvent>;
|
||||||
|
|
||||||
|
export type SatelliteCDNUpload = {
|
||||||
|
created: number;
|
||||||
|
sha256: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
infohash: string;
|
||||||
|
magnet: string;
|
||||||
|
size: number;
|
||||||
|
type: string;
|
||||||
|
nip94: Tag[];
|
||||||
|
};
|
||||||
|
export type SatelliteCDNFile = {
|
||||||
|
created: number;
|
||||||
|
magnet: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
export type SatelliteCDNAccount = {
|
||||||
|
timeRemaining: number;
|
||||||
|
paidThrough: number;
|
||||||
|
transactions: {
|
||||||
|
order: NostrEvent;
|
||||||
|
receipt: NostrEvent;
|
||||||
|
payment: NostrEvent;
|
||||||
|
}[];
|
||||||
|
storageTotal: number;
|
||||||
|
creditTotal: number;
|
||||||
|
usageTotal: number;
|
||||||
|
rateFiat: {
|
||||||
|
usd: number;
|
||||||
|
};
|
||||||
|
exchangeFiat: {
|
||||||
|
usd: number;
|
||||||
|
};
|
||||||
|
files: SatelliteCDNFile[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAccountAuthToken(signEvent: Signer) {
|
||||||
|
return signEvent({
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
kind: 22242,
|
||||||
|
content: "Authenticate User",
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAccount(authToken: NostrEvent) {
|
||||||
|
return fetch(`${ROOT_URL}/account?auth=${encodeURIComponent(JSON.stringify(authToken))}`).then((res) =>
|
||||||
|
res.json(),
|
||||||
|
) as Promise<SatelliteCDNAccount>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFile(sha256: string, signEvent: Signer) {
|
||||||
|
const draft: DraftNostrEvent = {
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
kind: 22242,
|
||||||
|
content: "Delete Item",
|
||||||
|
tags: [["x", sha256]],
|
||||||
|
};
|
||||||
|
const signed = await signEvent(draft);
|
||||||
|
await fetch(`${ROOT_URL}/item?auth=${encodeURIComponent(JSON.stringify(signed))}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(file: File, signEvent: Signer) {
|
||||||
|
const draft: DraftNostrEvent = {
|
||||||
|
created_at: dayjs().unix(),
|
||||||
|
kind: 22242,
|
||||||
|
content: "Authorize Upload",
|
||||||
|
tags: [["name", file.name]],
|
||||||
|
};
|
||||||
|
const signed = await signEvent(draft);
|
||||||
|
return (await fetch(`${ROOT_URL}/item?auth=${encodeURIComponent(JSON.stringify(signed))}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: file,
|
||||||
|
}).then((res) => res.json())) as Promise<SatelliteCDNUpload>;
|
||||||
|
}
|
@@ -7,6 +7,7 @@ import ShieldOff from "../../components/icons/shield-off";
|
|||||||
import HoverLinkOverlay from "../../components/hover-link-overlay";
|
import HoverLinkOverlay from "../../components/hover-link-overlay";
|
||||||
import Users01 from "../../components/icons/users-01";
|
import Users01 from "../../components/icons/users-01";
|
||||||
import Magnet from "../../components/icons/magnet";
|
import Magnet from "../../components/icons/magnet";
|
||||||
|
import Moon01 from "../../components/icons/moon-01";
|
||||||
|
|
||||||
function InternalLink({
|
function InternalLink({
|
||||||
to,
|
to,
|
||||||
@@ -56,6 +57,16 @@ export default function ToolsHomeView() {
|
|||||||
<InternalLink to="/torrents" icon={Magnet}>
|
<InternalLink to="/torrents" icon={Magnet}>
|
||||||
Torrents
|
Torrents
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
|
<Card as={LinkBox} alignItems="center" p="4" gap="4" minW="40">
|
||||||
|
<Image src="https://satellite.earth/image.png" w="10" h="10" />
|
||||||
|
<CardHeader p="0">
|
||||||
|
<Heading size="md">
|
||||||
|
<HoverLinkOverlay as={RouterLink} to="/tools/satellite-cdn">
|
||||||
|
Satellite CDN
|
||||||
|
</HoverLinkOverlay>
|
||||||
|
</Heading>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
<InternalLink to="/tools/network" icon={Users01}>
|
<InternalLink to="/tools/network" icon={Users01}>
|
||||||
User Network
|
User Network
|
||||||
</InternalLink>
|
</InternalLink>
|
||||||
|
33
src/views/tools/satellite-cdn/delete-file-button.tsx
Normal file
33
src/views/tools/satellite-cdn/delete-file-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { IconButton, useToast } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import { SatelliteCDNFile, deleteFile } from "../../../helpers/satellite-cdn";
|
||||||
|
import { useSigningContext } from "../../../providers/signing-provider";
|
||||||
|
import { TrashIcon } from "../../../components/icons";
|
||||||
|
|
||||||
|
export default function FileDeleteButton({ file }: { file: SatelliteCDNFile }) {
|
||||||
|
const toast = useToast();
|
||||||
|
const { requestSignature } = useSigningContext();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const handleClick = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await deleteFile(file.sha256, requestSignature);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [requestSignature, file]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
icon={<TrashIcon />}
|
||||||
|
aria-label="Delete File"
|
||||||
|
title="Delete File"
|
||||||
|
colorScheme="red"
|
||||||
|
onClick={handleClick}
|
||||||
|
isLoading={loading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
194
src/views/tools/satellite-cdn/index.tsx
Normal file
194
src/views/tools/satellite-cdn/index.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Flex,
|
||||||
|
Heading,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Link,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
Tbody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
Thead,
|
||||||
|
Tr,
|
||||||
|
useToast,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { useAsync } from "react-use";
|
||||||
|
|
||||||
|
import VerticalPageLayout from "../../../components/vertical-page-layout";
|
||||||
|
import { useSigningContext } from "../../../providers/signing-provider";
|
||||||
|
import { NostrEvent } from "../../../types/nostr-event";
|
||||||
|
import { formatBytes } from "../../../helpers/number";
|
||||||
|
import { CopyIconButton } from "../../../components/copy-icon-button";
|
||||||
|
import Timestamp from "../../../components/timestamp";
|
||||||
|
import { SatelliteCDNFile, getAccount, getAccountAuthToken, uploadFile } from "../../../helpers/satellite-cdn";
|
||||||
|
import FileDeleteButton from "./delete-file-button";
|
||||||
|
import { matchSorter } from "match-sorter";
|
||||||
|
|
||||||
|
function FileUploadButton() {
|
||||||
|
const toast = useToast();
|
||||||
|
const { requestSignature } = useSigningContext();
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
async (file: File) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const upload = await uploadFile(file, requestSignature);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
[requestSignature],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
ref={inputRef}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.[0]) handleInputChange(e.target.files[0]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button colorScheme="primary" onClick={() => inputRef.current?.click()} isLoading={loading}>
|
||||||
|
Upload File
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileRow({ file }: { file: SatelliteCDNFile }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tr>
|
||||||
|
<Td>
|
||||||
|
<Link isExternal href={file.url}>
|
||||||
|
{file.name}
|
||||||
|
</Link>
|
||||||
|
<CopyIconButton text={file.url} aria-label="Copy URL" title="Copy URL" size="xs" variant="ghost" ml="2" />
|
||||||
|
</Td>
|
||||||
|
<Td isNumeric>{formatBytes(file.size)}</Td>
|
||||||
|
<Td>{file.type}</Td>
|
||||||
|
<Td>
|
||||||
|
<Timestamp timestamp={file.created} />
|
||||||
|
</Td>
|
||||||
|
<Td isNumeric>
|
||||||
|
<ButtonGroup size="sm" variant="ghost">
|
||||||
|
<FileDeleteButton file={file} />
|
||||||
|
</ButtonGroup>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilesTable({ files }: { files: SatelliteCDNFile[] }) {
|
||||||
|
return (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="sm">
|
||||||
|
<Thead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th isNumeric>Size</Th>
|
||||||
|
<Th>Type</Th>
|
||||||
|
<Th>Created</Th>
|
||||||
|
<Th isNumeric />
|
||||||
|
</Tr>
|
||||||
|
</Thead>
|
||||||
|
<Tbody>
|
||||||
|
{files.map((file) => (
|
||||||
|
<FileRow key={file.sha256} file={file} />
|
||||||
|
))}
|
||||||
|
</Tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SatelliteCDNView() {
|
||||||
|
const toast = useToast();
|
||||||
|
const { requestSignature } = useSigningContext();
|
||||||
|
|
||||||
|
const [authToken, setAuthToken] = useState<NostrEvent>();
|
||||||
|
|
||||||
|
const { value: account, loading: isLoadingAccount } = useAsync(
|
||||||
|
async () => authToken && (await getAccount(authToken)),
|
||||||
|
[authToken],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const handleAuthClick = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setAuthToken(await getAccountAuthToken(requestSignature));
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error) toast({ description: e.message, status: "error" });
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [requestSignature]);
|
||||||
|
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (!account)
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={handleAuthClick}
|
||||||
|
mx="auto"
|
||||||
|
px="10"
|
||||||
|
my="8"
|
||||||
|
colorScheme="primary"
|
||||||
|
isLoading={loading || isLoadingAccount}
|
||||||
|
>
|
||||||
|
Unlock Account
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const filteredFiles = account.files.filter((f) =>
|
||||||
|
f.name.toLocaleLowerCase().includes(search.toLocaleLowerCase().trim()),
|
||||||
|
);
|
||||||
|
const sortedFiles = matchSorter(filteredFiles, search.toLocaleLowerCase().trim(), { keys: ["name"] });
|
||||||
|
return <FilesTable files={sortedFiles} />;
|
||||||
|
} else return <FilesTable files={account.files} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VerticalPageLayout>
|
||||||
|
<Flex gap="2" alignItems="center" wrap="wrap">
|
||||||
|
<Image src="https://satellite.earth/image.png" w="12" />
|
||||||
|
<Heading>Satellite CDN</Heading>
|
||||||
|
<ButtonGroup ml="auto">
|
||||||
|
<Button
|
||||||
|
as={Link}
|
||||||
|
href="https://github.com/lovvtide/satellite-web/blob/master/docs/cdn.md"
|
||||||
|
isExternal
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
|
View Docs
|
||||||
|
</Button>
|
||||||
|
<FileUploadButton />
|
||||||
|
</ButtonGroup>
|
||||||
|
</Flex>
|
||||||
|
{account && (
|
||||||
|
<Flex gap="2">
|
||||||
|
<Input
|
||||||
|
type="Search"
|
||||||
|
placeholder="Search files"
|
||||||
|
w={{ base: "full", md: "md" }}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
{renderContent()}
|
||||||
|
</VerticalPageLayout>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user