Compare commits

..

No commits in common. "develop" and "master" have entirely different histories.

55 changed files with 2 additions and 18117 deletions

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

36
.gitignore vendored
View File

@ -1,36 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@ -1,40 +0,0 @@
# Builder stage
FROM node:18.17.0 AS builder
# Set working directory
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy app source
COPY . .
# Build the app
RUN npm run build
# Runtime stage
FROM node:18.17.0-slim
# Set working directory
WORKDIR /app
# Copy built artifacts from the builder stage
COPY --from=builder /app/build /app/build
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/package.json /app
COPY --from=builder /app/scripts /app/scripts
COPY --from=builder /app/app /app/app/
COPY --from=builder /app/prisma /app/prisma/
COPY --from=builder /app/sites.json /app
RUN apt-get update -y && apt-get install -y openssl
RUN npm install -g npm@10.2.5
# Expose the port the app runs on
EXPOSE 8080
# Command to run the app
CMD ["npm", "start"]

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 Hammer Technologies LLC Copyright (c) 2023 LumeWeb
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@ -1,36 +1,2 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). # web3.news
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -1,48 +0,0 @@
export const ArrowIcon = ({className}: {className?: string}) => {
return (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_61_133)">
<path
d="M3.30432 1.71807L8.28174 1.71826"
stroke="currentColor"
strokeOpacity="0.75"
strokeWidth="2"
strokeMiterlimit="10"
strokeLinecap="square"
/>
<path
d="M8.28175 1.7183L8.28198 6.6958"
stroke="currentColor"
strokeOpacity="0.75"
strokeWidth="2"
strokeMiterlimit="10"
strokeLinecap="square"
/>
<path
d="M8.28149 1.71834L1.25024 8.74951"
stroke="currentColor"
strokeOpacity="0.75"
strokeWidth="2"
strokeMiterlimit="10"
/>
</g>
<defs>
<clipPath id="clip0_61_133">
<rect
width="10"
height="10"
fill="currentColor"
transform="translate(10.0007 9.99951) rotate(-180)"
/>
</clipPath>
</defs>
</svg>
)
}

View File

@ -1,229 +0,0 @@
"use client";
import { formatDate } from "@/utils";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { useEffect, useState } from "react";
import { Article } from "@/lib/prisma";
import { Link, useFetcher } from "@remix-run/react";
const Feed = ({
className,
variant = "col",
icon,
title,
initialData,
}: {
className?: string;
variant?: "row" | "col";
title: string;
icon: keyof typeof ICON_DICT;
initialData: Article[];
}) => {
const filters = ["latest", "day", "week", "month"] as const;
const [content, setContent] = useState<NonNullable<Article[]>>(initialData);
const [selectedFilter, setSelectedFilter] =
useState<(typeof filters)[number]>("latest");
const [currentPage, setCurrentPage] = useState(0);
const [initialLoad, setIsInitialLoad] = useState(true);
const fetcher = useFetcher();
const Icon = ICON_DICT[icon];
useEffect(() => {
if (!initialLoad) {
// Fetch data for subsequent pages
fetcher.load(`/api/feed?filter=${selectedFilter}&page=${currentPage}`);
}
}, [initialLoad, selectedFilter, currentPage]);
useEffect(() => {
if (fetcher.data) {
setContent((prevContent) => [...prevContent, ...fetcher.data.data]);
}
}, [initialLoad, fetcher.data]);
const handleFilterChange = (filter: (typeof filters)[number]) => {
setIsInitialLoad(false);
setContent([]);
setSelectedFilter(filter);
setCurrentPage(0);
};
const handleLoadMore = () => {
setIsInitialLoad(false);
setCurrentPage((prevPage) => prevPage + 1);
};
let activeContent = content;
if (!initialLoad) {
if (fetcher.state === "loading") {
activeContent = [
{
createdAt: new Date(),
updatedAt: new Date(),
title: "Loading",
id: 0,
cid: "",
url: "",
site: "",
},
];
}
}
return (
<section className={`w-full h-full space-y-6 ${className}`}>
<header className="flex flex-row space-x-3 items-start">
<Icon className="text-primary mt-1" />
<nav>
<h3 className="text-primary text-xl">{title}</h3>
<ul className="text-gray-400 text-sm list-none [&>li:hover]:text-white">
{filters.map((filter, index) => (
<li
key={index}
className={`inline cursor-pointer ${
index === filters.length - 1
? ""
: "after:content-['/'] after:mx-1 after:text-gray-400"
} ${
filter === selectedFilter ? "text-white" : "text-gray-400"
}`}
onClick={() => handleFilterChange(filter)}
>
{filter}
</li>
))}
</ul>
</nav>
</header>
<ScrollArea.Root
className={`overflow-hidden w-full h-[400px] rounded-md`}
>
<ScrollArea.Viewport className="w-full h-full">
<div className={`flex gap-4 flex-${variant}`}>
{activeContent.map((item, index) => {
return (
<article
key={index}
className="flex bg-gray-800 flex-col justify-between w-full py-4 px-6 rounded"
>
<span className="inline-block text-gray-500 w-full flex-1">
{formatDate(item.createdAt)}
</span>
<p className="inline-block text-white w-[25ch] flex-auto">
<Link to={`/article/${item.cid}`}>{item.title}</Link>
</p>
</article>
);
})}
{(fetcher.data as any)?.next ? (
<button
className="bg-gray-600 text-gray-300 rounded-md p-2 px-4"
onClick={handleLoadMore}
>
Fetch more
</button>
) : null}
</div>
</ScrollArea.Viewport>
<ScrollArea.ScrollAreaScrollbar
orientation="vertical"
className="flex h-full select-none touch-none p-0.5 bg-gray-500 transition-colors duration-[160ms] ease-out hover:bg-gray-700 data-[orientation=vertical]:w-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:h-2.5"
>
<ScrollArea.ScrollAreaThumb className="flex-1 bg-gray-400 rounded-[10px] relative before:content-[''] before:absolute before:top-1/2 before:left-1/2 before:-translate-x-1/2 before:-translate-y-1/2 before:w-full before:h-full before:min-w-[44px] before:min-h-[44px]" />
</ScrollArea.ScrollAreaScrollbar>
</ScrollArea.Root>
</section>
);
};
export default Feed;
const PaperIcon = ({ className }: { className?: string }) => {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.75 0.75V23.25H20.25V0.75H3.75ZM6.46729 2.95093H9.23364C9.64819 2.95093 9.98364 3.28674 9.98364 3.70093C9.98364 4.11511 9.64819 4.45093 9.23364 4.45093H6.46729C6.05273 4.45093 5.71729 4.11511 5.71729 3.70093C5.71729 3.28674 6.05273 2.95093 6.46729 2.95093ZM17.5327 16.9918H6.46729C6.05273 16.9918 5.71729 16.656 5.71729 16.2418C5.71729 15.8276 6.05273 15.4918 6.46729 15.4918H17.5327C17.9473 15.4918 18.2827 15.8276 18.2827 16.2418C18.2827 16.656 17.9473 16.9918 17.5327 16.9918ZM17.5327 14.1639H6.46729C6.05273 14.1639 5.71729 13.8281 5.71729 13.4139C5.71729 12.9998 6.05273 12.6639 6.46729 12.6639H17.5327C17.9473 12.6639 18.2827 12.9998 18.2827 13.4139C18.2827 13.8281 17.9473 14.1639 17.5327 14.1639ZM17.5327 11.3361H6.46729C6.05273 11.3361 5.71729 11.0002 5.71729 10.5861C5.71729 10.1719 6.05273 9.83606 6.46729 9.83606H17.5327C17.9473 9.83606 18.2827 10.1719 18.2827 10.5861C18.2827 11.0002 17.9473 11.3361 17.5327 11.3361ZM17.5327 8.50818H9.23364C8.81909 8.50818 8.48364 8.17236 8.48364 7.75818C8.48364 7.34399 8.81909 7.00818 9.23364 7.00818H17.5327C17.9473 7.00818 18.2827 7.34399 18.2827 7.75818C18.2827 8.17236 17.9473 8.50818 17.5327 8.50818Z"
fill="currentColor"
/>
</svg>
);
};
const TopArrowLodashIcon = ({ className }: { className?: string }) => {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12.0001 4.97949L16.7458 9.72518H7.25439L12.0001 4.97949Z"
fill="currentColor"
/>
<path
d="M12.7896 22.0004V8.23828H11.2106V22.0004H12.7896Z"
fill="currentColor"
/>
<path
d="M16.7456 3.57846V1.99951L7.25423 1.99951V3.57846L16.7456 3.57846Z"
fill="currentColor"
/>
</svg>
);
};
const TrendUpIcon = ({ className }: { className?: string }) => {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
className={className}
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_61_503)">
<path
d="M23.0769 6L13.5769 15.5L8.5769 10.5L1.0769 18"
stroke="#ACF9C0"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M17.0769 6H23.0769V12"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_61_503">
<rect
width="24"
height="24"
fill="white"
transform="translate(0.0769043)"
/>
</clipPath>
</defs>
</svg>
);
};
const ICON_DICT = {
"paper-icon": PaperIcon,
"trend-up-icon": TrendUpIcon,
"top-arrow-icon": TopArrowLodashIcon,
} as const;

