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" />
<link rel="icon" href="/logo_32.png" />
<title>DTAN.XYZ</title>
<link href="/fonts/outfit/outfit.css" rel="stylesheet" />
</head>
<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"> & {
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => Promise<void> | void;
type: "primary" | "secondary" | "danger";
small?: boolean;
};
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 (
<button
{...props}
type="button"
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,
)}
ref={ref}
onClick={clicking}
>
{spinning ? "Loading.." : props.children}
{spinning ? "Loading..." : props.children}
</button>
);
});

View File

@ -20,9 +20,9 @@ export function Comments({ link }: { link: NostrLink }) {
{comments.data
?.sort((a, b) => (a.created_at > b.created_at ? -1 : 1))
.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}>
<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>
<Text content={a.content} tags={a.tags} />
</div>
@ -50,10 +50,21 @@ function WriteComment({ link }: { link: NostrLink }) {
}
return (
<div className="rounded p-2 bg-slate-900">
<h3>Write a Comment</h3>
<textarea className="w-full" value={msg} onChange={(e) => setMsg(e.target.value)}></textarea>
<Button onClick={sendComment}>Send</Button>
<div className="rounded-lg p-4 bg-neutral-900 flex flex-row gap-4">
<div className="flex-shrink">
<ProfileImage pubkey={login.publicKey} />
</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>
);
}

View File

@ -26,7 +26,7 @@ export function ProfileImage({ pubkey, size, withName, children, ...props }: Pro
>
<div
{...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}
></div>
{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]);
return (
<div>
<input
type="text"
placeholder="Search.."
className="p-3 rounded w-full"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key == "Enter") {
navigate(`/search/${encodeURIComponent(term)}${tags.length > 0 ? `?tags=${tags.join(",")}` : ""}`);
}
}}
/>
</div>
<input
type="text"
placeholder="Search..."
className="px-4 py-3 bg-neutral-800 rounded-full w-full focus-visible:outline-none"
value={term}
onChange={(e) => setTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key == "Enter") {
navigate(`/search/${encodeURIComponent(term)}${tags.length > 0 ? `?tags=${tags.join(",")}` : ""}`);
}
}}
/>
);
}

View File

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

View File

