mirror of
https://git.v0l.io/Kieran/dtan.git
synced 2024-12-12 15:06:22 +00:00
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:
commit
01610ab846
@ -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>
|
||||
|
72
public/fonts/outfit/outfit.css
Normal file
72
public/fonts/outfit/outfit.css
Normal 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;
|
||||
}
|
BIN
public/fonts/outfit/outfit_400_latin-ext.woff2
Normal file
BIN
public/fonts/outfit/outfit_400_latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_400_latin.woff2
Normal file
BIN
public/fonts/outfit/outfit_400_latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_500_latin-ext.woff2
Normal file
BIN
public/fonts/outfit/outfit_500_latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_500_latin.woff2
Normal file
BIN
public/fonts/outfit/outfit_500_latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_600_latin-ext.woff2
Normal file
BIN
public/fonts/outfit/outfit_600_latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_600_latin.woff2
Normal file
BIN
public/fonts/outfit/outfit_600_latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_700_latin-ext.woff2
Normal file
BIN
public/fonts/outfit/outfit_700_latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/outfit/outfit_700_latin.woff2
Normal file
BIN
public/fonts/outfit/outfit_700_latin.woff2
Normal file
Binary file not shown.
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -19,10 +19,10 @@ export function Comments({ link }: { link: NostrLink }) {
|
||||
<WriteComment link={link} />
|
||||
{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">
|
||||
.map((a, i) => (
|
||||
<div key={i} 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>
|
||||
);
|
||||
}
|
||||
|
@ -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}</>}
|
||||
|
@ -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(",")}` : ""}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -3,29 +3,34 @@ import { useMemo } from "react";
|
||||
import { Mention } from "./mention";
|
||||
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]);
|
||||
|
||||
function renderFrag(f: ParsedFragment) {
|
||||
function renderFrag(f: ParsedFragment, index: number) {
|
||||
switch (f.type) {
|
||||
case "media":
|
||||
return <img key={index} src={f.content} style={{ maxHeight: "50vh" }} />;
|
||||
case "mention":
|
||||
case "link": {
|
||||
const link = tryParseNostrLink(f.content);
|
||||
if (link) {
|
||||
return <Mention link={link} />;
|
||||
const nostrLink = tryParseNostrLink(f.content);
|
||||
if (nostrLink) {
|
||||
return <Mention key={index} link={nostrLink} />;
|
||||
} else {
|
||||
return (
|
||||
<Link to={f.content} target="_blank">
|
||||
<Link key={index} to={f.content} target="_blank" className="text-indigo-300" rel="noopener noreferrer">
|
||||
{f.content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return <span>{f.content}</span>;
|
||||
return <span key={index}>{f.content}</span>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="text">{frags.map(renderFrag)}</div>;
|
||||
if (wrap) {
|
||||
return <div className="text">{frags.map(renderFrag)}</div>;
|
||||
}
|
||||
return frags.map(renderFrag);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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="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>
|
||||
|
@ -15,7 +15,7 @@ export function LatestTorrents({ author }: { author?: string }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3>Latest Torrents</h3>
|
||||
<h2>Latest Torrents</h2>
|
||||
<TorrentList items={latest.data ?? []} />
|
||||
</>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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
32
src/page/new.css
Normal 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;
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { useLocation, useParams } from "react-router-dom";
|
||||
import { TorrentKind } from "../const";
|
||||
import { useRequestBuilder } from "@snort/system-react";
|
||||
import { TorrentList } from "../element/torrent-list";
|
||||
import { Search } from "../element/search";
|
||||
|
||||
export function SearchPage() {
|
||||
const params = useParams();
|
||||
@ -26,9 +25,8 @@ export function SearchPage() {
|
||||
const data = useRequestBuilder(NoteCollection, rb);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Search term={term} tags={tags} />
|
||||
<h2>Search Results:</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2>Search Results</h2>
|
||||
<TorrentList items={data.data ?? []} />
|
||||
</div>
|
||||
);
|
||||
|
@ -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 { Text } from "../element/text";
|
||||
|
||||
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,74 @@ 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, i) => (
|
||||
<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 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">
|
||||
<Text content={item.content} tags={item.tags} wrap={false}></Text>
|
||||
</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>
|
||||
<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} />
|
||||
</div>
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user