Merge pull request #4 from reyamir/feat/ui

New UI
This commit is contained in:
smolgrrr 2023-11-03 00:02:44 +11:00 committed by GitHub
commit e149de5ef9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 415 additions and 316 deletions

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from "react";
import './App.css'; import "./App.css";
import Home from './components/Home'; import Home from "./components/Home";
import Settings from './components/Settings'; import Settings from "./components/Settings";
import SwipeableViews from 'react-swipeable-views'; import SwipeableViews from "react-swipeable-views";
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes } from "react-router-dom";
import Thread from './components/Thread/Thread'; import Thread from "./components/Thread/Thread";
import Header from './components/Header/Header'; import Header from "./components/Header/Header";
function App() { function App() {
const [index, setIndex] = React.useState(1); const [index, setIndex] = React.useState(1);
@ -21,17 +21,23 @@ function App() {
<Routes> <Routes>
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/home" element={<Home />} /> <Route path="/home" element={<Home />} />
<Route path='/thread/:id' element={<Thread />} /> <Route path="/thread/:id" element={<Thread />} />
<Route path="/" element={ <Route
<SwipeableViews index={index} onChangeIndex={handleChangeIndex}> path="/"
element={
<SwipeableViews
index={index}
onChangeIndex={handleChangeIndex}
className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"
>
<Settings /> <Settings />
<Home /> <Home />
</SwipeableViews> </SwipeableViews>
} /> }
/>
</Routes> </Routes>
</Router> </Router>
); );
} }
export default App; export default App;

View File