View File

@ -1,17 +0,0 @@
const Root = ({className, children, href}: React.PropsWithChildren & {href:string,className?: string}) => {
return <a href={href} className={`block relative overflow-hidden w-full ${className}`}>{children}</a>
}
const Background = ({className, children}: React.PropsWithChildren & {className?: string}) => {
return <div className={`absolute w-full inset-0 z-0 ${className}`}>{children}</div>
}
const Foreground = ({className, children}: React.PropsWithChildren & {className?: string}) => {
return <div className={`absolute w-full h-full inset-0 z-10 ${className}`}>{children}</div>
}
export {
Root,
Background,
Foreground
}

View File

@ -1,20 +0,0 @@
import { Link } from "@remix-run/react";
import React from "react";
type Props = {};
const Footer = ({}: Props) => {
return (
<div className="w-full mt-5 flex flex-row items-center justify-center text-gray-400">
<Link to="/about" className="hover:text-white hover:underline">
About Web3.news
</Link>
<div className="h-7 w-[1px] bg-current mx-4" />
<Link to="/donate" className="hover:text-white hover:underline">
Contribute to the cause
</Link>
</div>
);
};
export default Footer;

View File

@ -1,68 +0,0 @@
import React from "react";
import { Link } from "@remix-run/react";
import Logo from "@/images/lume-logo-sm.png";
type Props = {};
export const Header = ({}: Props) => {
return (
<header className="w-full flex flex-row justify-between relative">
<div className="flex flex-col">
<Link to="/">
<Web3NewsLogo />
<div className="relative mt-1">
<img
className="-right-8 -top-3 absolute"
width={28}
height={24}
src={Logo}
alt="Lume Web logo"
/>
<span className="right-0 -top-[6px] absolute text-white text-opacity-50 text-sm font-normal font-secondary leading-7">
a Lume project
</span>
</div>
</Link>
</div>
<div className="flex gap-3 font-normal flex-row text-gray-300 rounded">
<Link
to="/about"
className="hover:text-white p-2 px-4 hover:bg-gray-800 rounded"
>
About
</Link>
<Link
to="/donate"
className="hover:text-white p-2 px-4 hover:bg-gray-800 rounded"
>
Contribute
</Link>
</div>
</header>
);
};
const Web3NewsLogo = ({ className }: { className?: string }) => {
return (
<svg
className={className}
width="159"
height="23"
viewBox="0 0 159 23"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.55 0.299999H3.309V16.172L8.703 8.081H11.059L16.391 16.172V0.299999H19.15V22H17.104L9.85 11.367L2.627 22H0.55V0.299999ZM22.2251 22L22.2561 0.299999H36.7641V3.059H25.0151V9.755H33.6331V12.545H25.0151V19.241H36.7641V22H22.2251ZM39.4824 22L39.4514 0.299999H47.7284C53.1844 0.299999 55.8194 6.903 52.0374 10.654C57.2144 14.157 55.0444 22 48.9374 22H39.4824ZM42.2414 9.786H47.7284C52.0064 9.786 52.2234 3.059 47.7284 3.059H42.2414V9.786ZM42.2414 19.241H48.9374C53.2154 19.241 53.4324 12.514 48.9374 12.514H42.2414V19.241ZM62.7651 22C59.9751 22 57.8051 19.768 57.8051 16.761H60.5021C60.5021 18.342 61.5251 19.303 62.7651 19.303H66.7021C71.1661 19.303 71.2281 12.297 66.7021 12.297H63.3541V10.003H66.0511C70.5771 10.003 70.5461 2.997 66.0511 2.997H62.8891C61.6181 2.997 60.5951 4.02 60.5951 5.415H57.9291C57.9291 2.532 60.1611 0.299999 62.8891 0.299999H66.0511C71.6931 0.299999 74.4831 7.771 69.7091 10.902C75.3511 13.909 72.6541 22 66.7021 22H62.7651Z"
fill="white"
/>
<path
d="M77.9783 22.248C76.8003 22.248 75.8703 21.504 75.8703 20.326C75.8703 19.148 76.8003 18.435 77.9783 18.435C79.1253 18.435 80.1173 19.148 80.1173 20.326C80.1173 21.504 79.1253 22.248 77.9783 22.248ZM83.2268 22V0.299999H85.3348L97.9828 16.854V0.299999H100.742V22H98.6028L85.9858 5.477V22H83.2268ZM103.812 22L103.843 0.299999H118.351V3.059H106.602V9.755H115.22V12.545H106.602V19.241H118.351V22H103.812ZM121.038 0.299999H123.797V16.172L129.191 8.081H131.547L136.879 16.172V0.299999H139.638V22H137.592L130.338 11.367L123.115 22H121.038V0.299999ZM142.744 15.614H145.503C145.503 18.218 146.991 19.427 148.944 19.427H152.044C153.408 19.427 156.105 18.59 156.105 15.924C156.105 10.065 143.054 14.064 143.054 6.19C143.054 2.563 145.968 0.175998 148.727 0.175998H152.323C155.733 0.175998 158.337 2.594 158.337 6.624H155.578C155.578 4.082 154.09 2.935 152.106 2.935H148.913C147.208 2.935 145.813 4.237 145.813 6.159C145.813 11.522 158.864 7.306 158.864 16.42C158.864 19.83 155.671 22.186 152.044 22.186H148.944C145.534 22.186 142.744 19.799 142.744 15.614Z"
fill="#ACF9C0"
/>
</svg>
);
};
export default Header;

View File

