Compare commits

...

10 Commits

Author SHA1 Message Date
c4d6c154f7
fix: patch session
Some checks failed
Deploy nsite / deploy (push) Has been cancelled
2024-12-26 15:06:14 +00:00
58e774a637
chore: bump pkgs 2024-12-26 14:36:09 +00:00
e31e971435
chore: readme 2024-12-26 14:29:29 +00:00
998bd236f8
fix: add nsite workflow 2024-12-26 14:24:01 +00:00
87cffbfc3d
feat: upgrades
- Add login page for bunker/nsec/create account
- Upgrade snort pacakges
- Placeholder avatars
- Logout button on profile page
2024-12-26 14:15:01 +00:00
dd31d27914
fix: add buildx builder 2024-05-16 16:10:27 +01:00
d4b9ce63f5
fix docker build 2024-05-16 16:08:20 +01:00
5c8cb7d359
feat: relay controls 2024-05-16 16:00:26 +01:00
85151ac008
update for nip-35 spec 2024-05-16 15:24:07 +01:00
1479093aef
feat: NostrTorrent 2024-04-18 18:48:55 +01:00
30 changed files with 1846 additions and 753 deletions

View File

@ -26,7 +26,7 @@ steps:
- yarn install - yarn install
- yarn build - yarn build
- name: build docker image - name: build docker image
image: r.j3ss.co/img image: docker
privileged: true privileged: true
volumes: volumes:
- name: cache - name: cache
@ -35,9 +35,11 @@ steps:
TOKEN: TOKEN:
from_secret: docker_hub from_secret: docker_hub
commands: commands:
- img login -u voidic -p $TOKEN - dockerd &
- img build -t voidic/dtan:latest --platform linux/amd64,linux/arm64 . - docker login -u voidic -p $TOKEN
- img push voidic/dtan:latest - 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)
volumes: volumes:
- name: cache - name: cache
claim: claim:

30
.github/workflows/nsite.yml vendored Normal file
View File

@ -0,0 +1,30 @@
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 Normal file → Executable file
View File

View File

@ -1,21 +1,36 @@
# 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 ```json
{ {
"kind": 2003, "kind": 2003,
"content": "<long-description-pre-formatted>", "content": "<long-description-pre-formatted>",
"tags": [ "tags": [
["title", "<torrent-title>"], ["title", "<torrent-title>"],
["btih", "<bittorrent-info-hash>"], ["x", "<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", "<file-name>", "<file-size-in-bytes>"], ["file", "<file-name>", "<file-size-in-bytes>"],
["file", ...other files], ["tracker", "udp://mytacker.com:1337"],
["imdb", "(optional)<imdb-entry-id>"] ["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"]
] ]
} }
``` ```
Top Level tags are usually the type of content like `"Video"` / `"Audio"` / `"Application"` and so on. ## Future Plans
Second level tags are sub categories like `"Movies"` / `"CAD/CAM"` ### 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.

View File

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

View File

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

92
src/element/file-tree.tsx Normal file
View File

@ -0,0 +1,92 @@
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>;
}

13
src/element/icon/copy.tsx Normal file
View File

@ -0,0 +1,13 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,15 @@
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>
);
}

View File

