feat: Added UI style improvements

This commit is contained in:
florian 2023-12-10 14:47:09 +01:00
parent b9d41a6263
commit 8c8e84d060
26 changed files with 454 additions and 161 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

@ -20,9 +20,9 @@ export function Comments({ link }: { link: NostrLink }) {
{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) => (
<div className="flex flex-col gap-2 rounded p-2 bg-slate-900"> <div 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

@ -0,0 +1,73 @@
import React from 'react';
interface RichTextContentProps {
text: string;
}
// Helper function to check if a string is an image URL
const isImageUrl = (string: string): boolean => /\.(jpeg|jpg|gif|png)$/.test(string);
// Helper function to check if a string is a web URL
const isWebUrl = (string: string): boolean => {
const urlPattern = new RegExp('^(https?:\\/\\/)' + // protocol
'((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // domain name
'((\\d{1,3}\\.){3}\\d{1,3}))' + // OR IP (v4) address
'(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // port and path
'(\\?[;&a-z\\d%_.~+=-]*)?' + // query string
'(\\#[-a-z\\d_]*)?$', 'i'); // fragment locator
return !!urlPattern.test(string);
};
// Function to split the text into segments
const getSegments = (text: string): { type: 'text' | 'image' | 'link', content: string }[] => {
const words = text.match(/(\S+|\s+)/g) || [];
const segments: { type: 'text' | 'image' | 'link', content: string }[] = [];
let currentTextSegment = '';
words.forEach(word => {
const trimmedWord = word.trim();
if (isImageUrl(trimmedWord)) {
if (currentTextSegment) {
segments.push({ type: 'text', content: currentTextSegment });
currentTextSegment = '';
}
segments.push({ type: 'image', content: trimmedWord });
} else if (isWebUrl(trimmedWord)) {
if (currentTextSegment) {
segments.push({ type: 'text', content: currentTextSegment });
currentTextSegment = '';
}
segments.push({ type: 'link', content: trimmedWord });
} else {
currentTextSegment += word;
}
});
if (currentTextSegment) {
segments.push({ type: 'text', content: currentTextSegment });
}
return segments;
};
const RichTextContent: React.FC<RichTextContentProps> = ({ text }) => {
const segments = getSegments(text);
return (
<div>
{segments.map((segment, index) => {
switch(segment.type) {
case 'image':
return <img key={index} src={segment.content} style={{maxHeight:'50vh'}} />;
case 'link':
return <a key={index} href={segment.content} className="text-indigo-300" target="_blank" rel="noopener noreferrer">{segment.content}</a>;
default:
return <span key={index}>{segment.content}</span>;
}
})}
</div>
);
};
export default RichTextContent;

View File

@ -12,19 +12,17 @@ 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="px-4 py-3 bg-neutral-800 rounded-full w-full focus-visible:outline-none"
className="p-3 rounded w-full" value={term}
value={term} onChange={(e) => setTerm(e.target.value)}
onChange={(e) => setTerm(e.target.value)} onKeyDown={(e) => {
onKeyDown={(e) => { if (e.key == "Enter") {
if (e.key == "Enter") { navigate(`/search/${encodeURIComponent(term)}${tags.length > 0 ? `?tags=${tags.join(",")}` : ""}`);
navigate(`/search/${encodeURIComponent(term)}${tags.length > 0 ? `?tags=${tags.join(",")}` : ""}`); }
} }}
}} />
/>
</div>
); );
} }

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="bg-neutral-800 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 TorrentTableEntry({ item }: { item: TaggedNostrEvent }) { function TagList({ tags }: { tags: string[][] }) {
const name = item.tags.find((a) => a[0] === "title")?.at(1); return tags
const size = item.tags .filter((a) => a[0] === "t")
.filter((a) => a[0] === "file") .slice(0, 3)
.map((a) => Number(a[2])) .map((current, index, allTags) => (
.reduce((acc, v) => (acc += v), 0); <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 ( return (
<tr className="hover:bg-slate-800"> <>
<td> <Link to={`/search/?tags=${tagUrl}`}>{tag[1]}</Link>
{item.tags {tags.length !== startIndex + 1 && " > "}
.filter((a) => a[0] === "t") </>
.slice(0, 3) );
.map((a, i, arr) => ( }
<>
<Link function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
to={`/search/?tags=${encodeURIComponent( const { name, size } = useMemo(() => {
arr const name = item.tags.find((a) => a[0] === "title")?.at(1);
.slice(0, i + 1) const size = item.tags
.map((b) => b[1]) .filter((a) => a[0] === "file")
.join(","), .map((a) => Number(a[2]))
)}`} .reduce((acc, v) => (acc += v), 0);
> return { name, size };
{a[1]} }, [item]);
</Link>
{arr.length !== i + 1 && " > "} return (
</> <tr className="hover:bg-indigo-800">
))} <td className="text-indigo-300">
<TagList tags={item.tags} />
</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

@ -26,9 +26,9 @@ 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} /> <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 RichTextContent from "../element/rich-text-content";
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,70 @@ 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>Size: {FormatBytes(size)}</div> <div className="flex flex-row">
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div> <div className="flex flex-col gap-2 flex-grow">
<div className="flex items-center gap-2"> <div>Size: {FormatBytes(size)}</div>
Tags:{" "} <div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div className="flex gap-1"> <div className="flex items-center gap-2">
{tags.map((a) => ( Tags:{" "}
<div className="rounded p-1 bg-slate-400">#{a}</div> <div className="flex gap-2">
))} {tags.map((a) => (
<div 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 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
</MagnetLink>
{item.pubkey == login?.publicKey && (
<Button type="danger" onClick={deleteTorrent}>
Delete
</Button>
)}
</div> </div>
</div> </div>
<div>
<MagnetLink item={item} className="flex gap-1 items-center">
Get this torrent
</MagnetLink>
</div>
</div> </div>
<h3>Description</h3> {item.content && (
<pre className="font-mono text-xs bg-slate-700 p-2 rounded overflow-y-auto">{item.content}</pre> <>
<h3>Files</h3> <h3 className="mt-2">Description</h3>
<div className="flex flex-col gap-1 bg-slate-700 p-2 rounded"> <pre className="font-mono text-sm bg-neutral-900 p-4 rounded-lg overflow-y-auto">
{files.map((a) => ( <RichTextContent text={item.content}></RichTextContent>
<div className="flex items-center gap-2"> </pre>
{a[1]} </>
<small className="text-slate-500 font-semibold">{FormatBytes(Number(a[2]))}</small>
</div>
))}
</div>
{item.pubkey == login?.publicKey && (
<Button className="bg-red-600 hover:bg-red-800" onClick={deleteTorrent}>
Delete
</Button>
)} )}
<h3>Comments</h3> <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>
<th>
<b>Filename</b>
</th>
<th>
<b>Size</b>
</th>
</thead>
{sortedFiles.map((a) => (
<tr>
<td className="pr-4">{a[1]}</td>
<td className="text-neutral-500 font-semibold text-right text-sm">{FormatBytes(Number(a[2]))}</td>
</tr>
))}
</table>
</div>
<h3 className="mt-2">Comments</h3>
<Comments link={link} /> <Comments link={link} />
</div> </div>
); );