Merge pull request 'Design update and some fixes' (#1) from florian/dtan:feat/ui-design-update into main

Reviewed-on: https://git.v0l.io/Kieran/dtan/pulls/1
Reviewed-by: Kieran <kieran@noreply.localhost>
This commit is contained in:
Kieran 2023-12-19 22:11:28 +00:00
commit 01610ab846
26 changed files with 399 additions and 172 deletions

View File

@ -11,6 +11,7 @@
<meta name="description" content="Torrents on Nostr" /> <meta name="description" content="Torrents on Nostr" />
<link rel="icon" href="/logo_32.png" /> <link rel="icon" href="/logo_32.png" />
<title>DTAN.XYZ</title> <title>DTAN.XYZ</title>
<link href="/fonts/outfit/outfit.css" rel="stylesheet" />
</head> </head>
<body> <body>

View File

@ -0,0 +1,72 @@
/* latin-ext */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(outfit_400_latin-ext.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(outfit_400_latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(outfit_500_latin-ext.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(outfit_500_latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(outfit_600_latin-ext.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(outfit_600_latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(outfit_700_latin-ext.woff2) format('woff2');
unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Outfit';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(outfit_700_latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3,6 +3,8 @@ import { HTMLProps, forwardRef, useState } from "react";
type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick"> & { type ButtonProps = Omit<HTMLProps<HTMLButtonElement>, "onClick"> & {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void; onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void;
type: "primary" | "secondary" | "danger";
small?: boolean;
}; };
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => { export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
@ -19,18 +21,28 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) =>
} }
} }
const colorScheme =
props.disabled ? "bg-neutral-900 text-neutral-600 border border-solid border-neutral-700" :
props.type == "danger"
? "bg-red-900 hover:bg-red-600"
: props.type == "primary"
? "bg-indigo-800 hover:bg-indigo-700"
: "bg-neutral-800 hover:bg-neutral-700";
return ( return (
<button <button
{...props} {...props}
type="button" type="button"
className={classNames( className={classNames(
"p-2 rounded flex gap-1 items-center justify-center bg-slate-800 hover:bg-slate-600", 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, props.className,
)} )}
ref={ref} ref={ref}
onClick={clicking} onClick={clicking}
> >
{spinning ? "Loading.." : props.children} {spinning ? "Loading..." : props.children}
</button> </button>
); );
}); });

View File

@ -19,10 +19,10 @@ export function Comments({ link }: { link: NostrLink }) {
<WriteComment link={link} /> <WriteComment link={link} />
{comments.data {comments.data
?.sort((a, b) => (a.created_at > b.created_at ? -1 : 1)) ?.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
.map((a) => ( .map((a, i) => (
<div className="flex flex-col gap-2 rounded p-2 bg-slate-900"> <div key={i} className="flex flex-col gap-2 rounded-lg p-4 bg-neutral-900">
<ProfileImage pubkey={a.pubkey} withName={true}> <ProfileImage pubkey={a.pubkey} withName={true}>
<span className="text-slate-400 text-sm">{new Date(a.created_at * 1000).toLocaleString()}</span> <span className="text-neutral-400 text-sm">{new Date(a.created_at * 1000).toLocaleString()}</span>
</ProfileImage> </ProfileImage>
<Text content={a.content} tags={a.tags} /> <Text content={a.content} tags={a.tags} />
</div> </div>
@ -50,10 +50,21 @@ function WriteComment({ link }: { link: NostrLink }) {
} }
return ( return (
<div className="rounded p-2 bg-slate-900"> <div className="rounded-lg p-4 bg-neutral-900 flex flex-row gap-4">
<h3>Write a Comment</h3> <div className="flex-shrink">
<textarea className="w-full" value={msg} onChange={(e) => setMsg(e.target.value)}></textarea> <ProfileImage pubkey={login.publicKey} />
<Button onClick={sendComment}>Send</Button> </div>
<div className="flex-grow">
<textarea
className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none w-full"
placeholder="Write a comment..."
value={msg}
onChange={(e) => setMsg(e.target.value)}
></textarea>
</div>
<div>
<Button type="primary" onClick={sendComment}>Send</Button>
</div>
</div> </div>
); );
} }

View File

@ -26,7 +26,7 @@ export function ProfileImage({ pubkey, size, withName, children, ...props }: Pro
> >
<div <div
{...props} {...props}
className="rounded-full aspect-square w-12 bg-slate-800 border border-slate-200 bg-cover bg-center" className="rounded-full aspect-square w-12 bg-neutral-800 border border-neutral-500 bg-cover bg-center"
style={v} style={v}
></div> ></div>
{withName === true && <>{profile?.name}</>} {withName === true && <>{profile?.name}</>}

View File

@ -12,11 +12,10 @@ export function Search(params: { term?: string; tags?: Array<string> }) {
}, [params]); }, [params]);
return ( return (
<div>
<input <input
type="text" type="text"
placeholder="Search.." placeholder="Search..."
className="p-3 rounded w-full" 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) => {
@ -25,6 +24,5 @@ export function Search(params: { term?: string; tags?: Array<string> }) {
} }
}} }}
/> />
</div>
); );
} }