@ -1,225 +0,0 @@
"use client";
import React, {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { ChevronRightIcon } from "@heroicons/react/24/outline"; // Assuming usage of Heroicons for icons
import { flushSync } from "react-dom";
import { Link, useFetcher, useSearchParams } from "@remix-run/react";
import { FILTER_TIMES, formatDate } from "@/utils";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
import { SitesCombobox } from "./SitesCombobox";
import { SearchResult, SiteList } from "@/types.js";
type Props = {};
const SearchBar = ({ sites }: { sites: SiteList }) => {
let [searchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get("q") ?? "");
const inputRef = useRef<HTMLInputElement>();
const [isLoading, setIsLoading] = useState(false);
const [activeInput, setActiveInput] = useState(true);
const [dirtyInput, setDirtyInput] = useState(false);
const [results, setResults] = useState<SearchResult[]>([]);
const [selectedSite, setSelectedSite] = useState(null);
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const fetcher = useFetcher({ key: "seach" });
const handleSearch = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
doSearch();
},
[query]
);
function doSearch() {
setIsLoading(true);
let newSearchParams = new URLSearchParams(searchParams.toString());
if (query) {
newSearchParams.set("q", query);
} else {
newSearchParams.delete("q");
return;
}
if (selectedSite) {
newSearchParams.set("site", selectedSite);
}
if (selectedTime) {
const timestampInMilliseconds = Date.parse(selectedTime); // Date.parse returns the timestamp in milliseconds
const timestamp = timestampInMilliseconds / 1000;
newSearchParams.set("time", timestamp.toString());
}
fetcher.load(`/api/search?${newSearchParams}`);
}
useEffect(() => {
if (fetcher.data) {
setResults(fetcher.data as SearchResult[]);
setIsLoading(false);
setActiveInput(false);
}
}, [fetcher.data]);
useEffect(() => {
doSearch();
}, [selectedSite, selectedTime]);
const isActive = results.length > 0 || dirtyInput;
return (
<div
className={`w-full mt-8 p-4 border-2 ${
isActive ? "border-sky-300 bg-gray-950" : "border-primary"
}`}
>
<form className={`flex items-center text-lg`} onSubmit={handleSearch}>
{isLoading || isActive ? (
<span className="text-white mr-2">Searching for</span>
) : null}
{activeInput ? (
<fieldset
className={`block w-full p-0 h-auto flex-1 overflow-hidden`}
>
{isActive ? (
<span className="text-blue-300 underline-offset-4 underline mr-[-0.5px]">
{'"'}
</span>
) : (
""
)}
<input
ref={(element) => {
if (element) {
inputRef.current = element;
}
}}
className={`flex-grow inline bg-transparent text-white placeholder-gray-400 outline-none ring-none ${
isActive
? `text-blue-300 p-0 underline underline-offset-4`
: "w-full p-2"
}`}
placeholder={
!isActive
? "Search for web3 news from the community"
: undefined
}
value={query}
size={query ? query.length : 1}
style={
query
? {
width: `calc(${query.length}ch+2px)`,
}
: undefined
}
onChange={(e) => {
if (!dirtyInput) {
setDirtyInput(true);
}
setQuery(e.target.value);
}}
/>
{isActive ? (
<span className="text-blue-300 underline-offset-4 underline ml-[-5.5px]">
{'"'}
</span>
) : (
""
)}
</fieldset>
) : (
<span
className="block w-full flex-1 text-blue-300"
onClick={() => {
flushSync(() => {
setActiveInput(true);
});
inputRef.current?.focus();
}}
>
{'"'}
{query}
{'"'}
</span>
)}
{isLoading ? (
// Shadcn Loading component placeholder
<LoadingComponent />
) : (
<div className="justify-self-end min-w-[220px] flex justify-end gap-2">
{/* Dropdown component should be here */}
<SitesCombobox siteList={sites} onSiteSelect={setSelectedSite} />
{/* Dropdown component should be here */}
<Select
defaultValue={"0"}
onValueChange={(v) => setSelectedTime(v)}
>
<SelectTrigger className="hover:bg-muted w-auto">
<SelectValue placeholder="Time ago" />
</SelectTrigger>
<SelectContent>
{FILTER_TIMES.map((v) => (
<SelectItem
value={String(v.value)}
key={`FilteTimeSelectItem_${v.value}`}
>
{v.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</form>
{results.length > 0 && (
<>
<hr className="my-4 border-1" />
{results.map((item) => (
<Link
to={`/article/${item.slug}`}
key={item.id}
className="flex cursor-pointer flex-row items-center space-x-5 my-2 py-2 px-4 hover:bg-gray-800 rounded-md"
>
<span className="text-sm text-gray-400">
{formatDate(item.timestamp)}
</span>
<h3 className="text-md font-semibold text-white">{item.title}</h3>
</Link>
))}
<Link to={`/search?q=${encodeURIComponent(query)}`}>
<button className="mt-4 flex justify-center items-center bg-secondary w-full py-7 text-white hover:bg-teal-800 transition-colors">
{results.length}+ search results for{" "}
<span className="text-blue-300 ml-1">{query}</span>
<ChevronRightIcon className="w-5 h-5 inline ml-2 mt-[1px]" />
</button>
</Link>
</>
)}
</div>
);
};
// Placeholder components for Shadcn
const LoadingComponent = () => {
// Replace with actual Shadcn Loading component
return <div>Loading...</div>;
};
export default SearchBar;

View File

@ -1,121 +0,0 @@
import React, { FormEvent, useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate } from "@remix-run/react";
import {
Select,
SelectContent,
SelectTrigger,
SelectItem,
SelectValue,
} from "./ui/select";
import { SitesCombobox } from "./SitesCombobox";
import { FILTER_TIMES } from "@/utils";
import { SiteList } from "@/types.js";
type Props = {
value: string;
placeholder?: string;
className?: string;
filters?: {
sites: { value: string; label: string }[];
};
sites: SiteList;
};
const SimplifiedSearchBar = ({
value: initialValue,
placeholder,
filters,
className,
sites,
}: Props) => {
let navigate = useNavigate();
let location = useLocation();
const [value, setValue] = useState<string>(initialValue);
const [selectedSite, setSelectedSite] = useState<any>(null);
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const handleSearch = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
doSearch();
},
[value]
);
function doSearch() {
const newSearchParams = new URLSearchParams(location.search);
if (value) {
newSearchParams.set("q", value);
} else {
newSearchParams.delete("q");
return;
}
if (selectedSite) {
newSearchParams.set("site", selectedSite);
}
if (selectedTime) {
const timestampInMilliseconds = Date.parse(selectedTime); // Date.parse returns the timestamp in milliseconds
const timestamp = timestampInMilliseconds / 1000;
newSearchParams.set("time", timestamp.toString());
}
navigate(`${location.pathname}?${newSearchParams}`);
}
useEffect(() => {
doSearch();
}, [selectedSite, selectedTime]);
return (
<form
className={`flex items-center text-lg border-b border-primary pb-2 ${className}`}
onSubmit={handleSearch}
>
<div className="flex-1 flex flex-row max-w-full">
<span className="text-white mr-2">results: </span>
<fieldset
className={`${
value && value !== ""
? `after:content-['"'] before:content-['"'] relative after:absolute after:-right-1`
: "w-full"
} text-primary ${className}`}
>
<input
className={`flex-grow inline bg-transparent placeholder-gray-400 outline-none ring-none text-primary ${
value && value !== "" ? "" : "w-full"
}`}
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
size={value.length}
/>
</fieldset>
</div>
<div className="w-56 flex gap-2">
{/* Dropdown component should be here */}
<SitesCombobox siteList={sites} onSiteSelect={setSelectedSite} />
{/* Dropdown component should be here */}
<Select defaultValue={"0"} onValueChange={(v) => setSelectedTime(v)}>
<SelectTrigger className="hover:bg-muted w-auto">
<SelectValue placeholder="Time ago" />
</SelectTrigger>
<SelectContent>
{FILTER_TIMES.map((v) => (
<SelectItem
value={String(v.value)}
key={`FilteTimeSelectItem_${v.value}`}
>
{v.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</form>
);
};
export default SimplifiedSearchBar;

View File

@ -1,83 +0,0 @@
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ChevronDownIcon } from "@heroicons/react/24/solid";
import { SelectOptions, SiteList } from "@/types.js";
import { useEffect } from "react";
export function SitesCombobox({
siteList,
onSiteSelect,
}: {
siteList: SiteList;
onSiteSelect: React.Dispatch<React.SetStateAction<any>>;
}) {
const sites = Object.entries(siteList).map((item) => {
return {
label: item[1].name,
value: item[0],
};
});
const [open, setOpen] = React.useState(false);
const [selectedSite, setSelectedSite] = React.useState<SelectOptions | null>(
null
);
useEffect(() => {
onSiteSelect(selectedSite?.value as any);
}, [selectedSite]);
return (
<div className="flex flex- items-center space-x-4">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={"ghost"}
className="max-w-[120px] focus:ring-2 focus:ring-ring px-2 font-bold items-center w-full flex justify-between text-white text-xs uppercase"
>
{selectedSite ? <>{selectedSite.label}</> : <>All Sites</>}
<ChevronDownIcon className="ml-3 w-5 h-5" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" side="right" align="start">
<Command>
<CommandInput placeholder="Select site..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sites?.map((status) => (
<CommandItem
className="text-white"
key={status.value}
value={status.value}
onSelect={(value) => {
setSelectedSite(
sites.find((site) => site.value === value) || null
);
setOpen(false);
}}
>
{status.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}

View File

@ -1,56 +0,0 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -1,155 +0,0 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { MagnifyingGlassIcon as Search } from "@radix-ui/react-icons"
import { cn } from "@/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -1,122 +0,0 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon as X } from "@radix-ui/react-icons"
import { cn } from "@/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -1,31 +0,0 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@ -1,160 +0,0 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/24/outline"
import { cn } from "../../utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md uppercase text-white text-[12px] px-3 font-semibold placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="ml-1 h-5 w-5" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -1,142 +0,0 @@
import { PassThrough } from "node:stream";
import type { AppLoadContext, EntryContext } from "@remix-run/node";
import { createReadableStreamFromReadable } from "@remix-run/node";
import { RemixServer } from "@remix-run/react";
import isbot from "isbot";
import { renderToPipeableStream } from "react-dom/server";
import { createSitemapGenerator } from "remix-sitemap";
const ABORT_DELAY = 5_000;
// Step 1. setup the generator
const { isSitemapUrl, sitemap } = createSitemapGenerator({
siteUrl: "https://web3.news",
generateRobotsTxt: true,
});
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
loadContext: AppLoadContext
) {
if (isSitemapUrl(request)) {
return await sitemap(request, remixContext);
}
return isbot(request.headers.get("user-agent"))
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
);
}
function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}
function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
})
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
}
);
setTimeout(abort, ABORT_DELAY);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,26 +0,0 @@
const initTracking = (): void => {
const _paq: any[] = ((window as any)._paq = (window as any)._paq || []);
const scriptSource: string =
process.env.TRACKING_SCRIPT_URL || "//piwiki.lumeweb.com/";
const siteId: string = process.env.TRACKING_SITE_ID || "4";
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
_paq.push(["setTrackerUrl", scriptSource + "matomo.php"]);
_paq.push(["setSiteId", siteId]);
const d: Document = document;
const g: HTMLScriptElement = d.createElement("script");
const s: HTMLScriptElement = d.getElementsByTagName(
"script"
)[0] as HTMLScriptElement;
g.async = true;
g.src = scriptSource + "matomo.js";
s.parentNode?.insertBefore(g, s);
g.onerror = () => {
console.error("Error loading the tracking script.");
};
};
export default initTracking;

View File

@ -1,56 +0,0 @@
import prisma, { Article } from "@/lib/prisma";
export type ApiResponse<T = Record<string, any>> = {
data: T[];
current: number;
next?: number | null;
amount?: number;
};
export async function fetchFeedData({
filter,
limit = 5,
next = 5,
current = 0,
}: {
filter?: { timerange?: "latest" | "day" | "week" | "month" };
next?: number;
limit?: number;
current?: number;
}): Promise<ApiResponse<Article>> {
let query = {};
if (filter?.timerange && filter.timerange !== "latest") {
const now = new Date();
const timeRanges = {
day: 1,
week: 7,
month: 30, // Approximation for a month, adjust as needed
};
const daysToSubtract = timeRanges[filter.timerange];
if (daysToSubtract !== undefined) {
const dateFrom = new Date(now.setDate(now.getDate() - daysToSubtract));
query = {
where: {
createdAt: {
gte: dateFrom,
},
},
};
}
}
const articles = await prisma.article.findMany({
...query,
skip: current,
take: next,
});
const nextPointer = articles.length >= limit ? next + limit : null;
return {
data: articles,
current: next,
next: nextPointer,
amount: articles.length,
};
}

View File

@ -1,76 +0,0 @@
import OGImage from "../images/og_default.png";
import type {
ServerRuntimeMetaArgs,
ServerRuntimeMetaDescriptor,
} from "@remix-run/server-runtime";
interface GenerateMetaTagsParams {
title: string;
description: string;
imageUrl?: {};
ogType?: "website" | "article";
parentMeta?: ServerRuntimeMetaArgs;
}
export function generateMetaTags({
title,
description,
imageUrl = OGImage,
ogType = "website",
parentMeta,
}: GenerateMetaTagsParams) {
const metaTags: Record<string, ServerRuntimeMetaDescriptor> = {};
// Helper function to generate a unique key for each meta tag
const generateKey = (tag: ServerRuntimeMetaDescriptor): string => {
if ("name" in tag && tag.name) {
return `name:${tag.name}`;
}
if ("property" in tag && tag.property) {
return `property:${tag.property}`;
}
if ("httpEquiv" in tag && tag.httpEquiv)
return `httpEquiv:${tag.httpEquiv}`;
if ("charSet" in tag) {
return "charSet";
}
if ("title" in tag) {
return "title";
}
if ("script:ld+json" in tag) {
return "script:ld+json";
}
if ("tagName" in tag && tag.tagName) {
return `tagName:${tag.tagName}`;
}
// Fallback for complex or unknown types - convert to JSON string
return `unknown:${JSON.stringify(tag)}`;
};
// Merge parent meta tags
parentMeta?.matches?.forEach((match) => {
match.meta?.forEach((tag) => {
const key = generateKey(tag);
metaTags[key] = tag;
});
});
// Define new meta tags and add or overwrite them in the metaTags object
const newMetaTags: ServerRuntimeMetaDescriptor[] = [
{ title },
{ name: "title", content: title },
{ name: "description", content: description },
{ name: "og:title", content: title },
{ name: "og:description", content: description },
{ name: "og:image", content: imageUrl },
{ name: "og:type", content: ogType },
{ name: "twitter:card", content: "summary" },
{ name: "twitter:title", content: title },
];
newMetaTags.forEach((tag) => {
const key = generateKey(tag);
metaTags[key] = tag;
});
return Object.values(metaTags);
}

View File

@ -1,34 +0,0 @@
import { PrismaClient, Article } from "@prisma/client";
import search from "./search.js";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = (globalForPrisma.prisma || new PrismaClient()).$extends({
query: {
article: {
async create({ args, query }) {
// Perform the create operation using Prisma
const createdRecord = await query(args);
// Index the created record in MeiliSearch
const index = search.index("articles");
const timestampInMilliseconds = Date.parse(
createdRecord.createdAt as any
); // Date.parse returns the timestamp in milliseconds
const timestamp = timestampInMilliseconds / 1000;
await index.addDocuments([
{
...createdRecord,
createdTimestamp: timestamp,
},
]);
return createdRecord;
},
},
},
});
export default prisma;
export type { Article };

View File

@ -1,5 +0,0 @@
import { MeiliSearch } from "meilisearch";
const meili = new MeiliSearch({ host: "http://melli:7700" });
export default meili;

View File

@ -1,56 +0,0 @@
import { LinksFunction, MetaFunction } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import Header from "@/components/LayoutHeader"; // Adjust the import path as needed
import Footer from "@/components/LayoutFooter"; // Adjust the import path as needed
import "../styles/global.scss";
import { cssBundleHref } from "@remix-run/css-bundle"; // Adjust the import path as needed
import "unfonts.css";
import initTracking from "@/lib/analytics.js";
import { useEffect } from "react";
export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];
export const meta: MetaFunction = () => [
{
charset: "utf-8",
},
{ viewport: "width=device-width,initial-scale=1" },
];
export default function Root() {
useEffect(() => {
if (process.env.NODE_ENV === "production") {
initTracking();
}
}, []);
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body className={`font-main bg-gray-900 flex`}>
<main className="dark flex w-full min-h-screen flex-col md:px-40 items-center py-16 mx-auto">
<Header />
<Outlet />
<Footer />
</main>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}