@ -1,47 +0,0 @@
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,8 +11,13 @@ type ProfileImageProps = HTMLProps<HTMLDivElement> & {
export function ProfileImage({ pubkey, size, withName, children, ...props }: ProfileImageProps) { export function ProfileImage({ pubkey, size, withName, children, ...props }: ProfileImageProps) {
const profile = useUserProfile(pubkey); 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 = { const v = {
backgroundImage: `url(${profile?.picture})`, backgroundImage: `url(${url})`,
} as CSSProperties; } as CSSProperties;
if (size) { if (size) {
v.width = `${size}px`; v.width = `${size}px`;

View File

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

View File

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

View File

@ -19,9 +19,11 @@ body {
h1 { h1 {
font-size: 32px; font-size: 32px;
} }
h2 { h2 {
font-size: 28px; font-size: 28px;
} }
h3 { h3 {
font-size: 21px; font-size: 21px;
} }
@ -34,7 +36,21 @@ a:not([href="/"], :has(button)) {
white-space-collapse: preserve-breaks; white-space-collapse: preserve-breaks;
} }
.file-list { input[type="radio"] {
font-size: 15px; border: 0px;
font-weight: 400; 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;
} }

View File

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

View File

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

230
src/nostr-torrent.ts Normal file
View File

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

68
src/page/login.tsx Normal file
View File

@ -0,0 +1,68 @@
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,16 +1,3 @@
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 { label.category div {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(0, 0, 0, 0.5);
padding: 4px 10px; padding: 4px 10px;

View File

@ -1,14 +1,15 @@
import "./new.css"; import "./new.css";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { Categories, Category, TorrentKind } from "../const"; import { Categories, Category } from "../const";
import { Button } from "../element/button"; import { Button } from "../element/button";
import { useLogin } from "../login"; import { useLogin } from "../login";
import { dedupe } from "@snort/shared"; import { dedupe, unixNow } from "@snort/shared";
import * as bencode from "../bencode"; import * as bencode from "../bencode";
import { sha1 } from "@noble/hashes/sha1"; import { sha1 } from "@noble/hashes/sha1";
import { bytesToHex } from "@noble/hashes/utils"; import { bytesToHex } from "@noble/hashes/utils";
import { useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { NostrLink } from "@snort/system"; import { NostrLink } from "@snort/system";
import { NostrTorrent, TorrentTag } from "../nostr-torrent";
async function openFile(): Promise<File | undefined> { async function openFile(): Promise<File | undefined> {
return new Promise((resolve) => { return new Promise((resolve) => {
@ -47,11 +48,13 @@ type TorrentEntry = {
name: string; name: string;
desc: string; desc: string;
btih: string; btih: string;
tags: string[]; tcat: string;
files: Array<{ files: Array<{
name: string; name: string;
size: number; size: number;
}>; }>;
trackers: Array<string>;
externalLabels: Array<TorrentTag>;
}; };
function entryIsValid(entry: TorrentEntry) { function entryIsValid(entry: TorrentEntry) {
@ -59,7 +62,7 @@ function entryIsValid(entry: TorrentEntry) {
entry.name && entry.name &&
entry.btih && entry.btih &&
entry.files.length > 0 && entry.files.length > 0 &&
entry.tags.length > 0 && entry.tcat.length > 0 &&
entry.files.every((f) => f.name.length > 0) entry.files.every((f) => f.name.length > 0)
); );
} }
@ -67,13 +70,18 @@ function entryIsValid(entry: TorrentEntry) {
export function NewPage() { export function NewPage() {
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [newLabelType, setNewLabelType] = useState<TorrentTag["type"]>("imdb");
const [newLabelSubType, setNewLabelSubType] = useState("");
const [newLabelValue, setNewLabelValue] = useState("");
const [obj, setObj] = useState<TorrentEntry>({ const [obj, setObj] = useState<TorrentEntry>({
name: "", name: "",
desc: "", desc: "",
btih: "", btih: "",
tags: [], tcat: "",
files: [], files: [],
trackers: [],
externalLabels: [],
}); });
async function loadTorrent() { async function loadTorrent() {
@ -89,60 +97,72 @@ export function NewPage() {
length: number; length: number;
name: Uint8Array; 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({ setObj({
name: dec.decode(info.name), name: dec.decode(info.name),
desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "", desc: dec.decode(torrent["comment"] as Uint8Array | undefined) ?? "",
btih: bytesToHex(sha1(infoBuf)), btih: bytesToHex(sha1(infoBuf)),
tags: [], tcat: "",
files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({ files: (info.files ?? [{ length: info.length, path: [info.name] }]).map((a) => ({
size: a.length, size: a.length,
name: a.path.map((b) => dec.decode(b)).join("/"), name: a.path.map((b) => dec.decode(b)).join("/"),
})), })),
trackers: dedupe([annouce, ...(announceList ?? [])]),
externalLabels: [],
}); });
} }
} }
async function publish() { async function publish() {
if (!login) return; if (!login) return;
const ev = await login.builder.generic((eb) => { const torrent = new NostrTorrent(
const v = eb undefined,
.kind(TorrentKind) obj.name,
.content(obj.desc) obj.desc,
.tag(["title", obj.name]) obj.btih,
.tag(["btih", obj.btih]) unixNow(),
.tag(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]); obj.files,
obj.trackers,
obj.tags.forEach((t) => v.tag(["t", t])); obj.externalLabels.concat([
obj.files.forEach((f) => v.tag(["file", f.name, String(f.size)])); {
type: "tcat",
return v; value: obj.tcat,
}); },
]),
);
const ev = torrent.toEvent(login.publicKey);
ev.tags.push(["alt", `${obj.name}\nTorrent published on https://dtan.xyz`]);
console.debug(ev); console.debug(ev);
if (ev) { if (ev) {
await login.system.BroadcastEvent(ev); const evSigned = await login.builder.signer.sign(ev);
login.system.BroadcastEvent(evSigned);
navigate(`/e/${NostrLink.fromEvent(evSigned).encode()}`);
} }
navigate(`/e/${NostrLink.fromEvent(ev).encode()}`);
} }
function renderCategories(a: Category, tags: Array<string>): ReactNode { function renderCategories(a: Category, tags: Array<string>): ReactNode {
const tcat = tags.join(",");
return ( return (
<> <>
<label className="category"> <label className="category">
<input <input
type="radio" type="radio"
value={tags.join(",")} value={tcat}
name="category" name="category"
checked={obj.tags.join(",") === tags.join(",")} checked={obj.tcat === tcat}
onChange={(e) => onChange={(e) =>
setObj((o) => ({ setObj((o) => ({
...o, ...o,
tags: e.target.checked ? dedupe(e.target.value.split(",")) : [], tcat: e.target.value,
})) }))
} }
/> />
<div data-checked={obj.tags.join(",") === tags.join(",")}>{a?.name}</div> <div data-checked={obj.tcat === tcat}>{a?.name}</div>
</label> </label>
{a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))} {a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))}
@ -150,6 +170,42 @@ 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 ( return (
<> <>
<h2>New Torrent</h2> <h2>New Torrent</h2>
@ -167,7 +223,6 @@ export function NewPage() {
</label> </label>
<input <input
type="text" type="text"
className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none"
placeholder="Title of the torrent..." placeholder="Title of the torrent..."
value={obj.name} value={obj.name}
onChange={(e) => setObj((o) => ({ ...o, name: e.target.value }))} onChange={(e) => setObj((o) => ({ ...o, name: e.target.value }))}
@ -177,7 +232,6 @@ export function NewPage() {
</label> </label>
<input <input
type="text" type="text"
className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none"
placeholder="Hash in hex format..." placeholder="Hash in hex format..."
value={obj.btih} value={obj.btih}
onChange={(e) => setObj((o) => ({ ...o, btih: e.target.value }))} onChange={(e) => setObj((o) => ({ ...o, btih: e.target.value }))}
@ -205,6 +259,131 @@ export function NewPage() {
</div> </div>
</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"> <div className="flex flex-col gap-2">
<label className="text-indigo-300"> <label className="text-indigo-300">
Files <span className="text-red-500">*</span> Files <span className="text-red-500">*</span>
@ -214,7 +393,6 @@ export function NewPage() {
<input <input
type="text" type="text"
value={a.name} value={a.name}
className="flex-1 px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
placeholder="collection1/IMG_00001.jpg" placeholder="collection1/IMG_00001.jpg"
onChange={(e) => onChange={(e) =>
setObj((o) => ({ setObj((o) => ({
@ -230,7 +408,6 @@ export function NewPage() {
/> />
<input <input
type="number" type="number"
className="px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
value={a.size} value={a.size}
min={0} min={0}
placeholder="69000" placeholder="69000"
@ -248,7 +425,7 @@ export function NewPage() {
/> />
<Button <Button
small small
type="secondary" type="danger"
onClick={() => onClick={() =>
setObj((o) => ({ setObj((o) => ({
...o, ...o,
@ -272,6 +449,52 @@ export function NewPage() {
> >
Add File Add File
</Button> </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}> <Button className="mt-4" type="primary" disabled={!entryIsValid(obj)} onClick={publish}>
Publish Publish
</Button> </Button>

View File

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

46
src/page/relays.tsx Normal file
View File

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

View File

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

58
src/relays.tsx Normal file
View File

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

1201
yarn.lock

File diff suppressed because it is too large Load Diff