View File

@ -3,29 +3,34 @@ import { useMemo } from "react";
import { Mention } from "./mention"; import { Mention } from "./mention";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
export function Text({ content, tags }: { content: string; tags: Array<Array<string>> }) { export function Text({ content, tags, wrap = true }: { content: string; tags: Array<Array<string>>; wrap?: boolean }) {
const frags = useMemo(() => transformText(content, tags), [content, tags]); const frags = useMemo(() => transformText(content, tags), [content, tags]);
function renderFrag(f: ParsedFragment) { function renderFrag(f: ParsedFragment, index: number) {
switch (f.type) { switch (f.type) {
case "media":
return <img key={index} src={f.content} style={{ maxHeight: "50vh" }} />;
case "mention": case "mention":
case "link": { case "link": {
const link = tryParseNostrLink(f.content); const nostrLink = tryParseNostrLink(f.content);
if (link) { if (nostrLink) {
return <Mention link={link} />; return <Mention key={index} link={nostrLink} />;
} else { } else {
return ( return (
<Link to={f.content} target="_blank"> <Link key={index} to={f.content} target="_blank" className="text-indigo-300" rel="noopener noreferrer">
{f.content} {f.content}
</Link> </Link>
); );
} }
} }
default: { default: {
return <span>{f.content}</span>; return <span key={index}>{f.content}</span>;
} }
} }
} }
if (wrap) {
return <div className="text">{frags.map(renderFrag)}</div>; return <div className="text">{frags.map(renderFrag)}</div>;
} }
return frags.map(renderFrag);
}

View File

@ -1,11 +1,12 @@
.torrent-list { .torrent-list {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 14px;
font-weight: 400;
} }
.torrent-list td, .torrent-list td,
.torrent-list th { .torrent-list th {
border: 1px solid #333; border-bottom: 1px solid #222;
padding: 0px 5px; padding: 0px 6px;
font-size: 14px;
} }

View File