View File

@ -1,84 +0,0 @@
import { ArrowIcon } from "@/components/ArrowIcon";
import * as GraphicSection from "@/components/GraphicSection";
import Logo from "@/images/lume-logo-bg.png";
import { generateMetaTags } from "@/lib/meta.js";
import type { ServerRuntimeMetaArgs } from "@remix-run/server-runtime";
export function meta(meta: ServerRuntimeMetaArgs) {
const title = "About - web3.news: Uniting Web3 Community";
const description =
"About Web3.news: Uniting the Web3, Crypto, and DeFi communities. We're dedicated to fostering collaboration, privacy, and financial freedom for all.";
return [
...generateMetaTags({
title: title,
description: description,
parentMeta: meta,
}),
];
}
export default function Page() {
return (
<span className="px-8 block text-gray-400 space-y-5">
<h1 className="text-primary text-3xl leading-9">
Web3.news: Your Community-Focused FOSS News Platform
</h1>
<p className="mt-3 leading-7">
At Web3.news, we're dedicated to addressing a vital need in the Web3,
Crypto, and DeFi ecosystems: providing a{" "}
<b>trusted, single-source platform </b>
for community-centered news. Born from the vision inspired by Satoshi's
pioneering work, we strive to uphold the principles of{" "}
<b>financial freedom </b>
and <b>free speech</b>.
</p>
<p className="mt-3 leading-7">
We aim to transcend tribalism, a challenging but vital goal, fostering a
spirit of <b>unity </b> among diverse factions within the Web3 culture.
Our platform is a space where cyberpunks, lunarpunks, solarpunks, and
all enthusiasts collaborate in advancing technology that safeguards our{" "}
<b>civil liberties</b>, especially <b>privacy</b>, while steering clear
of the usual hype and overstatement.
</p>
<p className="mt-3 leading-7">
Web3.news is more than a platform; it's a <b>sanctuary </b> for everyone
in the community, a place where <b>collaboration and innovation </b>{" "}
take precedence over competition and division.
</p>
<p className="mt-3 leading-7">
Join us in our mission to elevate Web3. Together, we can shape a future
that emphasizes <b>collective advancement </b> and{" "}
<b>universal empowerment</b>, where progress is shared and{" "}
<b>everyone has a voice</b>.
</p>
<GraphicSection.Root
href="https://lumeweb.com"
className="h-[300px] border border-gray-800 cursor-pointer [&:hover_.link]:underline [&:hover_.background]:rotate-12 [&:hover_.background]:scale-110"
>
<GraphicSection.Background>
<img
src={Logo}
className="background opacity-50 transition-transform duration-500 transform-gpu absolute -top-[100px] -left-10"
alt="Logo background"
aria-hidden
/>
</GraphicSection.Background>
<GraphicSection.Foreground>
<div className="flex flex-col items-start justify-center h-full w-[500px] float-right mr-20">
<p className="text-white text-2xl">
WEB3.NEWS is a project by <span className="underline">Lume</span>.
Lets build an open, user-owned web together.
</p>
<div className="link mt-2 flex text-gray-400">
<span>Learn more about Lume and join our community</span>
<ArrowIcon className=" ml-2 mt-2" />
</div>
</div>
</GraphicSection.Foreground>
</GraphicSection.Root>
</span>
);
}