@ -4,18 +4,19 @@ import { FormatBytes } from "../const";
import { Link } from "react-router-dom";
import { MagnetLink } from "./magnet";
import { Mention } from "./mention";
import { useMemo } from "react";
export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
return (
<table className="torrent-list">
<table className="torrent-list mb-8">
<thead>
<tr className="bg-slate-600">
<th>Category</th>
<tr className="bg-neutral-800 h-8">
<th className="rounded-tl-lg">Category</th>
<th>Name</th>
<th>Uploaded</th>
<th></th>
<th>Size</th>
<th>From</th>
<th className="rounded-tr-lg">From</th>
</tr>
</thead>
<tbody>
@ -27,45 +28,59 @@ export function TorrentList({ items }: { items: Array<TaggedNostrEvent> }) {
);
}
function TorrentTableEntry({ item }: { item: TaggedNostrEvent }) {
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);
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 (
<tr className="hover:bg-slate-800">
<td>
{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 && " > "}
</>
))}
<>
<Link to={`/search/?tags=${tagUrl}`}>{tag[1]}</Link>
{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]);
return (
<tr className="hover:bg-indigo-800">
<td className="text-indigo-300">
<TagList tags={item.tags} />
</td>
<td>
<td className="break-words">
<Link to={`/e/${NostrLink.fromEvent(item).encode()}`} state={item}>
{name}
</Link>
</td>
<td>{new Date(item.created_at * 1000).toLocaleDateString()}</td>
<td className="text-neutral-300">{new Date(item.created_at * 1000).toLocaleDateString()}</td>
<td>
<MagnetLink item={item} />
</td>
<td>{FormatBytes(size)}</td>
<td>
<td className="whitespace-nowrap text-right text-neutral-300">{FormatBytes(size)}</td>
<td className="text-indigo-300 whitespace-nowrap break-words text-ellipsis">
<Mention link={new NostrLink(NostrPrefix.PublicKey, item.pubkey)} />
</td>
</tr>

View File

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

View File

@ -4,10 +4,16 @@
html,
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;
color: white;
font-size: 16px;
font-family: Arial, Helvetica, sans-serif;
}
h1 {
@ -20,19 +26,15 @@ h3 {
font-size: 21px;
}
input[type="text"],
input[type="number"],
textarea {
color: black;
padding: 4px;
border-radius: 4px;
}
a:not([href="/"], :has(button)) {
text-decoration: dotted;
text-decoration-line: underline;
text-decoration-line: none;
}
.text {
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";
export function HomePage() {
return (
<div className="flex flex-col gap-2">
<Search />
<div className="flex flex-col gap-4">
<LatestTorrents />
</div>
);

View File

@ -2,6 +2,7 @@ import { Link, Outlet } from "react-router-dom";
import { Button } from "../element/button";
import { LoginSession, LoginState, useLogin } from "../login";
import { ProfileImage } from "../element/profile-image";
import { Search } from "../element/search";
export function Layout() {
const login = useLogin();
@ -17,14 +18,15 @@ export function Layout() {
return (
<div className="container mx-auto">
<header className="flex justify-between items-center p-1">
<Link to={"/"} className="flex gap-1 items-center">
<header className="flex justify-between items-center pt-4 pb-6">
<Link to={"/"} className="flex gap-2 items-center">
<img src="/logo_256.jpg" className="rounded-full" height={40} width={40} />
<h1 className="font-bold uppercase">dtan.xyz</h1>
</Link>
{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>
<div className="p-1">
<div>
<Outlet />
</div>
</div>
@ -33,10 +35,10 @@ export function Layout() {
function LoggedInHeader({ login }: { login: LoginSession }) {
return (
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<ProfileImage pubkey={login.publicKey} />
<Link to="/new">
<Button>+ Create</Button>
<Button type="primary">+ Create</Button>
</Link>
</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 { Categories, Category, TorrentKind } from "../const";
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() {
const login = useLogin();
const navigate = useNavigate();
const [obj, setObj] = useState({
const [obj, setObj] = useState<TorrentEntry>({
name: "",
desc: "",
btih: "",
tags: [] as Array<string>,
files: [] as Array<{
name: string;
size: number;
}>,
tags: [],
files: [],
});
async function loadTorrent() {
@ -110,7 +129,7 @@ export function NewPage() {
function renderCategories(a: Category, tags: Array<string>): ReactNode {
return (
<>
<div className="flex gap-1 bg-slate-500 p-1 rounded">
<label className="category">
<input
type="radio"
value={tags.join(",")}
@ -123,8 +142,9 @@ export function NewPage() {
}))
}
/>
<label>{a?.name}</label>
</div>
<div data-checked={obj.tags.join(",") === tags.join(",")}>{a?.name}</div>
</label>
{a.sub_category?.map((b) => renderCategories(b, [...tags, b.tag]))}
</>
);
@ -132,57 +152,69 @@ export function NewPage() {
return (
<>
<h1>New</h1>
<div className="flex gap-1">
<Button onClick={loadTorrent}>Import from Torrent</Button>
<Button>Import from Magnet</Button>
<h2>New Torrent</h2>
<div className="flex gap-4 my-4">
<Button onClick={loadTorrent} type="primary">
Import from Torrent
</Button>
{/*<Button>Import from Magnet</Button>*/}
</div>
<h2>Torrent Info</h2>
<form className="flex flex-col gap-2">
<div className="flex gap-2">
<div className="flex-1 flex flex-col gap-1">
<label>Title</label>
<form className="flex flex-col gap-2 bg-neutral-900 rounded-2xl p-6 mb-8">
<div className="flex gap-4">
<div className="flex-1 flex flex-col gap-2">
<label className="text-indigo-300">
Title <span className="text-red-500">*</span>
</label>
<input
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}
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
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}
onChange={(e) => setObj((o) => ({ ...o, btih: e.target.value }))}
/>
<label>Category</label>
<div className="flex flex-col gap-1">
<label className=" text-indigo-300 mt-2">
Category <span className="text-red-500">*</span>
</label>
<div className="flex flex-col gap-2">
{Categories.map((a) => (
<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>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-1">
<label>Description</label>
<label className="text-indigo-300">Description</label>
<textarea
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}
onChange={(e) => setObj((o) => ({ ...o, desc: e.target.value }))}
></textarea>
</div>
</div>
<h2>Files</h2>
<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) => (
<div className="flex gap-1">
<div className="flex gap-2">
<input
type="text"
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"
onChange={(e) =>
setObj((o) => ({
@ -198,6 +230,7 @@ export function NewPage() {
/>
<input
type="number"
className="px-3 py-1 bg-neutral-800 rounded-xl focus-visible:outline-none"
value={a.size}
min={0}
placeholder="69000"
@ -214,6 +247,8 @@ export function NewPage() {
}
/>
<Button
small
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
@ -227,6 +262,7 @@ export function NewPage() {
))}
</div>
<Button
type="secondary"
onClick={() =>
setObj((o) => ({
...o,
@ -236,7 +272,9 @@ export function NewPage() {
>
Add File
</Button>
<Button onClick={publish}>Publish</Button>
<Button className="mt-4" type="primary" disabled={!entryIsValid(obj)} onClick={publish}>
Publish
</Button>
</form>
</>
);

View File

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

View File

@ -1,13 +1,15 @@
import { unwrap } from "@snort/shared";
import { NostrLink, NoteCollection, RequestBuilder, TaggedNostrEvent, parseNostrLink } from "@snort/system";
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 { 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 RichTextContent from "../element/rich-text-content";
export function TorrentPage() {
const location = useLocation();
@ -31,11 +33,11 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
const navigate = useNavigate();
const link = NostrLink.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);
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() {
@ -47,45 +49,70 @@ export function TorrentDetail({ item }: { item: TaggedNostrEvent }) {
}
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center text-xl">
<div className="flex flex-col gap-4 pb-8">
<div className="flex gap-4 items-center text-xl">
<ProfileImage pubkey={item.pubkey} />
{name}
</div>
<div className="flex flex-col gap-1 bg-slate-700 p-2 rounded">
<div>Size: {FormatBytes(size)}</div>
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div className="flex items-center gap-2">
Tags:{" "}
<div className="flex gap-1">
{tags.map((a) => (
<div className="rounded p-1 bg-slate-400">#{a}</div>
))}
<div className=" bg-neutral-900 p-4 rounded-lg">
<div className="flex flex-row">
<div className="flex flex-col gap-2 flex-grow">
<div>Size: {FormatBytes(size)}</div>
<div>Uploaded: {new Date(item.created_at * 1000).toLocaleDateString()}</div>
<div className="flex items-center gap-2">
Tags:{" "}
<div className="flex gap-2">
{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>
<MagnetLink item={item} className="flex gap-1 items-center">
Get this torrent
</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 && (
<Button className="bg-red-600 hover:bg-red-800" onClick={deleteTorrent}>
Delete
</Button>
{item.content && (
<>
<h3 className="mt-2">Description</h3>
<pre className="font-mono text-sm bg-neutral-900 p-4 rounded-lg overflow-y-auto">
<RichTextContent text={item.content}></RichTextContent>
</pre>
</>
)}
<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} />
</div>
);