@ -4,18 +4,19 @@ import { FormatBytes } from "../const";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { MagnetLink } from "./magnet"; import { MagnetLink } from "./magnet";
import { Mention } from "./mention"; import { Mention } from "./mention";
import { useMemo } from "react";
export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) { export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
return ( return (
<table className="torrent-list"> <table className="torrent-list mb-8">
<thead> <thead>
<tr className="bg-slate-600"> <tr className="h-8">
<th>Category</th> <th className="rounded-tl-lg">Category</th>
<th>Name</th> <th>Name</th>
<th>Uploaded</th> <th>Uploaded</th>
<th></th> <th></th>
<th>Size</th> <th>Size</th>
<th>From</th> <th className="rounded-tr-lg">From</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -27,45 +28,59 @@ export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
); );
} }
function TagList({ tags }: { tags: string[][] }) {
return tags
.filter((a) => a[0] === "t")
.slice(0, 3)
.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[] }) {
const tagUrl = useMemo(() => {
return encodeURIComponent(
tags
.slice(0, startIndex + 1)
.map((b) => b[1])
.join(","),
);
}, [tags, startIndex]);
return (
<>
<Link to={`/search/?tags=${tagUrl}`}>{tag[1]}</Link>
{tags.length !== startIndex + 1 && " > "}
</>
);
}
function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) { function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
const { name, size } = useMemo(() => {
const name = item.tags.find((a) => a[0] === "title")?.at(1); const name = item.tags.find((a) => a[0] === "title")?.at(1);
const size = item.tags const size = item.tags
.filter((a) => a[0] === "file") .filter((a) => a[0] === "file")
.map((a) => Number(a[2])) .map((a) => Number(a[2]))
.reduce((acc, v) => (acc += v), 0); .reduce((acc, v) => (acc += v), 0);
return { name, size };
}, [item]);
return ( return (
<tr className="hover:bg-slate-800"> <tr className="hover:bg-indigo-800">
<td> <td className="text-indigo-300">
{item.tags <TagList tags={item.tags} />
.filter((a) => a[0] === "t")
.slice(0, 3)
.map((a, i, arr) => (
<>
<Link
to={`/search/?tags=${encodeURIComponent(
arr
.slice(0, i + 1)
.map((b) => b[1])
.join(","),
)}`}
>
{a[1]}
</Link>
{arr.length !== i + 1 && " > "}
</>
))}
</td> </td>
<td> <td className="break-words">
<Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}> <Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}>
{name} {name}
</Link> </Link>
</td> </td>
<td>{new Date(item.created_at * 1000).toLocaleDateString()}</td> <td className="text-neutral-300">{new Date(item.created_at * 1000).toLocaleDateString()}</td>
<td> <td>
<MagnetLink item={item} /> <MagnetLink item={item} />
</td> </td>
<td>{FormatBytes(size)}</td> <td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(size)}</td>
<td> <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>
</tr> </tr>

View File