View File

@ -1,83 +0,0 @@
import { Link } from "@remix-run/react";
import { generateMetaTags } from "@/lib/meta.js";
import type { ServerRuntimeMetaArgs } from "@remix-run/server-runtime";
export function meta(meta: ServerRuntimeMetaArgs) {
const title = "Support - web3.news: Empowering a User-Owned Web";
const description =
"Support an open web with web3.news. Contribute to our ad-free, user-focused platform and fuel community-led innovation.";
return [
...generateMetaTags({
title: title,
description: description,
parentMeta: meta,
}),
];
}
export default function Page() {
return (
<article className="px-8 block text-gray-400 space-y-5">
<h1 className="text-primary text-3xl leading-9">
Building an Open, User-Owned Web Together
</h1>
<p className="text-white mt-3 leading-7">
Our mission is clear:{" "}
<strong>to create a web that is owned and shaped by its users</strong>.
If we don't take the lead, others <strong>will</strong> shape it for us.
</p>
<p>
Operated under the Lume Web project, web3.news exists for the
community's benefit and is developed under the MIT license. Were
committed to <strong>transparency and open collaboration</strong>.
</p>
<p>
At web3.news, we are dedicated to{" "}
<strong>building technology, fostering community,</strong> and{" "}
<strong>providing education</strong>. Our vision is powered by your
participation and support.
</p>
<p>
Currently, web3.news operates thanks to your generous donations and
grant funding, at this time primarily from the{" "}
<Link to="https://sia.tech/" className="text-white">
<strong>Sia Foundation</strong>
</Link>
, a 501c3 blockchain foundation focused on decentralized storage. This
significant support aligns closely with our mission of{" "}
<strong>building an open and decentralized web</strong>. These
contributions are vital for our ongoing operations and development,
ensuring web3.news remains a vibrant and valuable resource for everyone,
and paving the way for our future growth and sustainability.
</p>
<p>
The support from the Sia Foundation not only bolsters our present
efforts but also aligns with our vision of leveraging blockchain
technology for{" "}
<strong>decentralized information sharing and storage</strong>.
</p>
<p>
Looking to the future, the Lume Web project is set to introduce
separate, community-supported paid products. This strategic move aims to
ensure the <strong>long-term sustainability of web3.news</strong> and
the ongoing development of innovative solutions. Aligned with our core
values and community focus, these products are envisioned as providing
essential services and value back to the community, shaped in part by
your feedback and participation.
</p>
<p>
We pledge to maintain web3.news as a platform{" "}
<strong>free from sponsorships and advertising</strong>, focusing on
solving problems through collaboration and innovation.
</p>
<p>
<strong>Join us in shaping the future of Web3.</strong>
</p>
<Link to="https://lumeweb.com/donate" aria-label="Support Our Mission">
<button className="my-6 p-8 text-gray-500 bg-gray-800 hover:bg-gray-800/70">
Support Our Mission
</button>
</Link>
</article>
);
}

View File

@ -1,65 +0,0 @@
import { ArrowLeftIcon } from "@radix-ui/react-icons";
import { Link, Outlet, useLocation } from "@remix-run/react";
const TEXT_DICT = {
"/about": {
headline: "Sharing community news on the open, user-owned web you deserve.",
tagline: "Learn about our community",
},
"/donate": {
headline:
"We think people should have free access to information no matter how they choose to access it.",
tagline: "Help us break the pattern",
},
};
export default function Layout() {
const { pathname } = useLocation();
const text = TEXT_DICT[pathname! as "/about" | "/donate"];
return (
<section className="w-full">
<header className="text-white mt-10 pb-3 border-b-2 border-primary">
<h2>{text.headline}</h2>
</header>
<Link to="/">
<button className="my-4 -ml-3 px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded">
<ArrowLeftIcon className="w-4 h-4 inline mr-2 -mt-1" />
Back to Home
</button>
</Link>
<div className="flex flex-row mt-6">
<aside className="w-1/3">
<h3 className="text-white mb-5">{text.tagline}</h3>
<nav className="sticky top-3">
<ol>
<AsideItem href="/about" title="About Web3.News" />
<AsideItem href="/donate" title="Donate" />
</ol>
</nav>
</aside>
<section className="w-full">
<Outlet />
</section>
</div>
</section>
);
}
const AsideItem = ({ title, href }: { title: string; href: string }) => {
const { pathname } = useLocation();
return (
<Link to={href}>
<li>
<button
className={`w-[calc(100%-20px)] mb-3 p-8 text-gray-500 bg-gray-800 text-start ${
pathname === href
? "border border-primary text-primary bg-transparent"
: "hover:bg-gray-800/70"
}`}
>
{title}
</button>
</li>
</Link>
);
};

View File

