Add Simple Satellite CDN view

This commit is contained in:
hzrd149 2023-12-24 11:51:51 -06:00
parent f4ba4fddbc
commit ad53ed15f5
6 changed files with 330 additions and 0 deletions

View File

@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add Simple Satellite CDN view

View File

@ -77,6 +77,7 @@ import LoginNostrConnectView from "./views/signin/nostr-connect";
import ThreadsNotificationsView from "./views/notifications/threads";
import DVMFeedView from "./views/dvm-feed/feed";
import TransformNoteView from "./views/tools/transform-note";
import SatelliteCDNView from "./views/tools/satellite-cdn";
const UserTracksTab = lazy(() => import("./views/user/tracks"));
const ToolsHomeView = lazy(() => import("./views/tools"));
@ -264,6 +265,7 @@ const router = createHashRouter([
{ path: "network-dm-graph", element: <NetworkDMGraphView /> },
{ path: "dm-feed", element: <DMFeedView /> },
{ path: "transform/:id", element: <TransformNoteView /> },
{ path: "satellite-cdn", element: <SatelliteCDNView /> },
],
},
{

View 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>;
}

View File

@ -7,6 +7,7 @@ import ShieldOff from "../../components/icons/shield-off";
import HoverLinkOverlay from "../../components/hover-link-overlay";
import Users01 from "../../components/icons/users-01";
import Magnet from "../../components/icons/magnet";
import Moon01 from "../../components/icons/moon-01";
function InternalLink({
to,
@ -56,6 +57,16 @@ export default function ToolsHomeView() {
<InternalLink to="/torrents" icon={Magnet}>
Torrents
</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}>
User Network
</InternalLink>

View 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}
/>
);
}

View 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>
);
}