@ -15,7 +15,7 @@ export function LatestTorrents({ author }: { author?: string }) {
return ( return (
<> <>
<h3>Latest Torrents</h3> <h2>Latest Torrents</h2>
<TorrentList items={latest.data ?? []} /> <TorrentList items={latest.data ?? []} />
</> </>
); );

View File

@ -4,10 +4,16 @@
html, html,
body { body {
font-family: 'Outfit', Arial, Helvetica, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-size: 16px;
color: #adadad;
font-style: normal;
font-weight: 500;
line-height: 24px;
background-color: black; background-color: black;
color: white; color: white;
font-size: 16px;
font-family: Arial, Helvetica, sans-serif;
} }
h1 { h1 {
@ -20,19 +26,15 @@ h3 {
font-size: 21px; font-size: 21px;
} }
input[type="text"],
input[type="number"],
textarea {
color: black;
padding: 4px;
border-radius: 4px;
}
a:not([href="/"], :has(button)) { a:not([href="/"], :has(button)) {
text-decoration: dotted; text-decoration-line: none;
text-decoration-line: underline;
} }
.text { .text {
white-space-collapse: preserve-breaks; white-space-collapse: preserve-breaks;
} }
.file-list {
font-size: 15px;
font-weight: 400;
}

View File

@ -1,10 +1,8 @@
import { Search } from "../element/search";
import { LatestTorrents } from "../element/trending"; import { LatestTorrents } from "../element/trending";
export function HomePage() { export function HomePage() {
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<Search />
<LatestTorrents /> <LatestTorrents />
</div> </div>
); );

View File

@ -2,6 +2,7 @@ import { Link, Outlet } from "react-router-dom";
import { Button } from "../element/button"; import { Button } from "../element/button";
import { LoginSession, LoginState, useLogin } from "../login"; import { LoginSession, LoginState, useLogin } from "../login";
import { ProfileImage } from "../element/profile-image"; import { ProfileImage } from "../element/profile-image";
import { Search } from "../element/search";
export function Layout() { export function Layout() {
const login = useLogin(); const login = useLogin();
@ -17,14 +18,15 @@ export function Layout() {
return ( return (
<div className="container mx-auto"> <div className="container mx-auto">
<header className="flex justify-between items-center p-1"> <header className="flex justify-between items-center pt-4 pb-6">
<Link to={"/"} className="flex gap-1 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>
{login ? <LoggedInHeader login={login} /> : <Button onClick={DoLogin}>Login</Button>} <div className="w-1/2"><Search /></div>
{login ? <LoggedInHeader login={login} /> : <Button type="primary" onClick={DoLogin}>Login</Button>}
</header> </header>
<div className="p-1"> <div>
<Outlet /> <Outlet />
</div> </div>
</div> </div>
@ -33,10 +35,10 @@ export function Layout() {
function LoggedInHeader({ login }: { login: LoginSession }) { function LoggedInHeader({ login }: { login: LoginSession }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-2">
<ProfileImage pubkey={login.publicKey} /> <ProfileImage pubkey={login.publicKey} />
<Link to="/new"> <Link to="/new">
<Button>+ Create</Button> <Button type="primary">+ Create</Button>
</Link> </Link>
</div> </div>
); );

32
src/page/new.css Normal file
View File

@ -0,0 +1,32 @@
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;
border-radius: 4px;
cursor: pointer;
margin: 1px;
}
label.category div:hover {
border: 1px solid white;
outline: none;
margin: 0px;
}
label.category div[data-checked="true"] {
background-color: #3730a3;
border: 1px solid white;
margin: 0px;
}

View File

@ -1,3 +1,4 @@
import "./new.css";
import { ReactNode, useState } from "react"; import { ReactNode, useState } from "react";
import { Categories, Category, TorrentKind } from "../const"; import { Categories, Category, TorrentKind } from "../const";
import { Button } from "../element/button"; import { Button } from "../element/button";
@ -42,19 +43,37 @@ async function openFile(): Promise<File | undefined> {
}); });
} }
type TorrentEntry = {
name: string;
desc: string;
btih: string;
tags: string[];
files: Array<{
name: string;
size: number;
}>;
};
function entryIsValid(entry: TorrentEntry) {
return (
entry.name &&
entry.btih &&
entry.files.length > 0 &&
entry.tags.length > 0 &&
entry.files.every((f) => f.name.length > 0)
);
}
export function NewPage() { export function NewPage() {
const login = useLogin(); const login = useLogin();
const navigate = useNavigate(); const navigate = useNavigate();
const [obj, setObj] = useState({ const [obj, setObj] = useState<TorrentEntry>({
name: "", name: "",
desc: "", desc: "",
btih: "", btih: "",
tags: [] as Array<string>, tags: [],
files: [] as Array<{ files: [],
name: string;
size: number;
}>,
}); });
async function loadTorrent() { async function loadTorrent() {
@ -110,7 +129,7 @@ export function NewPage() {
function renderCategories(a: Category, tags: Array<string>): ReactNode { function renderCategories(a: Category, tags: Array<string>): ReactNode {
return ( return (
<> <>
<div className="flex gap-1 bg-slate-500 p-1 rounded"> <label className="category">
<input <input
type="radio" type="radio"
value={tags.join(",")} value={tags.join(",")}
@ -123,8 +142,9 @@ export function NewPage() {
})) }))
} }
/> />
<label>{a?.name}</label> <div data-checked={obj.tags.join(",") === tags.join(",")}>{a?.name}</div>
</div> </label>
{a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))} {a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))}
</> </>
); );
@ -132,57 +152,69 @@ export function NewPage() {
return ( return (
<> <>
<h1>New</h1> <h2>New Torrent</h2>
<div className="flex gap-1"> <div className="flex gap-4 my-4">
<Button onClick={loadTorrent}>Import from Torrent</Button> <Button onClick={loadTorrent} type="primary">
<Button>Import from Magnet</Button> Import from Torrent
</Button>
{/*<Button>Import from Magnet</Button>*/}
</div> </div>
<h2>Torrent Info</h2> <form className="flex flex-col gap-2 bg-neutral-900 rounded-2xl p-6 mb-8">
<form className="flex flex-col gap-2"> <div className="flex gap-4">
<div className="flex gap-2"> <div className="flex-1 flex flex-col gap-2">
<div className="flex-1 flex flex-col gap-1"> <label className="text-indigo-300">
<label>Title</label> Title <span className="text-red-500">*</span>
</label>
<input <input
type="text" type="text"
placeholder="raw noods" className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none"
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 }))}
/> />
<label>Info Hash</label> <label className=" text-indigo-300 mt-2 ">
Info Hash <span className="text-red-500">*</span>
</label>
<input <input
type="text" type="text"
placeholder="hex" className="px-4 py-2 rounded-xl bg-neutral-800 focus-visible:outline-none"
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 }))}
/> />
<label>Category</label> <label className=" text-indigo-300 mt-2">
<div className="flex flex-col gap-1"> Category <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-2">
{Categories.map((a) => ( {Categories.map((a) => (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="font-bold bg-slate-800 p-1">{a.name}</div> <div className="font-bold">{a.name}</div>
<div className="flex gap-1 flex-wrap">{renderCategories(a, [a.tag])}</div> <div className="flex gap-1 flex-wrap">{renderCategories(a, [a.tag])}</div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
<div className="flex-1 flex flex-col gap-1"> <div className="flex-1 flex flex-col gap-1">
<label>Description</label> <label className="text-indigo-300">Description</label>
<textarea <textarea
rows={30} rows={30}
className="font-mono text-xs" className="p-4 rounded-xl bg-neutral-800 focus-visible:outline-none font-mono text-sm"
value={obj.desc} value={obj.desc}
onChange={(e) => setObj((o) => ({ ...o, desc: e.target.value }))} onChange={(e) => setObj((o) => ({ ...o, desc: e.target.value }))}
></textarea> ></textarea>
</div> </div>
</div> </div>
<h2>Files</h2>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label className="text-indigo-300">
Files <span className="text-red-500">*</span>
</label>
{obj.files.map((a, i) => ( {obj.files.map((a, i) => (
<div className="flex gap-1"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={a.name} value={a.name}
className="flex-1" 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) => ({
@ -198,6 +230,7 @@ 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"
@ -214,6 +247,8 @@ export function NewPage() {
} }
/> />
<Button <Button
small
type="secondary"
onClick={() => onClick={() =>
setObj((o) => ({ setObj((o) => ({
...o, ...o,
@ -227,6 +262,7 @@ export function NewPage() {
))} ))}
</div> </div>
<Button <Button
type="secondary"
onClick={() => onClick={() =>
setObj((o) => ({ setObj((o) => ({
...o, ...o,
@ -236,7 +272,9 @@ export function NewPage() {
> >
Add File Add File
</Button> </Button>
<Button onClick={publish}>Publish</Button> <Button className="mt-4" type="primary" disabled={!entryIsValid(obj)} onClick={publish}>
Publish
</Button>
</form> </form>
</> </>
); );

View File

@ -1,7 +1,7 @@
import { useUserProfile } from "@snort/system-react"; import { useUserProfile } from "@snort/system-react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { ProfileImage } from "../element/profile-image"; import { ProfileImage } from "../element/profile-image";
import { parseNostrLink } from "@snort/system"; import { MetadataCache, parseNostrLink } from "@snort/system";
import { LatestTorrents } from "../element/trending"; import { LatestTorrents } from "../element/trending";
import { Text } from "../element/text"; import { Text } from "../element/text";
@ -12,7 +12,7 @@ export function ProfilePage() {
if (!link) return; if (!link) return;
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<ProfileSection pubkey={link.id} /> <ProfileSection pubkey={link.id} />
<LatestTorrents author={link.id} /> <LatestTorrents author={link.id} />
</div> </div>
@ -21,18 +21,29 @@ export function ProfilePage() {
export function ProfileSection({ pubkey }: { pubkey: string }) { export function ProfileSection({ pubkey }: { pubkey: string }) {
const profile = useUserProfile(pubkey); const profile = useUserProfile(pubkey);
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-4 mb-4">
<ProfileImage pubkey={pubkey} size={240} /> <ProfileImage pubkey={pubkey} size={200} />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<h2>{profile?.name}</h2> <h2>{profile?.name}</h2>
<Text content={profile?.about ?? ""} tags={[]} /> <Text content={profile?.about ?? ""} tags={[]} />
{profile?.website && ( <WebSiteLink profile={profile} />
<Link to={profile.website} target="_blank">
{new URL(profile.website).hostname}
</Link>
)}
</div> </div>
</div> </div>
); );
} }
function WebSiteLink({ profile }: { profile?: MetadataCache }) {
const website = profile?.website;
if (!website) return;
const hostname = website.startsWith("http") ? new URL(website).hostname : website;
const url = website.startsWith("http") ? website : `https://${website}`;
return (
<Link to={url} target="_blank">
{hostname}
</Link>
);
}

View File

@ -3,7 +3,6 @@ import { useLocation, useParams } from "react-router-dom";
import { TorrentKind } from "../const"; import { TorrentKind } from "../const";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { TorrentList } from "../element/torrent-list"; import { TorrentList } from "../element/torrent-list";
import { Search } from "../element/search";
export function SearchPage() { export function SearchPage() {
const params = useParams(); const params = useParams();
@ -26,9 +25,8 @@ export function SearchPage() {
const data = useRequestBuilder(NoteCollection, rb); const data = useRequestBuilder(NoteCollection, rb);
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4">
<Search term={term} tags={tags} /> <h2>Search Results</h2>
<h2>Search Results:</h2>
<TorrentList items={data.data ?? []} /> <TorrentList items={data.data ?? []} />
</div> </div>
); );

View File

@ -1,13 +1,15 @@
import { unwrap } from "@snort/shared"; import { unwrap } from "@snort/shared";
import { NostrLink, NoteCollection, RequestBuilder, TaggedNostrEvent, parseNostrLink } from "@snort/system"; import { NostrLink, NoteCollection, RequestBuilder, TaggedNostrEvent, parseNostrLink } from "@snort/system";
import { useRequestBuilder } from "@snort/system-react"; import { useRequestBuilder } from "@snort/system-react";
import { 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 { 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";
export function TorrentPage() { export function TorrentPage() {
const location = useLocation(); const location = useLocation();
@ -31,11 +33,11 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
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 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);
const files = item.tags.filter((a) => a[0] === "file"); 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 tags = item.tags.filter((a) => a[0] === "t").map((a) => a[1]);
async function deleteTorrent() { async function deleteTorrent() {
@ -47,45 +49,74 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
} }
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-4 pb-8">
<div className="flex gap-2 items-center text-xl"> <div className="flex gap-4 items-center text-xl">
<ProfileImage pubkey={item.pubkey} /> <ProfileImage pubkey={item.pubkey} />
{name} {name}
</div> </div>
<div className="flex flex-col gap-1 bg-slate-700 p-2 rounded"> <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(size)}</div> <div>Size: {FormatBytes(size)}</div>
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div> <div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
Tags:{" "} Tags:{" "}
<div className="flex gap-1"> <div className="flex gap-2">
{tags.map((a) => ( {tags.map((a, i) => (
<div className="rounded p-1 bg-slate-400">#{a}</div> <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>
</div> </div>
<div> </div>
<MagnetLink item={item} className="flex gap-1 items-center"> <div className="flex flex-col gap-2">
<MagnetLink
item={item}
className="flex gap-1 items-center px-4 py-3 rounded-full justify-center bg-indigo-800 hover:bg-indigo-700"
>
Get this torrent Get this torrent
</MagnetLink> </MagnetLink>
</div>
</div>
<h3>Description</h3>
<pre className="font-mono text-xs bg-slate-700 p-2 rounded overflow-y-auto">{item.content}</pre>
<h3>Files</h3>
<div className="flex flex-col gap-1 bg-slate-700 p-2 rounded">
{files.map((a) => (
<div className="flex items-center gap-2">
{a[1]}
<small className="text-slate-500 font-semibold">{FormatBytes(Number(a[2]))}</small>
</div>
))}
</div>
{item.pubkey == login?.publicKey && ( {item.pubkey == login?.publicKey && (
<Button className="bg-red-600 hover:bg-red-800" onClick={deleteTorrent}> <Button type="danger" onClick={deleteTorrent}>
Delete Delete
</Button> </Button>
)} )}
<h3>Comments</h3> </div>
</div>
</div>
{item.content && (
<>
<h3 className="mt-2">Description</h3>
<pre className="font-mono text-sm bg-neutral-900 p-4 rounded-lg overflow-y-auto">
<Text content={item.content} tags={item.tags} wrap={false}></Text>
</pre>
</>
)}
<h3 className="mt-2">Files</h3>
<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} /> <Comments link={link} />
</div> </div>
); );