@ -1,96 +0,0 @@
import Feed from "@/components/Feed";
import SearchBar from "@/components/SearchBar";
import { ApiResponse, fetchFeedData } from "@/lib/feed";
import * as GraphicSection from "@/components/GraphicSection";
import { ArrowIcon } from "@/components/ArrowIcon";
import { Article } from "@/lib/prisma";
import Logo from "@/images/lume-logo-bg.png";
import { json, LoaderFunction, redirect } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { SiteList } from "@/types.js";
import { getAvailableSites } from "@/utils";
import { generateMetaTags } from "@/lib/meta.js";
import type { ServerRuntimeMetaArgs } from "@remix-run/server-runtime";
type LoaderData = {
data: ApiResponse<Article>;
sites: SiteList;
};
export function meta(meta: ServerRuntimeMetaArgs) {
const title =
"web3.news - Your Community Hub for Web3, DeFi, and the Open Web";
const description =
"Discover Web3.news: Your hub for community-driven FOSS news in Web3, Crypto, and DeFi. Engage in collaboration, innovation, and uphold free speech and privacy.";
return [
...generateMetaTags({
title: title,
description: description,
parentMeta: meta,
}),
];
}
export let loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const referer = request.headers.get("referer");
const queryParam = url.searchParams.get("q");
// Handle redirection based on referer and query parameters
if (!referer && queryParam) {
return redirect(`/search?q=${queryParam}`);
}
// Fetch your data here
const data = await fetchFeedData({});
const sites = getAvailableSites();
// Return the fetched data as JSON
return json({ data, sites });
};
export default function Index() {
let { data, sites } = useLoaderData<LoaderData>();
return (
<>
<SearchBar sites={sites} />
<div className="space-y-8 w-full my-10">
<div className="flex flex-row flex-wrap justify-between w-full">
<Feed
title="Latest from the community"
icon={"paper-icon"}
className="w-[calc(33%-20px)] max-w-md"
initialData={data.data}
/>
</div>
<GraphicSection.Root
href="https://lumeweb.com"
className="h-[100px] border border-gray-800 cursor-pointer [&:hover_.link]:underline [&:hover_.background]:rotate-12 [&:hover_.background]:scale-110"
>
<GraphicSection.Background>
<img
src={Logo}
className="background transition-transform duration-500 transform-gpu absolute -top-[320px] -right-10"
alt="Logo background"
aria-hidden
/>
</GraphicSection.Background>
<GraphicSection.Foreground>
<div className="mt-5 flex flex-col items-center justify-center">
<p className="text-white text-lg">
WEB3.NEWS is a project by Lume. Lets build an open, user-owned
web together.
</p>
<div className="link flex text-gray-400">
<span>Learn more about Lume and join our community</span>
<ArrowIcon className=" ml-2 mt-2" />
</div>
</div>
</GraphicSection.Foreground>
</GraphicSection.Root>
</div>
</>
);
}

View File

@ -1,97 +0,0 @@
import {
json,
LoaderFunction,
ActionFunction,
ActionFunctionArgs,
} from "@remix-run/node";
import { S5Client } from "@lumeweb/s5-js";
import xml2js from "xml2js";
import { prisma } from "@/lib/prisma";
import path from "path";
import { getAvailableSites } from "@/utils.js";
import { CID } from "@lumeweb/libs5";
// Action function for POST requests
export async function action({ request }: ActionFunctionArgs) {
const client = new S5Client("https://s5.web3portal.com");
const data = await request.json();
const site = data.site;
const sites = getAvailableSites();
if (!(site in sites)) {
throw new Response("Site does not exist", { status: 404 });
}
const siteInfo = sites[site];
const meta = (await client.getMetadata(data.cid as string)) as any;
const fileMeta = meta.metadata as any;
const paths = fileMeta.paths as {
[file: string]: {
cid: string;
};
};
// Check if the RSS feed path exists in the paths
if (!(siteInfo.rss in paths)) {
throw new Response("RSS feed not found", { status: 404 });
}
// Download and parse the RSS feed
const rssData = await client.downloadData(paths[siteInfo.rss].cid);
const rss = await xml2js.parseStringPromise(rssData);
// Process each item in the RSS feed
for (const item of rss.rss.channel[0].item) {
const url = item.link[0];
const title = item.title[0]; // Title is directly available from the feed
let pathname = new URL(url).pathname;
// Normalize and remove leading and trailing slashes from the path
pathname = path.normalize(pathname).replace(/^\/|\/$/g, "");
// Function to determine if a URL path represents a directory
const isDirectory = (pathname: string) => {
return !paths.hasOwnProperty(pathname);
};
// Check if the path is a directory and look for a directory index
if (isDirectory(pathname)) {
for (const file of fileMeta.tryFiles) {
const indexPath = path.join(pathname, file);
if (paths.hasOwnProperty(indexPath)) {
pathname = indexPath;
break;
}
}
}
const cid = paths[pathname]?.cid;
if (cid) {
const exists = await prisma.article.findUnique({
where: { cid },
});
if (!exists) {
const record = {
title,
url,
cid: CID.decode(cid).toString(),
createdAt: new Date(),
updatedAt: new Date(),
site: data.site,
};
// Insert a new record into the database
await prisma.article.create({
data: record,
});
}
}
}
return new Response("", { status: 200 });
}

View File

@ -1,44 +0,0 @@
import { json, LoaderFunctionArgs } from "@remix-run/node";
import { fetchFeedData } from "@/lib/feed";
type Filter = "latest" | "day" | "week" | "month";
export async function loader({ request }: LoaderFunctionArgs) {
const search = new URL(request.url).searchParams;
let filter: Filter | null = null;
let page = "0";
const searchEntries = Array.from(search.entries());
if (searchEntries.length) {
const searchParams = Object.fromEntries(searchEntries);
({ filter, page = "0" } = searchParams as any as {
filter: Filter;
page: string;
});
}
try {
// Define the limit of articles per page
const limit = 5;
// Calculate the `current` and `next` values based on the `page` parameter
const current = parseInt(page, 10) * limit;
const next = limit;
// Prepare the parameters for fetchFeedData
const queryParams = {
filter: filter ? { timerange: filter } : undefined,
next,
limit,
current,
};
// Fetch data using the fetchFeedData function
const dataResponse = await fetchFeedData(queryParams);
return json(dataResponse);
} catch (error) {
throw new Response("Internal Server Error", {
status: 500,
});
}
}

View File

@ -1,40 +0,0 @@
import { type LoaderFunctionArgs } from "@remix-run/node";
import search from "@/lib/search.js";
export async function loader({ request }: LoaderFunctionArgs) {
const searchParams = new URL(request.url).searchParams;
const query = searchParams.get("q");
if (!query || !query.length) {
throw new Response("Invalid query", {
status: 400,
});
}
const site = searchParams.get("site");
const time = searchParams.get("time");
let filters = [];
if (site) {
filters.push(`site = ${site}`);
}
if (time) {
filters.push(`createdTimestamp >= ${parseInt(time).toString()}`);
}
const results = await search.index("articles").search(query, {
filter: filters.length ? filters.join(" AND ") : undefined,
});
return results.hits.map((item) => {
return {
id: item.id,
timestamp: item.createdAt,
title: item.title,
description: "",
slug: item.slug,
};
});
}

View File

@ -1,66 +0,0 @@
import { useLoaderData, Link } from "@remix-run/react";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { prisma } from "@/lib/prisma";
import { LoaderFunctionArgs } from "@remix-run/node";
import { Article } from "@prisma/client";
import { generateMetaTags } from "@/lib/meta.js";
import type { ServerRuntimeMetaArgs } from "@remix-run/server-runtime";
export function meta(meta: ServerRuntimeMetaArgs<Article>) {
const data = meta.data as Article;
const title = `${data.title} - web3.news`;
const description = `Read "${data.title}" on web3.news. Dive into insightful Web3 and blockchain discussions and stay updated with the latest trends and developments.`;
return [
...generateMetaTags({
title: title,
description: description,
imageUrl: undefined,
ogType: "article",
parentMeta: meta,
}),
{ name: "og:url", content: data.url },
{ name: "twitter:url", content: data.url },
{ name: "canonical", content: data.url },
];
}
// Loader function to fetch article data
export async function loader({ params }: LoaderFunctionArgs) {
const { cid } = params;
const article = await prisma.article.findUnique({
where: { cid },
});
if (!article) {
throw new Response("Not Found", { status: 404 });
}
return article;
}
const Page = () => {
// Use the loader data
const article = useLoaderData() as unknown as Article;
return (
<>
<Link to="/" className="w-full mt-1">
<button className="px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded">
<ArrowLeftIcon className="w-4 h-4 inline mr-2 -mt-1" />
Go Back Home
</button>
</Link>
<div className="w-full min-h-[calc(100%-80px)] !h-full !mt-1 !mb-0">
<iframe
className="w-full h-full"
src={article.url}
title={article.title}
></iframe>
</div>
</>
);
};
export default Page;

