diff --git a/.yarn/releases/yarn-4.1.1.cjs b/.yarn/releases/yarn-4.1.1.cjs
old mode 100644
new mode 100755
diff --git a/package.json b/package.json
index 5dc7c8b..2d238eb 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,8 @@
"@snort/shared": "^1.0.14",
"@snort/system": "^1.2.12",
"@snort/system-react": "^1.2.12",
- "@snort/worker-relay": "^1.0.9",
+ "@snort/system-wasm": "^1.0.2",
+ "@snort/worker-relay": "^1.0.10",
"classnames": "^2.3.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
diff --git a/src/element/file-tree.tsx b/src/element/file-tree.tsx
new file mode 100644
index 0000000..d865114
--- /dev/null
+++ b/src/element/file-tree.tsx
@@ -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 (
+ <>
+
{
+ // lazy stateless toggle
+ e.currentTarget.nextElementSibling?.classList.toggle("hidden");
+ }}
+ >
+
+ {n.name}
+
+ {n.children.sort((a) => (a.isDir ? -1 : 1)).map((b) => renderNode(b))}
+ >
+ );
+ } else {
+ return (
+
+
+
+ {n.name}
+
+
{FormatBytes(n.size)}
+
+ );
+ }
+ }
+
+ return {renderNode(tree)}
;
+}
diff --git a/src/element/icon/copy.tsx b/src/element/icon/copy.tsx
new file mode 100644
index 0000000..71888c2
--- /dev/null
+++ b/src/element/icon/copy.tsx
@@ -0,0 +1,13 @@
+export default function CopyIcon() {
+ return (
+
+ );
+}
diff --git a/src/element/icon/file-icon.tsx b/src/element/icon/file-icon.tsx
new file mode 100644
index 0000000..4f6735e
--- /dev/null
+++ b/src/element/icon/file-icon.tsx
@@ -0,0 +1,13 @@
+export default function FileIcon() {
+ return (
+
+ );
+}
diff --git a/src/element/icon/folder.tsx b/src/element/icon/folder.tsx
new file mode 100644
index 0000000..ef94e11
--- /dev/null
+++ b/src/element/icon/folder.tsx
@@ -0,0 +1,13 @@
+export default function FolderIcon() {
+ return (
+
+ );
+}
diff --git a/src/element/icon/magnet.tsx b/src/element/icon/magnet.tsx
new file mode 100644
index 0000000..c0c6236
--- /dev/null
+++ b/src/element/icon/magnet.tsx
@@ -0,0 +1,15 @@
+export default function MagnetIcon({ size }: { size?: number }) {
+ return (
+
+ );
+}
diff --git a/src/element/magnet.tsx b/src/element/magnet.tsx
deleted file mode 100644
index fa3ebb2..0000000
--- a/src/element/magnet.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import { TaggedNostrEvent } from "@snort/system";
-import { Trackers } from "../const";
-import { Link, LinkProps } from "react-router-dom";
-
-type MagnetLinkProps = Omit & {
- 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 (
-
-
-
- {props.children}
-
- );
-}
diff --git a/src/element/torrent-list.tsx b/src/element/torrent-list.tsx
index 2a78252..9a69f24 100644
--- a/src/element/torrent-list.tsx
+++ b/src/element/torrent-list.tsx
@@ -2,9 +2,10 @@ 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 }) {
return (
@@ -28,58 +29,44 @@ export function TorrentList({ items }: { items: Array }) {
);
}
-function TagList({ tags }: { tags: string[][] }) {
- return tags
- .filter((a) => a[0] === "t")
+function TagList({ torrent }: { torrent: NostrTorrent }) {
+ return torrent.categoryPath
.slice(0, 3)
- .map((current, index, allTags) => (
-
- ));
+ .map((current, index, allTags) => );
}
-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)
- .map((b) => b[1])
- .join(","),
- );
+ return encodeURIComponent(tags.slice(0, startIndex + 1).join(","));
}, [tags, startIndex]);
return (
<>
- {tag[1]}
+ {tag}
{tags.length !== startIndex + 1 && " > "}
>
);
}
function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
- 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]);
-
+ const torrent = NostrTorrent.fromEvent(item);
return (
-
+
|
- {name}
+ {torrent.title}
|
- {new Date(item.created_at * 1000).toLocaleDateString()} |
+ {new Date(torrent.publishedAt * 1000).toLocaleDateString()} |
-
+
+
+
|
- {FormatBytes(size)} |
+ {FormatBytes(torrent.totalSize)} |
|
diff --git a/src/index.css b/src/index.css
index 3ee2e5d..2ee62e8 100644
--- a/src/index.css
+++ b/src/index.css
@@ -33,8 +33,3 @@ a:not([href="/"], :has(button)) {
.text {
white-space-collapse: preserve-breaks;
}
-
-.file-list {
- font-size: 15px;
- font-weight: 400;
-}
diff --git a/src/nostr-torrent.ts b/src/nostr-torrent.ts
new file mode 100644
index 0000000..2a6dce4
--- /dev/null
+++ b/src/nostr-torrent.ts
@@ -0,0 +1,193 @@
+import { unixNow } from "@snort/shared";
+import { 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" | undefined;
+ readonly value: string;
+}
+
+export class NostrTorrent {
+ constructor(
+ readonly id: string,
+ readonly title: string,
+ readonly summary: string,
+ readonly infoHash: string,
+ readonly publishedAt: number,
+ readonly files: Array,
+ readonly trackers: Array,
+ readonly tags: Array,
+ ) {}
+
+ 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 === undefined).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 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],
+ ["i", 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) {
+ ret.tags.push(["t", `${tag.type !== undefined ? `${tag.type}:` : ""}${tag.value}`]);
+ }
+
+ 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 = [];
+ const trackers: Array = [];
+ const tags: Array = [];
+
+ 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 "i": {
+ infoHash = t[1];
+ break;
+ }
+ case "file": {
+ files.push({
+ name: t[1],
+ size: Number(t[2]),
+ });
+ break;
+ }
+ case "tracker": {
+ trackers.push(t[1]);
+ break;
+ }
+ case "t": {
+ const kSplit = t[1].split(":", 2);
+ if (kSplit.length === 1) {
+ tags.push({
+ type: undefined,
+ value: t[1],
+ });
+ } else {
+ tags.push({
+ type: kSplit[0],
+ value: kSplit[1],
+ } as TorrentTag);
+ }
+ break;
+ }
+ case "imdb": {
+ // v0: imdb tag
+ tags.push({
+ type: "imdb",
+ value: t[1],
+ });
+ break;
+ }
+ }
+ }
+
+ return new NostrTorrent(ev.id, title, ev.content, infoHash, ev.created_at, files, trackers, tags);
+ }
+}
diff --git a/src/page/new.tsx b/src/page/new.tsx
index 329a4c1..3129151 100644
--- a/src/page/new.tsx
+++ b/src/page/new.tsx
@@ -47,11 +47,12 @@ type TorrentEntry = {
name: string;
desc: string;
btih: string;
- tags: string[];
+ tags: Array;
files: Array<{
name: string;
size: number;
}>;
+ trackers: Array;
};
function entryIsValid(entry: TorrentEntry) {
@@ -74,6 +75,7 @@ export function NewPage() {
btih: "",
tags: [],
files: [],
+ trackers: [],
});
async function loadTorrent() {
@@ -99,6 +101,7 @@ export function NewPage() {
size: a.length,
name: a.path.map((b) => dec.decode(b)).join("/"),
})),
+ trackers: [],
});
}
}
@@ -272,6 +275,53 @@ export function NewPage() {
>
Add File
+
+
+ {obj.trackers.map((a, i) => (
+
+
+ setObj((o) => ({
+ ...o,
+ trackers: o.trackers.map((f, ii) => {
+ if (ii === i) {
+ return e.target.value;
+ }
+ return f;
+ }),
+ }))
+ }
+ />
+
+
+ ))}
+
+
diff --git a/src/page/torrent.tsx b/src/page/torrent.tsx
index d309f6a..671e4f6 100644
--- a/src/page/torrent.tsx
+++ b/src/page/torrent.tsx
@@ -4,12 +4,14 @@ 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();
@@ -32,13 +34,7 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
const login = useLogin();
const navigate = useNavigate();
const link = NostrLink.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]);
+ const torrent = NostrTorrent.fromEvent(item);
async function deleteTorrent() {
const ev = await login?.builder?.delete(item.id);
@@ -50,33 +46,43 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
return (
-
-
+
{torrent.title}
+
+
-
Size: {FormatBytes(size)}
-
Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}
+
Size: {FormatBytes(torrent.totalSize)}
+
Uploaded: {new Date(torrent.publishedAt * 1000).toLocaleString()}
Tags:{" "}
- {tags.map((a, i) => (
-
- #{a}
-
- ))}
+ {torrent.tags
+ .filter((a) => a.type === undefined)
+ .map((a, i) => (
+
+ #{a.value}
+
+ ))}
-
+
+
+
+
+ Copy JSON
+
{item.pubkey == login?.publicKey && (