Compare commits

..

No commits in common. "c4d6c154f7ac61e4f98d6837e96aee2a41834da7" and "0c715ea501c21d2acd49064872b041e3a33386fa" have entirely different histories.

30 changed files with 752 additions and 1845 deletions

View File

@ -26,7 +26,7 @@ steps:
- yarn install
- yarn build
- name: build docker image
image: docker
image: r.j3ss.co/img
privileged: true
volumes:
- name: cache
@ -35,11 +35,9 @@ steps:
TOKEN:
from_secret: docker_hub
commands:
- dockerd &
- docker login -u voidic -p $TOKEN
- docker buildx create --name mybuilder --bootstrap --use
- docker buildx build --push -t voidic/dtan:latest --platform linux/amd64,linux/arm64 .
- kill $(cat /var/run/docker.pid)
- img login -u voidic -p $TOKEN
- img build -t voidic/dtan:latest --platform linux/amd64,linux/arm64 .
- img push voidic/dtan:latest
volumes:
- name: cache
claim:

View File

@ -1,30 +0,0 @@
name: Deploy nsite
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: "yarn"
- name: Install Dependencies
run: yarn install
- name: Build
run: yarn build
- name: Redirect 404 to Index for SPA
run: cp dist/index.html dist/404.html
- name: Deploy nsite
run: npx -y nsite-cli upload dist --verbose --purge --privatekey ${{ secrets.NSITE_KEY }}

0
.yarn/releases/yarn-4.1.1.cjs vendored Executable file → Normal file
View File

View File