View File

@ -1,90 +0,0 @@
import React from "react";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { formatDate, getAvailableSites, getResults } from "@/utils";
import SimplifiedSearchBar from "@/components/SimplifiedSearchBar";
import { Link, useLoaderData, useSearchParams } from "@remix-run/react";
import { json, LoaderFunction } from "@remix-run/node";
import type { SearchResult, SiteList } from "@/types.js";
import { generateMetaTags } from "@/lib/meta.js";
import type { ServerRuntimeMetaArgs } from "@remix-run/server-runtime";
type LoaderData = {
sites: SiteList;
results: SearchResult[];
query: string;
};
export function meta(meta: ServerRuntimeMetaArgs) {
const title = "Search - web3.news: Discover Community Web3 News";
const description =
"Find the latest Web3, Crypto, and DeFi news easily with web3.news search. Dive into community-driven, cutting-edge content.";
return [
...generateMetaTags({
title: title,
description: description,
parentMeta: meta,
}),
];
}
export let loader: LoaderFunction = async ({ request }) => {
const sites = getAvailableSites();
const search = new URL(request.url).searchParams;
const query = search.get("q") ?? "";
const site = search.get("site") ?? undefined;
const time = search.get("time") ?? undefined;
const results = await getResults({ query: query, site, time });
// Return the fetched data as JSON
return json({ sites, results, query });
};
const Page = () => {
const { sites, results, query } = useLoaderData<LoaderData>();
return (
<div className="w-full items-center text-lg">
<SimplifiedSearchBar
value={query}
placeholder={
query ? undefined : "Search for web3 news from the community"
}
sites={sites}
/>
<Link to="/">
<button className="my-4 -ml-3 px-3 py-2 text-gray-400 hover:bg-gray-800 hover:text-white rounded">
<ArrowLeftIcon className="w-4 h-4 inline mr-2 -mt-1" />
Go Back Home
</button>
</Link>
{results.length > 0 && (
<>
{results.map((item) => (
<Link
to={`/article/${item.slug}`}
key={item.id}
className="flex cursor-pointer flex-row items-center space-x-5 my-2 py-2 px-4 hover:bg-gray-800 rounded-md"
>
<span className="text-sm text-gray-400">
{formatDate(item.timestamp)}
</span>
<h3 className="text-md font-semibold text-white">
{" "}
<Link to={`/article/${item.cid}`}>{item.title}</Link>
</h3>
</Link>
))}
<Link to={`/search?q=${encodeURIComponent(query)}`}>
<button className="rounded mt-4 flex justify-center items-center bg-gray-800 mx-auto w-44 py-7 text-white hover:bg-gray-800/50 transition-colors">
Load More
</button>
</Link>
</>
)}
</div>
);
};
export default Page;

21
app/types.d.ts vendored
View File

@ -1,21 +0,0 @@
export type SearchResult = {
id: number;
timestamp: Date;
title: string;
description: string;
slug: string;
cid: string;
};
export type SelectOptions = {
value: string;
label: string;
};
export type SiteList = {
[domain: string]: {
pubkey: string;
name: string;
rss: string;
};
};

View File

@ -1,76 +0,0 @@
import { formatDistanceToNow, subDays, subMonths, subYears } from "date-fns";
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { SearchResult, SiteList } from "@/types.js";
import fs from "fs";
import search from "./lib/search.js";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Utility function to format dates
export const formatDate = (date: string | Date) => {
const _date = new Date(date);
const distance = formatDistanceToNow(_date, { addSuffix: true });
return distance
.replace(/less than a minute?/, "<1m")
.replace(/ minutes?/, "m")
.replace(/ hours?/, "h")
.replace(/ days?/, "d")
.replace(/ weeks?/, "w");
};
export async function getResults({
query,
site,
time,
}: {
query?: string;
site?: string;
time?: string;
}): Promise<SearchResult[]> {
if (!query) {
return [];
}
let filters = [];
if (site) {
filters.push(`site = ${site}`);
}
if (time) {
filters.push(`createdTimestamp >= ${parseInt(time).toString()}`);
}
const results = await search.index("articles").search(query, {
filter: filters.length ? filters.join(" AND ") : undefined,
});
return results.hits.map((item) => {
return {
id: item.id,
cid: item.cid,
timestamp: item.createdAt,
title: item.title,
description: "",
slug: item.slug,
};
});
}
export function getAvailableSites() {
return JSON.parse(
fs.readFileSync("sites.json", { encoding: "utf8" })
) as SiteList;
}
export const FILTER_TIMES = [
{ value: 0, label: "All Times" },
{ value: subDays(new Date(), 1), label: "1d ago" },
{ value: subDays(new Date(), 7), label: "7d ago" },
{ value: subDays(new Date(), 15), label: "15d ago" },
{ value: subMonths(new Date(), 1), label: "1m ago" },
{ value: subMonths(new Date(), 6), label: "6m ago" },
{ value: subYears(new Date(), 1), label: "1y ago" },
];

View File

@ -1,43 +0,0 @@
version: '3.8'
services:
app:
image: git.lumeweb.com/lumeweb/web3.news:latest
volumes:
- ./data/app.db:/app/prisma/dev.db
bridge:
image: git.lumeweb.com/lumeweb/web3.news:latest
command: ["npm", "run", "bridge"]
volumes:
- ./data/app.db:/app/prisma/dev.db
melli:
image: getmeili/meilisearch:v1.5
volumes:
- ./melli_data:/meili_data
caddy:
image: iarekylew00t/caddy-cloudflare
command: caddy run --config=/etc/caddy/Caddyfile
env_file:
- .env
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./caddy/data/:/data
- ./caddy/config/Caddyfile:/etc/caddy/Caddyfile
cap_add:
- NET_ADMIN
melli_init:
image: git.lumeweb.com/lumeweb/web3.news:latest
command: ["npm", "run", "melli-init"]
volumes:
- ./data/app.db:/app/prisma/dev.db
profiles: ["disabled"]
depends_on:
- melli
db_init:
image: git.lumeweb.com/lumeweb/web3.news:latest
command: ["npm", "run", "db-init"]
volumes:
- ./data/app.db:/app/prisma/dev.db
profiles: ["disabled"]

2
env.d.ts vendored
View File

@ -1,2 +0,0 @@
/// <reference types="@remix-run/dev" />
/// <reference types="@remix-run/node" />

15005
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,77 +0,0 @@
{
"name": "web3.news",
"version": "0.1.0",
"private": true,
"type": "module",
"prisma": {
"seed": "ts-node-esm prisma/seed.mts"
},
"scripts": {
"build": "run-s \"build:*\"",
"build:remix": "NODE_ENV=production remix vite:build",
"build:prisma": "prisma generate",
"dev": "run-p \"dev:*\"",
"dev:remix": "remix vite:dev",
"start": "PORT=8080 remix-serve ./build/server/index.js",
"typecheck": "tsc",
"bridge": "bun run scripts/bridge.mts",
"melli-init": "bun run scripts/melli-init.mts",
"db-init": "prisma db push",
"postinstall": "patch-package"
},
"dependencies": {
"@fontsource/be-vietnam-pro": "^5.0.18",
"@fontsource/jaldi": "^5.0.8",
"@heroicons/react": "^2.0.18",
"@lumeweb/s5-js": "^0.1.0-develop.27",
"@prisma/client": "^5.6.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@remix-run/css-bundle": "^2.4.0",
"@remix-run/node": "^2.4.1",
"@remix-run/react": "^2.4.1",
"@remix-run/serve": "^2.4.1",
"class-variance-authority": "^0.7.0",
"clsx": "^2.0.0",
"cmdk": "^0.2.0",
"date-fns": "^2.30.0",
"isbot": "^3.7.1",
"meilisearch": "^0.36.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix-sitemap": "^3.2.0",
"swr": "^2.2.4",
"tailwind-merge": "^2.0.0",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@faker-js/faker": "^8.3.1",
"@remix-run/dev": "^2.4.1",
"@scure/bip39": "^1.2.2",
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/xml2js": "^0.4.14",
"autoprefixer": "^10.0.1",
"bun": "^1.0.20",
"ed25519-keygen": "^0.4.10",
"eslint": "^8",
"eslint-config-next": "14.0.2",
"npm-run-all": "^4.1.5",
"patch-package": "^8.0.0",
"postcss": "^8",
"prisma": "^5.6.0",
"sass": "^1.69.5",
"tailwindcss": "^3.3.0",
"tailwindcss-animate": "^1.0.7",
"ts-node": "^10.9.1",
"typescript": "^5.2.2",
"unplugin-fonts": "^1.1.1",
"vite": "^5.0.10",
"vite-tsconfig-paths": "^4.2.2"
}
}