@ -1,22 +1,29 @@
import { Cog6ToothIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline'; import {
Cog6ToothIcon,
QuestionMarkCircleIcon,
} from "@heroicons/react/24/outline";
export default function Header() { export default function Header() {
return ( return (
<header className="hidden lg:block text-white p-4"> <header className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="container mx-auto flex justify-between items-center"> <div className="flex justify-between items-center h-16">
<a href='/'> <a href="/">
<div className="flex items-center"> <div className="flex items-center gap-2">
<img src="/tao.png" className="h-8" /> <img src="/tao.png" className="h-8" alt="logo" />
<span className="font-bold">The Anon Operation</span> <span className="font-semibold text-white text-sm">
The Anon Operation
</span>
</div> </div>
</a> </a>
<a href='/settings'> <a
<button className="ml-auto pr-4"> href="/settings"
<QuestionMarkCircleIcon className="h-6 w-6 text-transperant" /> className="text-neutral-300 inline-flex gap-4 items-center"
>
<button>
<QuestionMarkCircleIcon className="h-5 w-5" />
</button> </button>
<button>
<button className=""> <Cog6ToothIcon className="h-5 w-5" />
<Cog6ToothIcon className="h-6 w-6 text-transperant" />
</button> </button>
</a> </a>
</div> </div>

View File

@ -1,16 +1,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from "react";
import PostCard from './PostCard/PostCard'; import PostCard from "./PostCard/PostCard";
import NewThreadCard from './PostCard/NewThreadCard'; import NewThreadCard from "./PostCard/NewThreadCard";
import { getPow } from '../utils/mine'; import { getPow } from "../utils/mine";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { subGlobalFeed } from '../utils/subscriptions'; import { subGlobalFeed } from "../utils/subscriptions";
import { uniqBy } from '../utils/utils'; import { uniqBy } from "../utils/utils";
import PWAInstallPopup from './Modals/PWACheckModal'; import PWAInstallPopup from "./Modals/PWACheckModal";
const Home = () => { const Home = () => {
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [filterDifficulty, setFilterDifficulty] = useState(localStorage.getItem('filterDifficulty') || '20'); const [filterDifficulty, setFilterDifficulty] = useState(
const [inBrowser, setInBrowser] = useState(false) localStorage.getItem("filterDifficulty") || "20"
);
const [inBrowser, setInBrowser] = useState(false);
const onEvent = (event: Event) => { const onEvent = (event: Event) => {
setEvents((prevEvents) => [...prevEvents, event]); setEvents((prevEvents) => [...prevEvents, event]);
@ -33,43 +35,54 @@ const Home = () => {
// setInBrowser(true) // setInBrowser(true)
// } // }
window.addEventListener('difficultyChanged', handleDifficultyChange); window.addEventListener("difficultyChanged", handleDifficultyChange);
return () => { return () => {
window.removeEventListener('difficultyChanged', handleDifficultyChange); window.removeEventListener("difficultyChanged", handleDifficultyChange);
}; };
}, []); }, []);
const uniqEvents = events.length > 0 ? uniqBy(events, "id") : []; const uniqEvents = events.length > 0 ? uniqBy(events, "id") : [];
const filteredAndSortedEvents = uniqEvents const filteredAndSortedEvents = uniqEvents
.filter(event => .filter(
(event) =>
getPow(event.id) > Number(filterDifficulty) && getPow(event.id) > Number(filterDifficulty) &&
event.kind === 1 && event.kind === 1 &&
!event.tags.some(tag => tag[0] === 'e') !event.tags.some((tag) => tag[0] === "e")
) )
.sort((a, b) => (b.created_at as any) - (a.created_at as any)); .sort((a, b) => (b.created_at as any) - (a.created_at as any));
const getMetadataEvent = (event: Event) => { const getMetadataEvent = (event: Event) => {
const metadataEvent = uniqEvents.find(e => e.pubkey === event.pubkey && e.kind === 0); const metadataEvent = uniqEvents.find(
(e) => e.pubkey === event.pubkey && e.kind === 0
);
if (metadataEvent) { if (metadataEvent) {
return metadataEvent; return metadataEvent;
} }
return null; return null;
} };
const countReplies = (event: Event) => { const countReplies = (event: Event) => {
return uniqEvents.filter(e => e.tags.some(tag => tag[0] === 'e' && tag[1] === event.id)).length; return uniqEvents.filter((e) =>
} e.tags.some((tag) => tag[0] === "e" && tag[1] === event.id)
).length;
};
return ( return (
<main className="bg-black text-white min-h-screen"> <main className="text-white mb-20">
{/* {inBrowser && <PWAInstallPopup onClose={() => setInBrowser(false)} />} */} {/* {inBrowser && <PWAInstallPopup onClose={() => setInBrowser(false)} />} */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4"> <div className="w-full px-4 sm:px-0 sm:max-w-xl mx-auto my-16">
<NewThreadCard /> <NewThreadCard />
{filteredAndSortedEvents.map((event, index) => ( </div>
<PostCard key={event.id} event={event} metadata={getMetadataEvent(event)} replyCount={countReplies(event)}/> <div className="columns-1 md:columns-2 lg:columns-3 [column-fill:_balance] box-border gap-4">
{filteredAndSortedEvents.map((event) => (
<PostCard
key={event.id}
event={event}
metadata={getMetadataEvent(event)}
replyCount={countReplies(event)}
/>
))} ))}
</div> </div>
</main> </main>

View File

@ -1,25 +1,25 @@
import { parseContent } from '../../utils/content'; import { parseContent } from "../../utils/content";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { getMetadata, uniqBy } from '../../utils/utils'; import { getMetadata, uniqBy } from "../../utils/utils";
import ContentPreview from './TextModal'; import ContentPreview from "./TextModal";
import { renderMedia } from '../../utils/FileUpload'; import { renderMedia } from "../../utils/FileUpload";
const colorCombos = [ const colorCombos = [
'from-red-400 to-yellow-500', "from-red-400 to-yellow-500",
'from-green-400 to-blue-500', "from-green-400 to-blue-500",
'from-purple-400 to-pink-500', "from-purple-400 to-pink-500",
'from-yellow-400 to-orange-500', "from-yellow-400 to-orange-500",
'from-indigo-400 to-purple-500', "from-indigo-400 to-purple-500",
'from-pink-400 to-red-500', "from-pink-400 to-red-500",
'from-blue-400 to-indigo-500', "from-blue-400 to-indigo-500",
'from-orange-400 to-red-500', "from-orange-400 to-red-500",
'from-teal-400 to-green-500', "from-teal-400 to-green-500",
'from-cyan-400 to-teal-500', "from-cyan-400 to-teal-500",
'from-lime-400 to-green-500', "from-lime-400 to-green-500",
'from-amber-400 to-orange-500', "from-amber-400 to-orange-500",
'from-rose-400 to-pink-500', "from-rose-400 to-pink-500",
'from-violet-400 to-purple-500', "from-violet-400 to-purple-500",
'from-sky-400 to-cyan-500' "from-sky-400 to-cyan-500",
]; ];
const getColorFromHash = (id: string, colors: string[]): string => { const getColorFromHash = (id: string, colors: string[]): string => {
@ -35,7 +35,7 @@ const getColorFromHash = (id: string, colors: string[]): string => {
}; };
const timeAgo = (unixTime: number) => { const timeAgo = (unixTime: number) => {
const seconds = Math.floor((new Date().getTime() / 1000) - unixTime); const seconds = Math.floor(new Date().getTime() / 1000 - unixTime);
if (seconds < 60) return `now`; if (seconds < 60) return `now`;
@ -52,7 +52,13 @@ const timeAgo = (unixTime: number) => {
return `${weeks}w`; return `${weeks}w`;
}; };
const QuoteEmbed = ({ event, metadata }: { event: Event, metadata: Event | null}) => { const QuoteEmbed = ({
event,
metadata,
}: {
event: Event;
metadata: Event | null;
}) => {
const { comment, file } = parseContent(event); const { comment, file } = parseContent(event);
const colorCombo = getColorFromHash(event.pubkey, colorCombos); const colorCombo = getColorFromHash(event.pubkey, colorCombos);
@ -62,24 +68,30 @@ const QuoteEmbed = ({ event, metadata }: { event: Event, metadata: Event | null}
} }
return ( return (
<div className="p-1 bg-gradient-to-r from-black to-neutral-900 rounded-lg border border-neutral-800"> <div className="p-3 rounded-lg border border-neutral-700 bg-neutral-800">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center"> <div className="flex items-center gap-1.5">
{metadataParsed ? {metadataParsed ? (
<> <>
<img className={`h-5 w-5 rounded-full`} src={metadataParsed.picture} /> <img
<div className="ml-2 text-sm font-semibold">{metadataParsed.name}</div> className={`h-5 w-5 rounded-full`}
src={metadataParsed.picture}
alt=""
/>
<div className="text-sm font-medium">{metadataParsed.name}</div>
</> </>
: ) : (
<> <>
<div className={`h-5 w-5 bg-gradient-to-r ${colorCombo} rounded-full`} /> <div
<div className="ml-2 text-sm font-semibold">Anonymous</div> className={`h-5 w-5 bg-gradient-to-r ${colorCombo} rounded-full`}
/>
<div className="text-sm font-medium">Anonymous</div>
</> </>
} )}
</div> </div>
</div> </div>
<div className="mr-2 flex flex-col break-words"> <div className="flex flex-col break-words">
<ContentPreview key={event.id} comment={comment} /> <ContentPreview key={event.id} comment={comment} />
</div> </div>
{renderMedia(file)} {renderMedia(file)}

View File

@ -1,15 +1,15 @@
import QuoteEmbed from "./QuoteEmbed"; import QuoteEmbed from "./QuoteEmbed";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { subNoteOnce } from '../../utils/subscriptions'; import { subNoteOnce } from "../../utils/subscriptions";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import LinkModal from "./LinkPreview"; import LinkModal from "./LinkPreview";
const ContentPreview = ({ key, comment }: { key: string, comment: string }) => { const ContentPreview = ({ key, comment }: { key: string; comment: string }) => {
const [finalComment, setFinalComment] = useState(comment) const [finalComment, setFinalComment] = useState(comment);
const [quoteEvents, setQuoteEvents] = useState<Event[]>([]); // Initialize state const [quoteEvents, setQuoteEvents] = useState<Event[]>([]); // Initialize state
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [url, setUrl] = useState('') const [url, setUrl] = useState("");
// Define your callback function for subGlobalFeed // Define your callback function for subGlobalFeed
const onEvent = (event: Event, relay: string) => { const onEvent = (event: Event, relay: string) => {
@ -19,8 +19,8 @@ const ContentPreview = ({ key, comment }: { key: string, comment: string }) => {
useEffect(() => { useEffect(() => {
const findUrl = comment.match(/\bhttps?:\/\/\S+/gi); const findUrl = comment.match(/\bhttps?:\/\/\S+/gi);
if (findUrl && findUrl.length > 0) { if (findUrl && findUrl.length > 0) {
setUrl(findUrl[0]) setUrl(findUrl[0]);
setFinalComment(finalComment.replace(findUrl[0], '').trim()) setFinalComment(finalComment.replace(findUrl[0], "").trim());
} }
const match = comment.match(/\bnostr:([a-z0-9]+)/i); const match = comment.match(/\bnostr:([a-z0-9]+)/i);
@ -28,39 +28,43 @@ const ContentPreview = ({ key, comment }: { key: string, comment: string }) => {
if (nostrQuoteID && nostrQuoteID.length > 0) { if (nostrQuoteID && nostrQuoteID.length > 0) {
let id_to_hex = String(nip19.decode(nostrQuoteID as string).data); let id_to_hex = String(nip19.decode(nostrQuoteID as string).data);
subNoteOnce(id_to_hex, onEvent); subNoteOnce(id_to_hex, onEvent);
setFinalComment(finalComment.replace('nostr:' + nostrQuoteID, '').trim()) setFinalComment(finalComment.replace("nostr:" + nostrQuoteID, "").trim());
} }
}, [comment, finalComment]); }, [comment, finalComment]);
const getMetadataEvent = (event: Event) => { const getMetadataEvent = (event: Event) => {
const metadataEvent = quoteEvents.find(e => e.pubkey === event.pubkey && e.kind === 0); const metadataEvent = quoteEvents.find(
(e) => e.pubkey === event.pubkey && e.kind === 0
);
if (metadataEvent) { if (metadataEvent) {
return metadataEvent; return metadataEvent;
} }
return null; return null;
} };
return ( return (
<div className="mr-2 flex flex-col break-words text-sm"> <div className="gap-2 flex flex-col break-words text-sm">
{isExpanded ? finalComment : finalComment.slice(0, 240)} {isExpanded ? finalComment : finalComment.slice(0, 240)}
{finalComment.length > 240 && ( {finalComment.length > 240 && (
<button <button
className="text-gray-500 text-sm text-neutral-500" className="text-sm text-neutral-500"
onClick={() => setIsExpanded(!isExpanded)} onClick={() => setIsExpanded(!isExpanded)}
> >
{isExpanded ? '...Read less' : '...Read more'} {isExpanded ? "...Read less" : "...Read more"}
</button> </button>
)} )}
{url !== '' && ( {url !== "" && <LinkModal key={key} url={url} />}
<LinkModal key={key} url={url} />
)}
{quoteEvents[0] && quoteEvents.length > 0 && ( {quoteEvents[0] && quoteEvents.length > 0 && (
<a href={`/thread/${nip19.noteEncode(quoteEvents[0].id)}`}> <a href={`/thread/${nip19.noteEncode(quoteEvents[0].id)}`}>
<QuoteEmbed key={key} event={quoteEvents[0]} metadata={getMetadataEvent(quoteEvents[0])} /> <QuoteEmbed
key={key}
event={quoteEvents[0]}
metadata={getMetadataEvent(quoteEvents[0])}
/>
</a> </a>
)} )}
</div> </div>
); );
} };
export default ContentPreview; export default ContentPreview;

View File

@ -1,9 +1,9 @@
import { PropsWithChildren } from 'react'; import { PropsWithChildren } from "react";
export default function CardContainer({ children }: PropsWithChildren) { export default function CardContainer({ children }: PropsWithChildren) {
return ( return (
<div className="card bg-gradient-to-r from-black to-neutral-950 shadow-lg shadow-black"> <div className="card break-inside-avoid mb-4 bg-neutral-900 border border-transparent hover:border-neutral-800">
<div className="card-body p-4">{children}</div> <div className="card-body">{children}</div>
</div> </div>
); );
} }

View File

@ -1,22 +1,31 @@
import CardContainer from './CardContainer'; import CardContainer from "./CardContainer";
import { ArrowUpTrayIcon, CpuChipIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import {
import { XCircleIcon } from '@heroicons/react/24/solid'; ArrowUpTrayIcon,
import { useState, useEffect, useMemo } from 'react'; CpuChipIcon,
import { generatePrivateKey, getPublicKey, finishEvent } from 'nostr-tools'; ArrowPathIcon,
import { publish } from '../../utils/relays'; } from "@heroicons/react/24/outline";
import FileUpload from '../../utils/FileUpload'; import { XCircleIcon } from "@heroicons/react/24/solid";
import { renderMedia } from '../../utils/FileUpload'; import { useState, useEffect, useMemo } from "react";
import { generatePrivateKey, getPublicKey, finishEvent } from "nostr-tools";
import { publish } from "../../utils/relays";
import FileUpload from "../../utils/FileUpload";
import { renderMedia } from "../../utils/FileUpload";
const NewThreadCard: React.FC = () => { const NewThreadCard: React.FC = () => {
const [comment, setComment] = useState(""); const [comment, setComment] = useState("");
const [file, setFile] = useState(""); const [file, setFile] = useState("");
const [sk, setSk] = useState(generatePrivateKey()); const [sk, setSk] = useState(generatePrivateKey());
const [difficulty, setDifficulty] = useState(localStorage.getItem('difficulty') || '21'); const [difficulty, setDifficulty] = useState(
localStorage.getItem("difficulty") || "21"
);
const [uploadingFile, setUploadingFile] = useState(false); const [uploadingFile, setUploadingFile] = useState(false);
const [messageFromWorker, setMessageFromWorker] = useState(null); const [messageFromWorker, setMessageFromWorker] = useState(null);
const [doingWorkProp, setDoingWorkProp] = useState(false); const [doingWorkProp, setDoingWorkProp] = useState(false);
// Initialize the worker outside of any effects // Initialize the worker outside of any effects
const worker = useMemo(() => new Worker(new URL('../../powWorker', import.meta.url)), []); const worker = useMemo(
() => new Worker(new URL("../../powWorker", import.meta.url)),
[]
);
useEffect(() => { useEffect(() => {
worker.onmessage = (event) => { worker.onmessage = (event) => {
@ -29,10 +38,10 @@ const NewThreadCard: React.FC = () => {
setDifficulty(difficulty); setDifficulty(difficulty);
}; };
window.addEventListener('difficultyChanged', handleDifficultyChange); window.addEventListener("difficultyChanged", handleDifficultyChange);
return () => { return () => {
window.removeEventListener('difficultyChanged', handleDifficultyChange); window.removeEventListener("difficultyChanged", handleDifficultyChange);
}; };
}, []); }, []);
@ -46,12 +55,12 @@ const NewThreadCard: React.FC = () => {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
pubkey: getPublicKey(sk), pubkey: getPublicKey(sk),
}, },
difficulty difficulty,
}); });
}; };
useEffect(() => { useEffect(() => {
setDoingWorkProp(false) setDoingWorkProp(false);
if (messageFromWorker) { if (messageFromWorker) {
try { try {
const signedEvent = finishEvent(messageFromWorker, sk); const signedEvent = finishEvent(messageFromWorker, sk);
@ -66,7 +75,7 @@ const NewThreadCard: React.FC = () => {
worker.terminate(); worker.terminate();
}; };
} catch (error) { } catch (error) {
setComment(error + ' ' + comment); setComment(error + " " + comment);
} }
} }
}, [messageFromWorker]); }, [messageFromWorker]);
@ -91,10 +100,7 @@ const NewThreadCard: React.FC = () => {
} }
} }
return ( return (
<>
<CardContainer>
<form <form
name="post" name="post"
method="post" method="post"
@ -106,36 +112,50 @@ const NewThreadCard: React.FC = () => {
}} }}
> >
<input type="hidden" name="MAX_FILE_SIZE" defaultValue={4194304} /> <input type="hidden" name="MAX_FILE_SIZE" defaultValue={4194304} />
<div id="togglePostFormLink" className="text-lg font-semibold"> <div
id="togglePostFormLink"
className="text-lg text-neutral-700 text-center mb-2 font-semibold"
>
Start a New Thread Start a New Thread
</div> </div>
<div> <div className="px-4 pt-4 flex flex-col bg-neutral-900 border border-neutral-800 rounded-lg">
<textarea <textarea
name="com" name="com"
wrap="soft" wrap="soft"
className="w-full p-2 rounded bg-gradient-to-r from-blue-900 to-cyan-500 text-white border-none placeholder-blue-300" className="shadow-lg w-full px-4 py-3 h-28 rounded-md outline-none focus:outline-none bg-neutral-800 border border-neutral-700 text-white placeholder:text-neutral-500"
placeholder='Shitpost here...' placeholder="Shitpost here..."
value={comment} value={comment}
onChange={(e) => setComment(e.target.value)} onChange={(e) => setComment(e.target.value)}
/> />
<div className="h-14 flex items-center justify-between">
<div className="inline-flex items-center gap-2 bg-neutral-800 px-1.5 py-1 rounded-lg">
<div className="inline-flex items-center gap-1.5 text-neutral-300">
<CpuChipIcon className="h-4 w-4" />
</div> </div>
<p className="text-xs font-medium text-neutral-400">
{difficulty} POW
</p>
</div>
<div>
<div className="relative"> <div className="relative">
{file != '' && {file !== "" && (
<button onClick={() => setFile('')}><XCircleIcon className="h-10 w-10 absolute shadow z-100 text-blue-500" /></button> <button onClick={() => setFile("")}>
} <XCircleIcon className="h-10 w-10 absolute shadow z-100 text-blue-500" />
</button>
)}
{renderMedia(file)} {renderMedia(file)}
</div> </div>
<div className="flex justify-between items-center"> <div className="flex items-center gap-4">
<div className="flex items-center"> <div className="flex items-center">
<ArrowUpTrayIcon <ArrowUpTrayIcon
className="h-6 w-6 text-white cursor-pointer" className="h-4 w-4 text-neutral-400 cursor-pointer"
onClick={() => document.getElementById('file_input')?.click()} onClick={() => document.getElementById("file_input")?.click()}
/> />
<input <input
type="file" type="file"
name="file_input" name="file_input"
id="file_input" id="file_input"
style={{ display: 'none' }} style={{ display: "none" }}
onChange={(e) => { onChange={(e) => {
const file_input = e.target.files?.[0]; const file_input = e.target.files?.[0];
if (file_input) { if (file_input) {
@ -144,26 +164,29 @@ const NewThreadCard: React.FC = () => {
}} }}
/> />
{uploadingFile ? ( {uploadingFile ? (
<div className='flex animate-spin text-sm text-gray-300'> <div className="flex animate-spin text-sm text-gray-300">
<ArrowPathIcon className="h-4 w-4 ml-auto" /> <ArrowPathIcon className="h-4 w-4 ml-auto" />
</div> </div>
) : null} ) : null}
</div> </div>
<span className="flex items-center"><CpuChipIcon className="h-6 w-6 text-white" />: {difficulty}</span> <button
<button type="submit" className="px-4 py-2 bg-gradient-to-r from-cyan-900 to-blue-500 rounded text-white font-semibold"> type="submit"
className="h-9 inline-flex items-center justify-center px-4 bg-blue-500 hover:bg-blue-600 rounded-lg text-white font-medium text-sm"
>
Submit Submit
</button> </button>
</div> </div>
{doingWorkProp ? ( {doingWorkProp ? (
<div className='flex animate-pulse text-sm text-gray-300'> <div className="flex animate-pulse text-sm text-gray-300">
<CpuChipIcon className="h-4 w-4 ml-auto" /> <CpuChipIcon className="h-4 w-4 ml-auto" />
<span>Generating Proof-of-Work...</span> <span>Generating Proof-of-Work...</span>
</div> </div>
) : null} ) : null}
</div>
</div>
</div>
<div id="postFormError" className="text-red-500" /> <div id="postFormError" className="text-red-500" />
</form> </form>
</CardContainer>
</>
); );
}; };

View File

@ -1,28 +1,28 @@
import CardContainer from './CardContainer'; import CardContainer from "./CardContainer";
import { FolderIcon } from '@heroicons/react/24/outline'; import { ChatBubbleLeftEllipsisIcon } from "@heroicons/react/24/outline";
import { parseContent } from '../../utils/content'; import { parseContent } from "../../utils/content";
import { Event } from 'nostr-tools'; import { Event } from "nostr-tools";
import { nip19 } from 'nostr-tools'; import { nip19 } from "nostr-tools";
import { getMetadata } from '../../utils/utils'; import { getMetadata } from "../../utils/utils";
import ContentPreview from'../Modals/TextModal'; import ContentPreview from "../Modals/TextModal";
import { renderMedia } from '../../utils/FileUpload'; import { renderMedia } from "../../utils/FileUpload";
const colorCombos = [ const colorCombos = [
'from-red-400 to-yellow-500', "from-red-400 to-yellow-500",
'from-green-400 to-blue-500', "from-green-400 to-blue-500",
'from-purple-400 to-pink-500', "from-purple-400 to-pink-500",
'from-yellow-400 to-orange-500', "from-yellow-400 to-orange-500",
'from-indigo-400 to-purple-500', "from-indigo-400 to-purple-500",
'from-pink-400 to-red-500', "from-pink-400 to-red-500",
'from-blue-400 to-indigo-500', "from-blue-400 to-indigo-500",
'from-orange-400 to-red-500', "from-orange-400 to-red-500",
'from-teal-400 to-green-500', "from-teal-400 to-green-500",
'from-cyan-400 to-teal-500', "from-cyan-400 to-teal-500",
'from-lime-400 to-green-500', "from-lime-400 to-green-500",
'from-amber-400 to-orange-500', "from-amber-400 to-orange-500",
'from-rose-400 to-pink-500', "from-rose-400 to-pink-500",
'from-violet-400 to-purple-500', "from-violet-400 to-purple-500",
'from-sky-400 to-cyan-500' "from-sky-400 to-cyan-500",
]; ];
const getColorFromHash = (id: string, colors: string[]): string => { const getColorFromHash = (id: string, colors: string[]): string => {
@ -38,7 +38,7 @@ const getColorFromHash = (id: string, colors: string[]): string => {
}; };
const timeAgo = (unixTime: number) => { const timeAgo = (unixTime: number) => {
const seconds = Math.floor((new Date().getTime() / 1000) - unixTime); const seconds = Math.floor(new Date().getTime() / 1000 - unixTime);
if (seconds < 60) return `now`; if (seconds < 60) return `now`;
@ -55,7 +55,17 @@ const timeAgo = (unixTime: number) => {
return `${weeks}w`; return `${weeks}w`;
}; };
const PostCard = ({ key, event, metadata, replyCount }: { key: string, event: Event, metadata: Event | null, replyCount: number }) => { const PostCard = ({
key,
event,
metadata,
replyCount,
}: {
key: string;
event: Event;
metadata: Event | null;
replyCount: number;
}) => {
let { comment, file } = parseContent(event); let { comment, file } = parseContent(event);
const colorCombo = getColorFromHash(event.pubkey, colorCombos); const colorCombo = getColorFromHash(event.pubkey, colorCombos);
@ -65,39 +75,55 @@ const PostCard = ({ key, event, metadata, replyCount }: { key: string, event: Ev
} }
return ( return (
<>
<CardContainer> <CardContainer>
<a href={`/thread/${nip19.noteEncode(event.id)}`}> <a href={`/thread/${nip19.noteEncode(event.id)}`}>
<div className="flex flex-col"> <div className="flex flex-col gap-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center"> <div className="flex items-center gap-2.5">
{metadataParsed ? {metadataParsed ? (
<> <>
<img className={`h-8 w-8 rounded-full`} src={metadataParsed.picture} /> <img
<div className="ml-2 text-md font-semibold">{metadataParsed.name}</div> className={`h-8 w-8 rounded-full`}
src={metadataParsed.picture}
alt=""
loading="lazy"
decoding="async"
/>
<div className="text-md font-semibold">
{metadataParsed.name}
</div>
</> </>
: ) : (
<> <>
<div className={`h-8 w-8 bg-gradient-to-r ${colorCombo} rounded-full`} /> <div
<div className="ml-2 text-md font-semibold">Anonymous</div> className={`h-7 w-7 bg-gradient-to-r ${colorCombo} rounded-full`}
/>
<div className="text-sm font-semibold">Anonymous</div>
</> </>
} )}
</div> </div>
<div className="flex items-center ml-auto"> <div className="flex items-center ml-auto gap-2.5">
<div className="text-xs text-gray-500">{event.id.match(/^0*([^\0]{2})/)?.[0] || 0}</div> &nbsp; <div className="text-xs text-neutral-600">
<div className="text-xs font-semibold text-gray-500 mr-2"> {timeAgo(event.created_at)}</div> {event.id.match(/^0*([^\0]{2})/)?.[0] || 0}
<FolderIcon className="h-5 w-5 mr-1 text-gray-500" /> </div>
<span className="text-xs text-gray-500">{replyCount}</span> <span className="text-neutral-700">·</span>
<div className="text-xs font-semibold text-neutral-600">
{timeAgo(event.created_at)}
</div>
<span className="text-neutral-700">·</span>
<div className="inline-flex items-center gap-1.5">
<ChatBubbleLeftEllipsisIcon className="h-4 w-4 text-neutral-600" />
<span className="text-xs text-neutral-600">{replyCount}</span>
</div> </div>
</div> </div>
<div className="mr-2 flex flex-col break-words"> </div>
<div className="flex flex-col break-words">
<ContentPreview key={event.id} comment={comment} /> <ContentPreview key={event.id} comment={comment} />
</div> </div>
</div> </div>
</a> </a>
{renderMedia(file)} {renderMedia(file)}
</CardContainer> </CardContainer>
</>
); );
}; };

View File

@ -20,7 +20,7 @@ code {
@layer components { @layer components {
.card { .card {
border-radius: theme('borderRadius.lg'); border-radius: theme('borderRadius.lg');
padding: theme('spacing.6'); padding: theme('spacing.4');
} }
/* ... */ /* ... */
} }

View File

@ -4,11 +4,11 @@ export interface UploadResult {
} }
/** /**
* Upload file to void.cat * Upload file to void.cat
* https://void.cat/swagger/index.html * https://void.cat/swagger/index.html
*/ */
export default async function FileUpload(file: File ): Promise<UploadResult> { export default async function FileUpload(file: File): Promise<UploadResult> {
const buf = await file.arrayBuffer(); const buf = await file.arrayBuffer();
const req = await fetch("https://void.cat/upload", { const req = await fetch("https://void.cat/upload", {
@ -24,9 +24,9 @@ export default async function FileUpload(file: File ): Promise<UploadResult> {
}); });
if (req.ok) { if (req.ok) {
let rsp: VoidUploadResponse = await req.json(); let rsp: VoidUploadResponse = await req.json();
const fileExtension = file.name.split('.').pop(); // Extracting the file extension const fileExtension = file.name.split(".").pop(); // Extracting the file extension
const resultUrl = `https://void.cat/d/${rsp.file?.id}.${fileExtension}`; const resultUrl = `https://void.cat/d/${rsp.file?.id}.${fileExtension}`;
return {url: resultUrl}; return { url: resultUrl };
} }
return { return {
error: "Upload failed", error: "Upload failed",
@ -36,48 +36,56 @@ export default async function FileUpload(file: File ): Promise<UploadResult> {
export const renderMedia = (file: string) => { export const renderMedia = (file: string) => {
if (file && (file.endsWith(".mp4") || file.endsWith(".webm"))) { if (file && (file.endsWith(".mp4") || file.endsWith(".webm"))) {
return ( return (
<video controls className="thumb"> <video
controls
muted
preload="metadata"
className="thumb mt-2 rounded-md w-full ring-1 ring-neutral-800"
>
<source src={file} type="video/mp4" /> <source src={file} type="video/mp4" />
</video> </video>
); );
} else if (!file.includes("http")) { } else if (!file.includes("http")) {
return ( return <></>;
<></>
);
} else { } else {
return ( return (
<img alt="Invalid thread" loading="lazy" className="thumb" src={file} /> <img
alt="Invalid thread"
loading="lazy"
className="thumb mt-2 rounded-md w-full ring-1 ring-neutral-800"
src={file}
/>
); );
} }
}; };
export interface UploadResult { export interface UploadResult {
url?: string; url?: string;
error?: string; error?: string;
} }
export type VoidUploadResponse = { export type VoidUploadResponse = {
ok: boolean, ok: boolean;
file?: VoidFile, file?: VoidFile;
errorMessage?: string, errorMessage?: string;
} };
export type VoidFile = { export type VoidFile = {
id: string, id: string;
meta?: VoidFileMeta meta?: VoidFileMeta;
} };
export type VoidFileMeta = { export type VoidFileMeta = {
version: number, version: number;
id: string, id: string;
name?: string, name?: string;
size: number, size: number;
uploaded: Date, uploaded: Date;
description?: string, description?: string;
mimeType?: string, mimeType?: string;
digest?: string, digest?: string;
url?: string, url?: string;
expires?: Date, expires?: Date;
storage?: string, storage?: string;
encryptionParams?: string, encryptionParams?: string;
} };