@ -1,36 +1,21 @@
# DTAN
**D**istributed **T**orrent **A**rchive on **N**ostr
This is a [NIP-35](https://github.com/nostr-protocol/nips/blob/master/35.md) client.
NIP-35 defines the event format as:
```json
{
"kind": 2003,
"content": "<long-description-pre-formatted>",
"tags": [
["title", "<torrent-title>"],
["x", "<bittorrent-info-hash>"],
["file", "<file-name>", "<file-size-in-bytes>"],
["file", "<file-name>", "<file-size-in-bytes>"],
["tracker", "udp://mytacker.com:1337"],
["tracker", "http://1337-tracker.net/announce"],
["i", "tcat:video,movie,4k"],
["i", "newznab:2045"],
["i", "imdb:tt15239678"],
["i", "tmdb:movie:693134"],
["i", "ttvdb:movie:290272"],
["t", "movie"],
["t", "4k"]
]
}
{
"kind": 2003,
"content": "<long-description-pre-formatted>",
"tags": [
["title", "<torrent-title>"],
["btih", "<bittorrent-info-hash>"],
["t", "<top-level-tag>"],
["t", "(optional)<second-level-tag>"],
["t", ...other tags],
["file", "<file-name>", "<file-size-in-bytes>"],
["file", "<file-name>", "<file-size-in-bytes>"],
["file", ...other files],
["imdb", "(optional)<imdb-entry-id>"]
]
}
```
## Future Plans
Top Level tags are usually the type of content like `"Video"` / `"Audio"` / `"Application"` and so on.
### Custom scraper relay
A relay which automatically clones torrent events from other relays.
Simple docker image which can easily be deployed anywhere and will add another replica of torrent metadata.
Second level tags are sub categories like `"Movies"` / `"CAD/CAM"`

View File

@ -11,16 +11,15 @@
"deploy": "yarn build && yarn dlx wrangler pages deploy dist"
},
"dependencies": {
"@noble/hashes": "^1.4.0",
"@snort/shared": "^1.0.17",
"@snort/system": "^1.6.1",
"@snort/system-react": "^1.6.1",
"@snort/system-wasm": "^1.0.5",
"@snort/worker-relay": "^1.3.1",
"@noble/hashes": "^1.3.2",
"@snort/shared": "^1.0.14",
"@snort/system": "^1.2.12",
"@snort/system-react": "^1.2.12",
"@snort/worker-relay": "^1.0.9",
"classnames": "^2.3.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.37",
@ -34,10 +33,10 @@
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"vite-plugin-pwa": "^0.20.0"
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.1.6",
"vite-plugin-pwa": "^0.19.4"
},
"packageManager": "yarn@4.1.1",
"prettier": {

View File

@ -1,7 +1,7 @@
import classNames from "classnames";
import { HTMLProps, forwardRef, useState } from "react";
type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick" | "small"> & {
type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick"> & {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void;
type: "primary" | "secondary" | "danger";
small?: boolean;
@ -34,7 +34,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
{...props}
type="button"
className={classNames(
props.small ? "px-3 py-1 rounded-2xl" : "px-4 py-3 rounded-xl ",
props.small ? "px-3 py-1 rounded-2xl" : "px-4 py-3 rounded-full ",
"flex gap-1 items-center justify-center whitespace-nowrap",
colorScheme,
props.className,

View File

@ -1,92 +0,0 @@
import { useMemo } from "react";
import { NostrTorrent } from "../nostr-torrent";
import FolderIcon from "./icon/folder";
import FileIcon from "./icon/file-icon";
import { FormatBytes } from "../const";
interface NodeTree {
isDir: boolean;
name: string;
size: number;
children: NodeTree[];
}
export default function TorrentFileList({ torrent }: { torrent: NostrTorrent }) {
const tree = useMemo(() => {
const ret = {
isDir: true,
name: "/",
size: 0,
children: [],
} as NodeTree;
function addAndRecurse(a: { paths: string[]; size: number }, atNode: NodeTree) {
if (a.paths.length > 1) {
const newdir = a.paths.shift()!;
let existingNode = atNode.children.find((a) => a.name === newdir);
if (!existingNode) {
existingNode = {
isDir: true,
name: newdir,
size: 0,
children: [],
};
atNode.children.push(existingNode);
}
addAndRecurse(a, existingNode);
} else {
atNode.children.push({
isDir: false,
name: a.paths[0],
size: a.size,
children: [],
});
}
}
const split = torrent.files
.map((a) => ({
size: a.size,
paths: a.name.split("/"),
}))
.sort((a, b) => a.paths.length - b.paths.length);
split.forEach((a) => addAndRecurse(a, ret));
return ret;
}, [torrent]);
function renderNode(n: NodeTree): React.ReactNode {
if (n.isDir && n.name === "/") {
// skip first node and just render children
return <>{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}</>;
} else if (n.isDir) {
return (
<>
<div
className="pl-1 flex gap-2 cursor-pointer"
onClick={(e) => {
// lazy stateless toggle
e.currentTarget.nextElementSibling?.classList.toggle("hidden");
}}
>
<FolderIcon />
{n.name}
</div>
<div className="pl-4 hidden">{n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}</div>
</>
);
} else {
return (
<div className="pl-1 flex justify-between items-center" key={n.name}>
<div className="flex gap-2">
<FileIcon />
{n.name}
</div>
<div>{FormatBytes(n.size)}</div>
</div>
);
}
}
return <div className="flex flex-col gap-1">{renderNode(tree)}</div>;
}

View File

@ -1,13 +0,0 @@
export default function CopyIcon() {
return (
<svg width={20} height={20} viewBox="0 0 20 20" fill="none">
<path
d="M13.3333 13.3327V15.666C13.3333 16.5994 13.3333 17.0661 13.1516 17.4227C12.9918 17.7363 12.7369 17.9912 12.4233 18.151C12.0668 18.3327 11.6 18.3327 10.6666 18.3327H4.33329C3.39987 18.3327 2.93316 18.3327 2.57664 18.151C2.26304 17.9912 2.00807 17.7363 1.84828 17.4227C1.66663 17.0661 1.66663 16.5994 1.66663 15.666V9.33268C1.66663 8.39926 1.66663 7.93255 1.84828 7.57603C2.00807 7.26243 2.26304 7.00746 2.57664 6.84767C2.93316 6.66602 3.39987 6.66602 4.33329 6.66602H6.66663M9.33329 13.3327H15.6666C16.6 13.3327 17.0668 13.3327 17.4233 13.151C17.7369 12.9912 17.9918 12.7363 18.1516 12.4227C18.3333 12.0661 18.3333 11.5994 18.3333 10.666V4.33268C18.3333 3.39926 18.3333 2.93255 18.1516 2.57603C17.9918 2.26243 17.7369 2.00746 17.4233 1.84767C17.0668 1.66602 16.6 1.66602 15.6666 1.66602H9.33329C8.39987 1.66602 7.93316 1.66602 7.57664 1.84767C7.26304 2.00746 7.00807 2.26243 6.84828 2.57603C6.66663 2.93255 6.66663 3.39926 6.66663 4.33268V10.666C6.66663 11.5994 6.66663 12.0661 6.84828 12.4227C7.00807 12.7363 7.26304 12.9912 7.57664 13.151C7.93316 13.3327 8.39987 13.3327 9.33329 13.3327Z"
stroke="currentColor"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -1,13 +0,0 @@
export default function FileIcon() {
return (
<svg width={18} height={22} viewBox="0 0 18 22">
<path
d="M11 1.26946V5.4C11 5.96005 11 6.24008 11.109 6.45399C11.2049 6.64215 11.3578 6.79513 11.546 6.89101C11.7599 7 12.0399 7 12.6 7H16.7305M17 8.98822V16.2C17 17.8802 17 18.7202 16.673 19.362C16.3854 19.9265 15.9265 20.3854 15.362 20.673C14.7202 21 13.8802 21 12.2 21H5.8C4.11984 21 3.27976 21 2.63803 20.673C2.07354 20.3854 1.6146 19.9265 1.32698 19.362C1 18.7202 1 17.8802 1 16.2V5.8C1 4.11984 1 3.27976 1.32698 2.63803C1.6146 2.07354 2.07354 1.6146 2.63803 1.32698C3.27976 1 4.11984 1 5.8 1H9.01178C9.74555 1 10.1124 1 10.4577 1.08289C10.7638 1.15638 11.0564 1.27759 11.3249 1.44208C11.6276 1.6276 11.887 1.88703 12.4059 2.40589L15.5941 5.59411C16.113 6.11297 16.3724 6.3724 16.5579 6.67515C16.7224 6.94356 16.8436 7.2362 16.9171 7.5423C17 7.88757 17 8.25445 17 8.98822Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -1,13 +0,0 @@
export default function FolderIcon() {
return (
<svg width={22} height={22} viewBox="0 0 22 20">
<path
d="M12 5L10.8845 2.76892C10.5634 2.1268 10.4029 1.80573 10.1634 1.57116C9.95158 1.36373 9.69632 1.20597 9.41607 1.10931C9.09916 1 8.74021 1 8.02229 1H4.2C3.0799 1 2.51984 1 2.09202 1.21799C1.71569 1.40973 1.40973 1.71569 1.21799 2.09202C1 2.51984 1 3.0799 1 4.2V5M1 5H16.2C17.8802 5 18.7202 5 19.362 5.32698C19.9265 5.6146 20.3854 6.07354 20.673 6.63803C21 7.27976 21 8.11984 21 9.8V14.2C21 15.8802 21 16.7202 20.673 17.362C20.3854 17.9265 19.9265 18.3854 19.362 18.673C18.7202 19 17.8802 19 16.2 19H5.8C4.11984 19 3.27976 19 2.63803 18.673C2.07354 18.3854 1.6146 17.9265 1.32698 17.362C1 16.7202 1 15.8802 1 14.2V5Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -1,15 +0,0 @@
export default function MagnetIcon({ size }: { size?: number }) {
return (
<svg width={size ?? 20} height={size ?? 20} viewBox="0 0 64 64" fill="currentColor">
<path
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
C59.5,21,59.9,34,52.2,41.7z"
/>
</svg>
);
}

47
src/element/magnet.tsx Normal file
View File

@ -0,0 +1,47 @@
import { TaggedNostrEvent } from "@snort/system";
import { Trackers } from "../const";
import { Link, LinkProps } from "react-router-dom";
type MagnetLinkProps = Omit<LinkProps, "to"> & {
item: TaggedNostrEvent;
size?: number;
};
export function MagnetLink({ item, size, ...props }: MagnetLinkProps) {
const btih = item.tags.find((a) => a[0] === "btih")?.at(1);
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const magnet = {
xt: `urn:btih:${btih}`,
dn: name,
tr: Trackers,
};
const params = Object.entries(magnet)
.map(([k, v]) => {
if (Array.isArray(v)) {
return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&");
} else {
return `${k}=${v as string}`;
}
})
.flat()
.join("&");
const link = `magnet:?${params}`;
return (
<Link {...props} to={link}>
<svg width={size ?? 20} height={size ?? 20} version="1.1" viewBox="0 0 64 64" fill="currentColor">
<path
d="M54.5,9.5c-4.9-5-11.4-7.8-18.3-7.8c-6.5,0-12.6,2.4-17.2,7L3.6,24.3c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9c2.5,2.5,6.6,2.5,9.1,0
l14.5-14.4c1.8-1.8,4.6-2.1,6.3-0.7c0.9,0.7,1.4,1.8,1.5,3c0.1,1.4-0.5,2.7-1.5,3.7L24.9,45.4c-2.5,2.5-2.5,6.6,0,9.1l5.9,5.9
c1.2,1.2,2.9,1.9,4.5,1.9c1.6,0,3.3-0.6,4.5-1.9l15.5-15.5C64.8,35.4,64.5,19.6,54.5,9.5z M15.4,36c-0.7,0.7-2,0.7-2.7,0l-5.9-5.9
c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6L15.4,36z M36.6,57.2c-0.7,0.7-2,0.7-2.7,0L28,51.3c-0.7-0.7-0.7-2,0-2.7l5.1-5.1l8.6,8.6
L36.6,57.2z M52.2,41.7L45,48.9l-8.6-8.6l6.3-6.3c1.9-1.9,2.9-4.5,2.8-7.1c-0.1-2.5-1.3-4.7-3.2-6.3c-1.6-1.3-3.5-1.9-5.5-1.9
c-2.5,0-5,1-6.9,2.9l-6.1,6.1l-8.6-8.6l7.2-7.2c3.7-3.6,8.6-5.7,13.9-5.7c0,0,0.1,0,0.1,0c5.7,0,11,2.3,15.1,6.5
C59.5,21,59.9,34,52.2,41.7z"
/>
</svg>
{props.children}
</Link>
);
}

View File

@ -11,13 +11,8 @@ type ProfileImageProps = HTMLProps<HTMLDivElement> & {
export function ProfileImage({ pubkey, size, withName, children, ...props }: ProfileImageProps) {
const profile = useUserProfile(pubkey);
const url =
(profile?.picture?.length ?? 0) > 0
? profile?.picture
: `https://nostr.api.v0l.io/api/v1/avatar/cyberpunks/${pubkey}`;
const v = {
backgroundImage: `url(${url})`,
backgroundImage: `url(${profile?.picture})`,
} as CSSProperties;
if (size) {
v.width = `${size}px`;

View File

@ -15,6 +15,7 @@ export function Search(params: { term?: string; tags?: Array<string> }) {
<input
type="text"
placeholder="Search..."
className="px-4 py-3 bg-neutral-800 rounded-full w-full focus-visible:outline-none"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => {

View File

@ -2,10 +2,9 @@ import "./torrent-list.css";
import { NostrLink, NostrPrefix, TaggedNostrEvent } from "@snort/system";
import { FormatBytes } from "../const";
import { Link } from "react-router-dom";
import { MagnetLink } from "./magnet";
import { Mention } from "./mention";
import { useMemo } from "react";
import { NostrTorrent } from "../nostr-torrent";
import MagnetIcon from "./icon/magnet";
export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
return (
@ -29,44 +28,58 @@ export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
);
}
function TagList({ torrent }: { torrent: NostrTorrent }) {
return torrent.categoryPath
function TagList({ tags }: { tags: string[][] }) {
return tags
.filter((a) => a[0] === "t")
.slice(0, 3)
.map((current, index, allTags) => <TagListEntry key={current} tags={allTags} startIndex={index} tag={current} />);
.map((current, index, allTags) => (
<TagListEntry key={current[1]} tags={allTags} startIndex={index} tag={current} />
));
}
function TagListEntry({ tags, startIndex, tag }: { tags: string[]; startIndex: number; tag: string }) {
function TagListEntry({ tags, startIndex, tag }: { tags: string[][]; startIndex: number; tag: string[] }) {
const tagUrl = useMemo(() => {
return encodeURIComponent(tags.slice(0, startIndex + 1).join(","));
return encodeURIComponent(
tags
.slice(0, startIndex + 1)
.map((b) => b[1])
.join(","),
);
}, [tags, startIndex]);
return (
<>
<Link to={`/search/?tags=${tagUrl}`}>{tag}</Link>
<Link to={`/search/?tags=${tagUrl}`}>{tag[1]}</Link>
{tags.length !== startIndex + 1 && " > "}
</>
);
}
function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
const torrent = NostrTorrent.fromEvent(item);
const { name, size } = useMemo(() => {
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const size = item.tags
.filter((a) => a[0] === "file")
.map((a) => Number(a[2]))
.reduce((acc, v) => (acc += v), 0);
return { name, size };
}, [item]);
return (
<tr className="hover:bg-indigo-800">
<td className="text-indigo-300">
<TagList torrent={torrent} />
<TagList tags={item.tags} />
</td>
<td className="break-words">
<Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}>
{torrent.title}
{name}
</Link>
</td>
<td className="text-neutral-300">{new Date(torrent.publishedAt * 1000).toLocaleDateString()}</td>
<td className="text-neutral-300">{new Date(item.created_at * 1000).toLocaleDateString()}</td>
<td>
<Link to={torrent.magnetLink}>
<MagnetIcon />
</Link>
<MagnetLink item={item} />
</td>
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(torrent.totalSize)}</td>
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(size)}</td>
<td className="text-indigo-300 whitespace-nowrap break-words text-ellipsis">
<Mention link={new NostrLink(NostrPrefix.PublicKey, item.pubkey)} />
</td>

View File

@ -19,11 +19,9 @@ body {
h1 {
font-size: 32px;
}
h2 {
font-size: 28px;
}
h3 {
font-size: 21px;
}
@ -36,21 +34,7 @@ a:not([href="/"], :has(button)) {
white-space-collapse: preserve-breaks;
}
input[type="radio"] {
border: 0px;
clip: rect(0px, 0px, 0px, 0px);
height: 1px;
width: 1px;
margin: -1px;
padding: 0px;
overflow: hidden;
white-space: nowrap;
position: absolute;
cursor: pointer;
}
input[type="text"],
input[type="number"],
input[type="password"] {
@apply px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none;
.file-list {
font-size: 15px;
font-weight: 400;
}

View File

@ -1,27 +1,19 @@
import { ExternalStore } from "@snort/shared";
import { EventPublisher, Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
import { EventPublisher, Nip7Signer } from "@snort/system";
import { SnortContext } from "@snort/system-react";
import { useContext, useSyncExternalStore } from "react";
export interface LoginSession {
type: "nip7" | "nsec" | "nip46";
publicKey: string;
privateKey?: string;
bunker?: string;
}
class LoginStore extends ExternalStore<LoginSession | undefined> {
#session?: LoginSession;
#signer?: EventPublisher;
constructor() {
super();
const s = window.localStorage.getItem("session");
if (s) {
this.#session = JSON.parse(s);
// patch session
if (this.#session) {
this.#session.type ??= "nip7";
}
}
}
@ -29,70 +21,15 @@ class LoginStore extends ExternalStore<LoginSession | undefined> {
return this.#session ? { ...this.#session } : undefined;
}
logout() {
this.#session = undefined;
this.#signer = undefined;
this.#save();
}
login(pubkey: string, type: LoginSession["type"] = "nip7") {
login(pubkey: string) {
this.#session = {
type: type ?? "nip7",
publicKey: pubkey,
};
this.#save();
}
loginPrivateKey(key: string) {
const s = new PrivateKeySigner(key);
this.#session = {
type: "nsec",
publicKey: s.getPubKey(),
privateKey: key,
};
this.#save();
}
loginBunker(url: string, localKey: string, remotePubkey: string) {
this.#session = {
type: "nip46",
publicKey: remotePubkey,
privateKey: localKey,
bunker: url,
};
this.#save();
}
getSigner() {
if (!this.#signer && this.#session) {
switch (this.#session.type) {
case "nsec":
this.#signer = new EventPublisher(new PrivateKeySigner(this.#session.privateKey!), this.#session.publicKey);
break;
case "nip46":
this.#signer = new EventPublisher(
new Nip46Signer(this.#session.bunker!, new PrivateKeySigner(this.#session.privateKey!)),
this.#session.publicKey,
);
break;
case "nip7":
this.#signer = new EventPublisher(new Nip7Signer(), this.#session.publicKey);
break;
}
}
if (this.#signer) {
return this.#signer;
}
throw "Signer not setup!";
}
#save() {
if (this.#session) {
window.localStorage.setItem("session", JSON.stringify(this.#session));
} else {
window.localStorage.removeItem("session");
}
window.localStorage.setItem("session", JSON.stringify(this.#session));
this.notifyChange();
}
}
@ -107,10 +44,9 @@ export function useLogin() {
const system = useContext(SnortContext);
return session
? {
type: session.type,
publicKey: session.publicKey,
builder: LoginState.getSigner(),
system,
}
...session,
builder: new EventPublisher(new Nip7Signer(), session.publicKey),
system,
}
: undefined;
}

View File

@ -12,10 +12,8 @@ import { NewPage } from "./page/new";
import { TorrentPage } from "./page/torrent";
import { SearchPage } from "./page/search";
import { System, initSystem } from "./system";
import { RelaysPage } from "./page/relays";
import LoginPage from "./page/login";
const routes = [
const Routes = [
{
element: <Layout />,
loader: async () => {
@ -43,23 +41,15 @@ const routes = [
path: "/search/:term?",
element: <SearchPage />,
},
{
path: "/relays",
element: <RelaysPage />,
},
{
path: "/login",
element: <LoginPage />,
},
],
},
] as Array<RouteObject>;
const router = createBrowserRouter(routes);
const Router = createBrowserRouter(Routes);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<SnortContext.Provider value={System}>
<RouterProvider router={router} />
<RouterProvider router={Router} />
</SnortContext.Provider>
</React.StrictMode>,
);

View File

@ -1,230 +0,0 @@
import { unixNow } from "@snort/shared";
import { EventExt, NostrEvent, NotSignedNostrEvent } from "@snort/system";
import { Trackers } from "./const";
export interface TorrentFile {
readonly name: string;
readonly size: number;
}
export interface TorrentTag {
readonly type: "tcat" | "newznab" | "tmdb" | "ttvdb" | "imdb" | "mal" | "anilist" | "generic";
readonly value: string;
}
export class NostrTorrent {
constructor(
readonly id: string | undefined,
readonly title: string,
readonly summary: string,
readonly infoHash: string,
readonly publishedAt: number,
readonly files: Array<TorrentFile>,
readonly trackers: Array<string>,
readonly tags: Array<TorrentTag>,
) {}
get newznab() {
return this.#getTagValue("newznab");
}
get imdb() {
return this.#getTagValue("imdb");
}
get tmdb() {
return this.#getTagValue("tmdb");
}
get ttvdb() {
return this.#getTagValue("ttvdb");
}
get mal() {
return this.#getTagValue("mal");
}
get anilist() {
return this.#getTagValue("anilist");
}
get totalSize() {
return this.files.reduce((acc, v) => acc + v.size, 0);
}
/**
* Get the category path ie. video->movie->hd
*/
get categoryPath() {
const tcat = this.#getTagValue("tcat");
if (tcat) {
return tcat.split(",");
} else {
// v0: ordered tags before tcat proposal
const regularTags = this.tags.filter((a) => a.type === "generic").slice(0, 3);
return regularTags.map((a) => a.value);
}
}
get tcat() {
return this.categoryPath.join(",");
}
get magnetLink() {
const magnet = {
xt: `urn:btih:${this.infoHash}`,
dn: this.title,
tr: this.trackers,
};
// use fallback tracker list if empty
if (magnet.tr.length === 0) {
magnet.tr.push(...Trackers);
}
const params = Object.entries(magnet)
.map(([k, v]) => {
if (Array.isArray(v)) {
return v.map((a) => `${k}=${encodeURIComponent(a)}`).join("&");
} else {
return `${k}=${v as string}`;
}
})
.flat()
.filter((a) => a.length > 0)
.join("&");
return `magnet:?${params}`;
}
/**
* Get the URL for a non-generic external reference tag ("i" tag)
*/
static externalDbLink(tag: TorrentTag) {
const ts = tag.value.split(":");
switch (tag.type) {
case "imdb":
return `https://www.imdb.com/title/${tag.value}/`;
case "tmdb": {
if (ts.length === 2) {
return `https://www.themoviedb.org/${ts[0]}/${ts[1]}`;
}
break;
}
case "mal": {
if (ts.length === 2) {
return `https://myanimelist.net/${ts[0]}/${ts[1]}`;
}
break;
}
case "anilist": {
if (ts.length === 2) {
return `https://anilist.co/${ts[0]}/${ts[1]}`;
}
break;
}
}
}
/**
* Get the nostr event for this torrent
*/
toEvent(pubkey?: string) {
const ret = {
id: this.id,
kind: 2003,
content: this.summary,
created_at: unixNow(),
pubkey: pubkey ?? "",
tags: [
["title", this.title],
["x", this.infoHash],
],
} as NotSignedNostrEvent;
for (const file of this.files) {
ret.tags.push(["file", file.name, String(file.size)]);
}
for (const tracker of this.trackers) {
ret.tags.push(["tracker", tracker]);
}
for (const tag of this.tags.filter((a) => a.type === "generic")) {
ret.tags.push(["t", tag.value]);
}
for (const tag of this.tags.filter((a) => a.type !== "generic")) {
ret.tags.push(["i", `${tag.type}:${tag.value}`]);
}
if (ret.id === undefined) {
ret.id = EventExt.createId(ret);
}
return ret;
}
#getTagValue(t: TorrentTag["type"]) {
const tag = this.tags.find((a) => a.type === t);
return tag?.value;
}
static fromEvent(ev: NostrEvent) {
let infoHash = "";
let title = "";
const files: Array<TorrentFile> = [];
const trackers: Array<string> = [];
const tags: Array<TorrentTag> = [];
for (const t of ev.tags) {
const key = t[0];
if (!t[1]) continue;
switch (key) {
case "title": {
title = t[1];
break;
}
// v0: btih tag
case "btih":
case "x": {
infoHash = t[1];
break;
}
case "file": {
files.push({
name: t[1],
size: Number(t[2]),
});
break;
}
case "tracker": {
trackers.push(t[1]);
break;
}
case "i": {
const kSplit = t[1].split(":", 2);
if (kSplit.length === 2) {
tags.push({
type: kSplit[0],
value: kSplit[1],
} as TorrentTag);
}
break;
}
case "t": {
tags.push({
type: "generic",
value: t[1],
} as TorrentTag);
break;
}
// v0: imdb tag
case "imdb": {
tags.push({
type: "imdb",
value: t[1],
});
break;
}
}
}
return new NostrTorrent(ev.id, title, ev.content, infoHash, ev.created_at, files, trackers, tags);
}
}

View File

@ -1,57 +1,35 @@
import { Link, Outlet, useNavigate } from "react-router-dom";
import { Link, Outlet } from "react-router-dom";
import { Button } from "../element/button";
import { LoginSession, useLogin } from "../login";
import { LoginSession, LoginState, useLogin } from "../login";
import { ProfileImage } from "../element/profile-image";
import { Search } from "../element/search";
import { useRelays } from "../relays";
import { useContext, useEffect } from "react";
import { SnortContext } from "@snort/system-react";
import { RelaySettings, SystemInterface } from "@snort/system";
export function Layout() {
const login = useLogin();
const system = useContext(SnortContext);
const { relays } = useRelays();
const navigate = useNavigate();
async function updateRelayConnections(system: SystemInterface, relays: Record<string, RelaySettings>) {
if (import.meta.env.VITE_SINGLE_RELAY) {
system.ConnectToRelay(import.meta.env.VITE_SINGLE_RELAY, { read: true, write: true });
} else {
for (const [k, v] of Object.entries(relays)) {
// note: don't awit this, causes race condition with sending requests to relays
system.ConnectToRelay(k, v);
}
for (const [k, v] of system.pool) {
if (!relays[k] && !v.ephemeral) {
system.DisconnectRelay(k);
}
async function DoLogin() {
if ("nostr" in window) {
const pubkey = await window.nostr?.getPublicKey();
if (pubkey) {
LoginState.login(pubkey);
}
}
}
useEffect(() => {
updateRelayConnections(system, Object.fromEntries(relays.map((a) => [a, { read: true, write: true }])));
}, [system, relays]);
return (
<div className="container mx-auto">
<header className="flex gap-4 items-center pt-4 pb-6">
<header className="flex justify-between items-center pt-4 pb-6">
<Link to={"/"} className="flex gap-2 items-center">
<img src="/logo_256.jpg" className="rounded-full" height={40} width={40} />
<h1 className="font-bold uppercase">dtan.xyz</h1>
</Link>
<div className="w-1/3">
<div className="w-1/2">
<Search />
</div>
<div className="grow"></div>
<Link to="/relays">
<Button type="secondary">Relays</Button>
</Link>
{login ? (
<LoggedInHeader login={login} />
) : (
<Button type="primary" onClick={() => navigate("/login")}>
<Button type="primary" onClick={DoLogin}>
Login
</Button>
)}

View File

@ -1,68 +0,0 @@
import { Nip46Signer, Nip7Signer, PrivateKeySigner } from "@snort/system";
import { Button } from "../element/button";
import { LoginState } from "../login";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { bech32ToHex } from "@snort/shared";
export default function LoginPage() {
const [key, setKey] = useState("");
const navigate = useNavigate();
return (
<>
<div className="flex flex-col gap-8">
<h2>Login</h2>
<hr />
<div className="flex flex-col gap-4">
<input type="password" placeholder="nsec/bunker" value={key} onChange={(e) => setKey(e.target.value)} />
<Button
type="primary"
onClick={async () => {
if (key.startsWith("nsec1")) {
LoginState.loginPrivateKey(bech32ToHex(key));
navigate("/");
} else if (key.startsWith("bunker://")) {
const signer = new Nip46Signer(key);
await signer.init();
const pubkey = await signer.getPubKey();
LoginState.loginBunker(key, signer.privateKey!, pubkey);
navigate("/");
}
}}
>
Login
</Button>
</div>
{window.nostr && (
<div className="flex flex-col gap-4">
Browser Extension:
<Button
type="primary"
onClick={async () => {
const pk = await new Nip7Signer().getPubKey();
LoginState.login(pk);
navigate("/");
}}
>
Nostr Extension
</Button>
</div>
)}
<h2>Create Account</h2>
<hr />
<Button
type="primary"
onClick={async () => {
const s = PrivateKeySigner.random();
LoginState.loginPrivateKey(s.privateKey);
navigate("/");
}}
>
Generate Account
</Button>
</div>
</>
);
}

View File

@ -1,3 +1,16 @@
label.category input {
border: 0px;
clip: rect(0px, 0px, 0px, 0px);
height: 1px;
width: 1px;
margin: -1px;
padding: 0px;
overflow: hidden;
white-space: nowrap;
position: absolute;
cursor: pointer;
}
label.category div {
background-color: rgba(0, 0, 0, 0.5);
padding: 4px 10px;

View File

@ -1,15 +1,14 @@
import "./new.css";
import { ReactNode, useState } from "react";
import { Categories, Category } from "../const";
import { Categories, Category, TorrentKind } from "../const";
import { Button } from "../element/button";
import { useLogin } from "../login";
import { dedupe, unixNow } from "@snort/shared";
import { dedupe } from "@snort/shared";
import * as bencode from "../bencode";
import { sha1 } from "@noble/hashes/sha1";
import { bytesToHex } from "@noble/hashes/utils";
import { Link, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { NostrLink } from "@snort/system";
import { NostrTorrent, TorrentTag } from "../nostr-torrent";
async function openFile(): Promise<File | undefined> {
return new Promise((resolve) => {
@ -48,13 +47,11 @@ type TorrentEntry = {
name: string;
desc: string;
btih: string;
tcat: string;
tags: string[];
files: Array<{
name: string;
size: number;
}>;
trackers: Array<string>;
externalLabels: Array<TorrentTag>;
};
function entryIsValid(entry: TorrentEntry) {
@ -62,7 +59,7 @@ function entryIsValid(entry: TorrentEntry) {
entry.name &&
entry.btih &&
entry.files.length > 0 &&
entry.tcat.length > 0 &&
entry.tags.length > 0 &&
entry.files.every((f) => f.name.length > 0)
);
}
@ -70,18 +67,13 @@ function entryIsValid(entry: TorrentEntry) {
export function NewPage() {
const login = useLogin();
const navigate = useNavigate();
const [newLabelType, setNewLabelType] = useState<TorrentTag["type"]>("imdb");
const [newLabelSubType, setNewLabelSubType] = useState("");
const [newLabelValue, setNewLabelValue] = useState("");
const [obj, setObj] = useState<TorrentEntry>({
name: "",
desc: "",
btih: "",
tcat: "",
tags: [],
files: [],
trackers: [],
externalLabels: [],
});
async function loadTorrent() {
@ -97,72 +89,60 @@ export function NewPage() {
length: number;
name: Uint8Array;
};
const annouce = dec.decode(torrent["announce"] as Uint8Array | undefined);
const announceList = (torrent["announce-list"] as Array<Array<Uint8Array>> | undefined)?.map((a) =>
dec.decode(a[0]),
);
setObj({
name: dec.decode(info.name),
desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "",
btih: bytesToHex(sha1(infoBuf)),
tcat: "",
tags: [],
files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({
size: a.length,
name: a.path.map((b) => dec.decode(b)).join("/"),
})),
trackers: dedupe([annouce, ...(announceList ?? [])]),
externalLabels: [],
});
}
}
async function publish() {
if (!login) return;
const torrent = new NostrTorrent(
undefined,
obj.name,
obj.desc,
obj.btih,
unixNow(),
obj.files,
obj.trackers,
obj.externalLabels.concat([
{
type: "tcat",
value: obj.tcat,
},
]),
);
const ev = torrent.toEvent(login.publicKey);
ev.tags.push(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]);
const ev = await login.builder.generic((eb) => {
const v = eb
.kind(TorrentKind)
.content(obj.desc)
.tag(["title", obj.name])
.tag(["btih", obj.btih])
.tag(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]);
obj.tags.forEach((t) => v.tag(["t", t]));
obj.files.forEach((f) => v.tag(["file", f.name, String(f.size)]));
return v;
});
console.debug(ev);
if (ev) {
const evSigned = await login.builder.signer.sign(ev);
login.system.BroadcastEvent(evSigned);
navigate(`/e/${NostrLink.fromEvent(evSigned).encode()}`);
await login.system.BroadcastEvent(ev);
}
navigate(`/e/${NostrLink.fromEvent(ev).encode()}`);
}
function renderCategories(a: Category, tags: Array<string>): ReactNode {
const tcat = tags.join(",");
return (
<>
<label className="category">
<input
type="radio"
value={tcat}
value={tags.join(",")}
name="category"
checked={obj.tcat === tcat}
checked={obj.tags.join(",") === tags.join(",")}
onChange={(e) =>
setObj((o) => ({
...o,
tcat: e.target.value,
tags: e.target.checked ? dedupe(e.target.value.split(",")) : [],
}))
}
/>
<div data-checked={obj.tcat === tcat}>{a?.name}</div>
<div data-checked={obj.tags.join(",") === tags.join(",")}>{a?.name}</div>
</label>
{a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))}
@ -170,42 +150,6 @@ export function NewPage() {
);
}
function externalDbLogo(type: TorrentTag["type"]) {
switch (type) {
case "imdb":
return (
<img
className="h-8"
title="IMDB"
src="https://m.media-amazon.com/images/G/01/imdb/images-ANDW73HA/favicon_desktop_32x32._CB1582158068_.png"
/>
);
case "tmdb":
return (
<img
className="h-8"
title="TheMovieDatabase"
src="https://www.themoviedb.org/assets/2/favicon-32x32-543a21832c8931d3494a68881f6afcafc58e96c5d324345377f3197a37b367b5.png"
/>
);
case "ttvdb":
return <img className="h-8" title="TheTVDatabase" src="https://thetvdb.com/images/icon.png" />;
case "mal":
return (
<img
className="h-8"
title="MyAnimeList"
src="https://myanimelist.net/img/common/pwa/launcher-icon-0-75x.png"
/>
);
case "anilist":
return <img className="h-8" title="AniList" src="https://anilist.co/img/icons/favicon-32x32.png" />;
case "newznab":
return <img className="h-8" title="newznab" src="https://www.newznab.com/favicon.ico" />;
}
return <div className="border border-neutral-600 rounded-xl px-2">{type}</div>;
}
return (
<>
<h2>New Torrent</h2>
@ -223,6 +167,7 @@ export function NewPage() {
</label>
<input
type="text"
className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none"
placeholder="Title of the torrent..."
value={obj.name}
onChange={(e) => setObj((o) => ({ ...o, name: e.target.value }))}
@ -232,6 +177,7 @@ export function NewPage() {
</label>
<input
type="text"
className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none"
placeholder="Hash in hex format..."
value={obj.btih}
onChange={(e) => setObj((o) => ({ ...o, btih: e.target.value }))}
@ -259,131 +205,6 @@ export function NewPage() {
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-indigo-300">External Ids</label>
<div className="flex gap-2">
<select
value={newLabelType}
onChange={(e) => setNewLabelType(e.target.value as TorrentTag["type"])}
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
>
<option value="imdb">IMDB</option>
<option value="newznab">newznab</option>
<option value="tmdb">TMDB (TheMovieDatabase)</option>
<option value="ttvdb">TTVDB (TheTVDatabase)</option>
<option value="mal">MAL (MyAnimeList)</option>
<option value="anilist">AniList</option>
</select>
{(() => {
switch (newLabelType) {
case "mal":
case "anilist": {
if (newLabelSubType !== "anime" && newLabelSubType !== "manga") {
setNewLabelSubType("anime");
}
return (
<select
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
value={newLabelSubType}
onChange={(e) => setNewLabelSubType(e.target.value)}
>
<option value="anime">Anime</option>
<option value="manga">Manga</option>
</select>
);
}
case "tmdb": {
if (newLabelSubType !== "tv" && newLabelSubType !== "movie") {
setNewLabelSubType("tv");
}
return (
<select
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
value={newLabelSubType}
onChange={(e) => setNewLabelSubType(e.target.value)}
>
<option value="tv">TV</option>
<option value="movie">Movie</option>
</select>
);
}
case "ttvdb": {
if (newLabelSubType !== "series" && newLabelSubType !== "movies") {
setNewLabelSubType("series");
}
return (
<select
className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none"
value={newLabelSubType}
onChange={(e) => setNewLabelSubType(e.target.value)}
>
<option value="series">Series</option>
<option value="movies">Movie</option>
</select>
);
}
default: {
if (newLabelSubType != "") {
setNewLabelSubType("");
}
}
}
})()}
<input type="text" value={newLabelValue} onChange={(e) => setNewLabelValue(e.target.value)} />
<Button
type="secondary"
onClick={() => {
const existing = obj.externalLabels.find((a) => a.type === newLabelType);
if (!existing) {
obj.externalLabels.push({
type: newLabelType as TorrentTag["type"],
value: `${newLabelSubType ? `${newLabelSubType}:` : ""}${newLabelValue}`,
});
setObj({ ...obj });
setNewLabelValue("");
}
}}
>
Add
</Button>
</div>
<div className="flex flex-col gap-2">
{obj.externalLabels.map((a) => {
const link = NostrTorrent.externalDbLink(a);
return (
<div className="flex justify-between bg-neutral-800 px-3 py-1 rounded-xl">
<div className="flex gap-2 items-center">
{externalDbLogo(a.type)}
{link && (
<>
<Link to={link} target="_blank" className="text-indigo-400 hover:underline">
{a.value}
</Link>
</>
)}
{!link && a.value}
</div>
<Button
type="danger"
small={true}
onClick={() =>
setObj((o) => {
const idx = o.externalLabels.findIndex((b) => b.type === a.type);
if (idx !== -1) {
o.externalLabels.splice(idx, 1);
}
return { ...o };
})
}
>
Remove
</Button>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-indigo-300">
Files <span className="text-red-500">*</span>
@ -393,6 +214,7 @@ export function NewPage() {
<input
type="text"
value={a.name}
className="flex-1 px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
placeholder="collection1/IMG_00001.jpg"
onChange={(e) =>
setObj((o) => ({
@ -408,6 +230,7 @@ export function NewPage() {
/>
<input
type="number"
className="px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
value={a.size}
min={0}
placeholder="69000"
@ -425,7 +248,7 @@ export function NewPage() {
/>
<Button
small
type="danger"
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
@ -449,52 +272,6 @@ export function NewPage() {
>
Add File
</Button>
<div className="flex flex-col gap-2">
<label className="text-indigo-300">Trackers</label>
{obj.trackers.map((a, i) => (
<div className="flex gap-2">
<input
type="text"
value={a}
placeholder="udp://mytracker.net:3333"
onChange={(e) =>
setObj((o) => ({
...o,
trackers: o.trackers.map((f, ii) => {
if (ii === i) {
return e.target.value;
}
return f;
}),
}))
}
/>
<Button
small
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
trackers: o.trackers.filter((_, ii) => i !== ii),
}))
}
>
Remove
</Button>
</div>
))}
</div>
<Button
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
trackers: [...o.trackers, ""],
}))
}
>
Add Tracker
</Button>
<Button className="mt-4" type="primary" disabled={!entryIsValid(obj)} onClick={publish}>
Publish
</Button>

View File

@ -4,9 +4,6 @@ import { ProfileImage } from "../element/profile-image";
import { CachedMetadata, parseNostrLink } from "@snort/system";
import { LatestTorrents } from "../element/trending";
import { Text } from "../element/text";
import { LoginState, useLogin } from "../login";
import { Button } from "../element/button";
import { hexToBech32 } from "@snort/shared";
export function ProfilePage() {
const params = useParams();
@ -24,25 +21,14 @@ export function ProfilePage() {
export function ProfileSection({ pubkey }: { pubkey: string }) {
const profile = useUserProfile(pubkey);
const login = useLogin();
return (
<div className="flex items-center gap-4 mb-4">
<ProfileImage pubkey={pubkey} size={200} />
<div className="flex flex-col gap-4 grow">
<h2>{(profile?.name?.length ?? 0) > 0 ? profile?.name : hexToBech32("npub", pubkey).slice(0, 12)}</h2>
{(profile?.about?.length ?? 0) > 0 && <Text content={profile?.about ?? ""} tags={[]} />}
<div className="flex flex-col gap-4">
<h2>{profile?.name}</h2>
<Text content={profile?.about ?? ""} tags={[]} />
<WebSiteLink profile={profile} />
{login?.publicKey === pubkey && (
<Button
type="primary"
onClick={() => {
LoginState.logout();
}}
>
Logout
</Button>
)}
</div>
</div>
);

View File

@ -1,46 +0,0 @@
import { useState } from "react";
import { Button } from "../element/button";
import { useRelays } from "../relays";
import { sanitizeRelayUrl } from "@snort/shared";
export function RelaysPage() {
const relays = useRelays();
const [newRelay, setNewRelay] = useState("");
return (
<>
<h2>Relays</h2>
<br />
<div className="flex flex-col gap-2">
{relays.relays.map((a) => (
<div key={a} className="bg-neutral-800 px-3 py-2 rounded-xl flex justify-between items-center">
{a}
<Button type="danger" onClick={() => relays.remove(a)}>
Remove
</Button>
</div>
))}
</div>
<br />
<div className="flex gap-4">
<input
type="text"
value={newRelay}
onChange={(e) => setNewRelay(e.target.value)}
placeholder="wss://myrelay.com"
/>
<Button
type="primary"
onClick={() => {
const url = sanitizeRelayUrl(newRelay);
if (url) {
relays.add(url);
setNewRelay("");
}
}}
>
Add
</Button>
</div>
</>
);
}

View File

@ -3,7 +3,6 @@ import { useRequestBuilder } from "@snort/system-react";
import { useLocation, useParams } from "react-router-dom";
import { TorrentKind } from "../const";
import { TorrentList } from "../element/torrent-list";
import { useRelays } from "../relays";
export function SearchPage() {
const params = useParams();
@ -11,23 +10,17 @@ export function SearchPage() {
const term = params.term as string | undefined;
const q = new URLSearchParams(location.search ?? "");
const tags = q.get("tags")?.split(",") ?? [];
const iz = q.getAll("i");
const { relays } = useRelays();
const rb = new RequestBuilder("search");
const f = rb.withFilter().relay(["wss://relay.nostr.band", "wss://relay.noswhere.com", ...relays]);
if (term || tags.length > 0 || iz.length > 0) {
f.kinds([TorrentKind]).limit(100);
}
if (term) {
f.search(term);
}
const rb = new RequestBuilder(`search:${term}+${tags.join(",")}`);
const f = rb
.withFilter()
.kinds([TorrentKind])
.search(term)
.limit(100)
.relay(["wss://relay.nostr.band", "wss://relay.noswhere.com"]);
if (tags.length > 0) {
f.tag("t", tags);
}
if (iz.length > 0) {
f.tag("i", iz);
}
const data = useRequestBuilder(rb);

View File

@ -4,14 +4,12 @@ import { useRequestBuilder } from "@snort/system-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { FormatBytes, TorrentKind } from "../const";
import { ProfileImage } from "../element/profile-image";
import { MagnetLink } from "../element/magnet";
import { useLogin } from "../login";
import { Button } from "../element/button";
import { Comments } from "../element/comments";
import { useMemo } from "react";
import { Text } from "../element/text";
import { NostrTorrent } from "../nostr-torrent";
import TorrentFileList from "../element/file-tree";
import CopyIcon from "../element/icon/copy";
import MagnetIcon from "../element/icon/magnet";
export function TorrentPage() {
const location = useLocation();
@ -19,13 +17,11 @@ export function TorrentPage() {
const evState = location.state && "kind" in location.state ? (location.state as TaggedNostrEvent) : undefined;
const rb = new RequestBuilder("torrent:event");
if (!evState) {
rb.withFilter()
.kinds([TorrentKind])
.link(parseNostrLink(unwrap(id)));
}
rb.withFilter()
.kinds([TorrentKind])
.link(parseNostrLink(unwrap(id)));
const evNew = useRequestBuilder(rb);
const evNew = useRequestBuilder(evState ? null : rb);
const ev = evState ?? evNew?.at(0);
if (!ev) return;
@ -36,7 +32,13 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
const login = useLogin();
const navigate = useNavigate();
const link = NostrLink.fromEvent(item);
const torrent = NostrTorrent.fromEvent(item);
const name = item.tags.find((a) => a[0] === "title")?.at(1);
const files = item.tags.filter((a) => a[0] === "file");
const size = useMemo(() => files.map((a) => Number(a[2])).reduce((acc, v) => (acc += v), 0), [files]);
const sortedFiles = useMemo(() => files.sort((a, b) => (a[1] < b[1] ? -1 : 1)), [files]);
const tags = item.tags.filter((a) => a[0] === "t").map((a) => a[1]);
async function deleteTorrent() {
const ev = await login?.builder?.delete(item.id);
@ -48,52 +50,33 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
return (
<div className="flex flex-col gap-4 pb-8">
<div className="text-2xl">{torrent.title}</div>
<div className="flex flex-col gap-2 bg-neutral-900 p-4 rounded-lg">
<ProfileImage pubkey={item.pubkey} withName={true} />
<div className="flex gap-4 items-center text-xl">
<ProfileImage pubkey={item.pubkey} />
{name}
</div>
<div className=" bg-neutral-900 p-4 rounded-lg">
<div className="flex flex-row">
<div className="flex flex-col gap-2 flex-grow">
<div>Size: {FormatBytes(torrent.totalSize)}</div>
<div>Uploaded: {new Date(torrent.publishedAt * 1000).toLocaleString()}</div>
<div>Size: {FormatBytes(size)}</div>
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div className="flex items-center gap-2">
Tags:{" "}
<div className="flex gap-2">
{torrent.tags.map((a, i) => {
if (a.type === "generic") {
return (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
<Link to={`/search/?tags=${a.value}`}>#{a.value}</Link>
</div>
);
} else {
return (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
<Link to={`/search/?i=${a.type}:${a.value}`}>#{a.value}</Link>
</div>
);
}
})}
{tags.map((a, i) => (
<div key={i} className="rounded-2xl py-1 px-4 bg-indigo-800 hover:bg-indigo-700">
<Link to={`/search/?tags=${a}`}>#{a}</Link>
</div>
))}
</div>
</div>
{torrent.trackers.length > 0 && <div>Trackers: {torrent.trackers.length}</div>}
</div>
<div className="flex flex-col gap-2">
<Link to={torrent.magnetLink}>
<Button type="primary" className="flex gap-1 items-center">
<MagnetIcon />
Get this torrent
</Button>
</Link>
<Button
type="primary"
onClick={async () => {
await navigator.clipboard.writeText(JSON.stringify(item, undefined, 2));
}}
className="flex gap-1 items-center"
<MagnetLink
item={item}
className="flex gap-1 items-center px-4 py-3 rounded-full justify-center bg-indigo-800 hover:bg-indigo-700"
>
<CopyIcon />
Copy JSON
</Button>
Get this torrent
</MagnetLink>
{item.pubkey == login?.publicKey && (
<Button type="danger" onClick={deleteTorrent}>
Delete
@ -111,8 +94,27 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
</>
)}
<h3 className="mt-2">Files</h3>
<div className="flex flex-col gap-1 bg-neutral-900 p-4 rounded-lg">
<TorrentFileList torrent={torrent} />
<div className="file-list flex flex-col gap-1 bg-neutral-900 p-4 rounded-lg">
<table className="w-max">
<thead>
<tr>
<th>
<b>Filename</b>
</th>
<th>
<b>Size</b>
</th>
</tr>
</thead>
<tbody>
{sortedFiles.map((a, i) => (
<tr key={i}>
<td className="pr-4">{a[1]}</td>
<td className="text-neutral-500 font-semibold text-right text-sm">{FormatBytes(Number(a[2]))}</td>
</tr>
))}
</tbody>
</table>
</div>
<h3 className="mt-2">Comments</h3>
<Comments link={link} />

View File

@ -1,58 +0,0 @@
import { ExternalStore, appendDedupe, sanitizeRelayUrl } from "@snort/shared";
import { useSyncExternalStore } from "react";
const storageKey = "relays";
class RelaysStore extends ExternalStore<Array<string>> {
#relays: Array<string> = [];
constructor() {
super();
const loaded = localStorage.getItem(storageKey);
if (loaded) {
this.#relays = JSON.parse(loaded);
} else {
this.#relays = ["wss://nos.lol/", "wss://relay.damus.io/", "wss://relay.nostr.band/"];
this.#save();
}
}
add(u: string) {
const url = sanitizeRelayUrl(u);
if (url) {
this.#relays = appendDedupe(this.#relays, [url]);
this.#save();
}
}
remove(u: string) {
const url = sanitizeRelayUrl(u);
if (url) {
this.#relays = this.#relays.filter((a) => a !== url);
this.#save();
}
}
#save() {
localStorage.setItem(storageKey, JSON.stringify(this.#relays));
this.notifyChange();
}
takeSnapshot(): string[] {
return [...this.#relays];
}
}
const relayStore = new RelaysStore();
export function useRelays() {
const relays = useSyncExternalStore(
(s) => relayStore.hook(s),
() => relayStore.snapshot(),
);
return {
relays,
add: (a: string) => relayStore.add(a),
remove: (a: string) => relayStore.remove(a),
};
}

View File

@ -1,32 +1,14 @@
import { DefaultOptimizer, NostrEvent, NostrSystem, Optimizer, PowMiner } from "@snort/system";
import { default as wasmInit, schnorr_verify_event, pow } from "@snort/system-wasm";
import { NostrSystem } from "@snort/system";
import { WorkerRelayInterface } from "@snort/worker-relay";
import WorkerVite from "@snort/worker-relay/src/worker?worker";
import WasmPath from "@snort/system-wasm/pkg/system_wasm_bg.wasm?url";
const workerScript = import.meta.env.DEV
? new URL("@snort/worker-relay/dist/esm/worker.mjs", import.meta.url)
: new WorkerVite();
const workerRelay = new WorkerRelayInterface(workerScript);
export const WasmOptimizer = {
...DefaultOptimizer,
schnorrVerify: (ev) => {
return schnorr_verify_event(ev);
},
} as Optimizer;
export class WasmPowWorker implements PowMiner {
minePow(ev: NostrEvent, target: number): Promise<NostrEvent> {
const res = pow(ev, target);
return Promise.resolve(res);
}
}
export const System = new NostrSystem({
cachingRelay: workerRelay,
optimizer: WasmOptimizer,
});
let didInit = false;
@ -34,14 +16,9 @@ export async function initSystem() {
if (didInit) return;
didInit = true;
const tasks = [
wasmInit(WasmPath),
workerRelay.init({
databasePath: "dtan.db",
insertBatchSize: 100,
}),
System.Init(),
];
await Promise.all(tasks);
await workerRelay.init("dtan.db");
await System.Init();
for (const r of ["wss://nos.lol", "wss://relay.damus.io", "wss://relay.nostr.band"]) {
await System.ConnectToRelay(r, { read: true, write: true });
}
}

1199
yarn.lock

File diff suppressed because it is too large Load Diff