View File

@ -1,54 +0,0 @@
diff --git a/node_modules/@remix-run/serve/dist/.rej b/node_modules/@remix-run/serve/dist/.rej
new file mode 100644
index 0000000..bcb1d11
--- /dev/null
+++ b/node_modules/@remix-run/serve/dist/.rej
@@ -0,0 +1,23 @@
+--- dist/cli.js
++++ dist/cli.js
+@@ -41,13 +41,15 @@ var getPort__default = /*#__PURE__*/_interopDefaultLegacy(getPort);
+ process.env.NODE_ENV = process.env.NODE_ENV ?? "production";
+ sourceMapSupport__default["default"].install({
+ retrieveSourceMap: function (source) {
+- // get source file with the `file://` prefix
+- let match = source.match(/^file:\/\/(.*)$/);
+- if (match) {
+- let filePath = url__default["default"].fileURLToPath(source);
++ let filePath = source;
++ if (filePath.startsWith("file://")) {
++ filePath = url__default["default"].fileURLToPath(source);
++ }
++ let mapPath = `${filePath}.map`;
++ if (fs__default["default"].existsSync(mapPath)) {
+ return {
+ url: source,
+- map: fs__default["default"].readFileSync(`${filePath}.map`, "utf8")
++ map: fs__default["default"].readFileSync(mapPath, "utf8")
+ };
+ }
+ return null;
diff --git a/node_modules/@remix-run/serve/dist/cli.js b/node_modules/@remix-run/serve/dist/cli.js
index fa63111..6647eea 100755
--- a/node_modules/@remix-run/serve/dist/cli.js
+++ b/node_modules/@remix-run/serve/dist/cli.js
@@ -41,13 +41,15 @@ var getPort__default = /*#__PURE__*/_interopDefaultLegacy(getPort);
process.env.NODE_ENV = process.env.NODE_ENV ?? "production";
sourceMapSupport__default["default"].install({
retrieveSourceMap: function (source) {
- // get source file with the `file://` prefix
- let match = source.match(/^file:\/\/(.*)$/);
- if (match) {
- let filePath = url__default["default"].fileURLToPath(source);
+ let filePath = source;
+ if (filePath.startsWith("file://")) {
+ filePath = url__default["default"].fileURLToPath(source);
+ }
+ let mapPath = `${filePath}.map`;
+ if (fs__default["default"].existsSync(mapPath)) {
return {
url: source,
- map: fs__default["default"].readFileSync(`${filePath}.map`, "utf8")
+ map: fs__default["default"].readFileSync(mapPath, "utf8")
};
}
return null;

View File

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@ -1,20 +0,0 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Article {
@@map("articles")
id Int @id @default(autoincrement())
title String
cid String @unique
url String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
site String
}

View File

@ -1,35 +0,0 @@
import {PrismaClient} from "@prisma/client";
import {faker} from "@faker-js/faker";
const prisma = new PrismaClient();
async function main() {
const numberOfArticles = 100; // Specify the number of articles to generate
const articles = [];
for (let i = 0; i < numberOfArticles; i++) {
const title = faker.lorem.sentence();
const slug = faker.helpers.slugify(title).toLowerCase();
const url = faker.internet.url();
const siteKey = faker.string.alphanumeric(10);
articles.push({title, slug, cid: Math.random().toString(), url, siteKey});
}
for (const article of articles) {
await prisma.article.create({
data: article,
});
}
console.log(`${articles.length} articles created.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,27 +0,0 @@
import { S5Client } from "@lumeweb/s5-js";
import { CID } from "@lumeweb/libs5";
import axios from "axios";
import { getAvailableSites } from "../app/utils.js";
const sites = getAvailableSites();
const client = new S5Client("https://s5.web3portal.com");
const httpClient = axios.create({
baseURL: "http://app:8080",
});
for (const siteName in sites) {
const site = sites[siteName];
const sub = await client.subscribeToEntry(Buffer.from(site.pubkey, "hex"));
sub.listen((entry) => {
const cid = CID.fromRegistry(entry.data);
try {
httpClient.post("/api/events/siteUpdateReceived", {
cid: cid.toString(),
site: siteName,
});
} catch {}
});
}

View File

@ -1,12 +0,0 @@
import { HDKey } from "ed25519-keygen/hdkey";
import * as bip39 from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";
const BIP44_PATH = "m/44'/1627'/0'/0'/0'";
const mn = bip39.generateMnemonic(wordlist);
console.log(mn);
const hdkey = HDKey.fromMasterSeed(await bip39.mnemonicToSeed(mn));
console.log(Buffer.from(hdkey.derive(BIP44_PATH).publicKeyRaw).toString("hex"));

View File

@ -1,7 +0,0 @@
import search from "../app/lib/search";
await search.createIndex("articles", { primaryKey: "id" });
const index = search.index("articles");
await index.updateFilterableAttributes(["site", "createdTimestamp"]);

View File

@ -1,7 +0,0 @@
{
"blog.lumeweb.com": {
"name": "Lume Web News",
"pubkey": "2F66BFFAD819A6BF221D26EE0FFE75E48D154F13761EAAE575896F17DBDA5FD3",
"rss": "post/index.xml"
}
}

View File

@ -1,76 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 248, 45%, 7%;
--popover-foreground: 0, 0%, 100%;
--primary: 136, 87%, 83%;
--primary-foreground: black;
--secondary: 169 46% 37%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 0 0% 32%;
--muted-foreground: 0 0% 32%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 0 0% 32%;
--input: 0 0% 32%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 248, 45%, 7%;
--popover-foreground: 0, 0%, 100%;
--primary: 136, 87%, 83%;
--primary-foreground: black;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-main;
}
}

View File

@ -1,78 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
main: '"Be Vietnam Pro"',
secondary: '"Jaldi"',
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;

View File

@ -1,36 +0,0 @@
{
"include": [
"env.d.ts",
"**/*.ts",
"**/*.tsx"
],
"compilerOptions": {
"lib": [
"DOM",
"DOM.Iterable",
"ES2022"
],
"isolatedModules": true,
"esModuleInterop": true,
"jsx": "react-jsx",
"resolveJsonModule": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2022",
"strict": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./app/*"
],
"~/*": [
"./app/*"
]
},
// Remix takes care of building everything in `remix build`.
"noEmit": true,
"types": ["unplugin-fonts/client"]
}
}

View File

@ -1,25 +0,0 @@
import { unstable_vitePlugin as remix } from "@remix-run/dev";
import { defineConfig } from "vite";
import { installGlobals } from "@remix-run/node";
import tsconfigPaths from "vite-tsconfig-paths";
import Unfonts from "unplugin-fonts/vite";
installGlobals();
export default defineConfig({
plugins: [
remix({ ignoredRouteFiles: ["**/.*"] }),
tsconfigPaths(),
Unfonts({
fontsource: {
/**
* Fonts families lists
*/
families: [
{ name: "Be Vietnam Pro", weights: [400], subset: "latin" },
{ name: "Jaldi", weights: [400], subset: "latin" },
],
},
}),
],
});