Compare commits
136 Commits
Author | SHA1 | Date |
---|---|---|
Derrick Hammer | c1897e4d7c | |
Derrick Hammer | 9c15ff8983 | |
Derrick Hammer | 4ea378d9a0 | |
Derrick Hammer | e434640ce4 | |
Derrick Hammer | 1b0ca47d82 | |
Derrick Hammer | f2aee7482b | |
Derrick Hammer | 11a6027e0c | |
Derrick Hammer | 15758f442a | |
Derrick Hammer | 494c7e9ac3 | |
Derrick Hammer | 6f9735044c | |
Derrick Hammer | 318d797c5f | |
Derrick Hammer | 9c5aceb6e5 | |
Derrick Hammer | 3a1d4d89d3 | |
Derrick Hammer | d1b834437c | |
Derrick Hammer | 6c87defd14 | |
Derrick Hammer | 09ecf1b2d3 | |
Derrick Hammer | 0334b1dcc5 | |
Derrick Hammer | 964c1eed0d | |
Derrick Hammer | f3c38dea8e | |
Derrick Hammer | 3ca61c2b7a | |
Derrick Hammer | 024f000bf9 | |
Derrick Hammer | 37ae9f7044 | |
Derrick Hammer | 2b637cca37 | |
Derrick Hammer | 04022af1df | |
Derrick Hammer | af4bbdb778 | |
Derrick Hammer | 9271b5e801 | |
Derrick Hammer | 1fea45aaf7 | |
Derrick Hammer | 4358538e21 | |
Derrick Hammer | 9ba24aff51 | |
Derrick Hammer | 135267fdd3 | |
Derrick Hammer | 4bb5470b3a | |
Derrick Hammer | 0593fa80b4 | |
Derrick Hammer | 37d6e172c7 | |
Derrick Hammer | 10b1826214 | |
Derrick Hammer | 5a1341ce40 | |
Derrick Hammer | cc1b0d9294 | |
Derrick Hammer | 48b7db9193 | |
Derrick Hammer | 5174b0077e | |
Derrick Hammer | d7014de62a | |
Derrick Hammer | 72e277833e | |
Derrick Hammer | 5cf0e15e2d | |
Derrick Hammer | 0ca9099d31 | |
Derrick Hammer | 1ea38add64 | |
Derrick Hammer | 1998c66779 | |
Derrick Hammer | b9b0cabdcf | |
Derrick Hammer | c0e4b4a9a9 | |
Derrick Hammer | eb25182cb7 | |
Derrick Hammer | f083140006 | |
Derrick Hammer | c20b48ea90 | |
Derrick Hammer | ec03ef8e49 | |
Derrick Hammer | 8799779e77 | |
Derrick Hammer | dadf92bbce | |
Derrick Hammer | eecacf65b1 | |
Derrick Hammer | 539882fef1 | |
Derrick Hammer | ec82c6b411 | |
Derrick Hammer | 23bca2458a | |
Derrick Hammer | 78207d0494 | |
Derrick Hammer | d3b9cadb54 | |
Derrick Hammer | 6d6f684b47 | |
Derrick Hammer | 9a1a2dfecf | |
Derrick Hammer | e1ba8a7656 | |
Derrick Hammer | 4a3030596f | |
Derrick Hammer | 232df2b60a | |
Derrick Hammer | 368e42e709 | |
Derrick Hammer | c47e31685e | |
Derrick Hammer | 2c7f15a2df | |
Derrick Hammer | 794f71b0e7 | |
Derrick Hammer | cdedc10e56 | |
Derrick Hammer | edc345fcdf | |
Derrick Hammer | cf6be488a8 | |
Derrick Hammer | 446131bf47 | |
Derrick Hammer | 35ca7b1ea5 | |
Derrick Hammer | 917511a449 | |
Derrick Hammer | 0c404bd3b1 | |
Derrick Hammer | 0ad37ad433 | |
Derrick Hammer | 9b964dd4f4 | |
Derrick Hammer | 87ac4c63f6 | |
Derrick Hammer | 74642f0930 | |
Derrick Hammer | 369996ff2c | |
Derrick Hammer | 7251656bef | |
Derrick Hammer | 0aadf6f6f7 | |
Derrick Hammer | 2bd0cf429c | |
Derrick Hammer | b337822a2d | |
Derrick Hammer | 57412148de | |
Derrick Hammer | 8277c3e6df | |
Derrick Hammer | 64c5642010 | |
Derrick Hammer | 316d881fbb | |
Derrick Hammer | 41e3a34ab7 | |
Derrick Hammer | b88c14407f | |
Derrick Hammer | 5b9bdb9c8f | |
Derrick Hammer | 3b28616f63 | |
Derrick Hammer | ba5fc45b4a | |
Derrick Hammer | bb8fd35b49 | |
Derrick Hammer | e18c76ca8b | |
Derrick Hammer | 81412d8cc8 | |
Derrick Hammer | 2d339f2ebe | |
Derrick Hammer | f6e627e045 | |
Derrick Hammer | 175eac054f | |
Derrick Hammer | cf10bd0201 | |
Derrick Hammer | 6604fdfad2 | |
Derrick Hammer | 4f4cebb721 | |
Derrick Hammer | 538241b858 | |
Derrick Hammer | cabac4d1b2 | |
Derrick Hammer | 8151d8a7ec | |
Derrick Hammer | dc7110d361 | |
Derrick Hammer | 1da5a340e3 | |
Derrick Hammer | bc2cacf204 | |
Derrick Hammer | e720c245a2 | |
Derrick Hammer | c6849e9a2d | |
Derrick Hammer | f94813d4c2 | |
Juan Di Toro | 47bdaf9b88 | |
Juan Di Toro | 487c5de10a | |
Juan Di Toro | 0114428786 | |
Derrick Hammer | 88e710051e | |
Juan Di Toro | 56107acef5 | |
Juan Di Toro | d203ad3400 | |
Juan Di Toro | 192d7049c8 | |
Juan Di Toro | 6e952c1514 | |
Juan Di Toro | 0990b9c3fd | |
Juan Di Toro | 0df362a891 | |
Juan Di Toro | cc090a1f33 | |
Derrick Hammer | 4a7c6b9a66 | |
Juan Di Toro | c8c77b536e | |
Juan Di Toro | 1ff20dcf73 | |
Juan Di Toro | 34dd31d6a8 | |
Derrick Hammer | b3188b5303 | |
Derrick Hammer | 5c17f888d7 | |
Derrick Hammer | 6039eb1765 | |
Juan Di Toro | 5d4a7ae552 | |
Juan Di Toro | 211b8ceb3f | |
Juan Di Toro | f44a5a35df | |
Juan Di Toro | a7d124073f | |
Juan Di Toro | 50ba72f950 | |
Juan Di Toro | fa67c88c6c | |
Juan Di Toro | b9b7a9a6cc | |
Juan Di Toro | 7c58be9d6f |
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
# 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
|
|
@ -0,0 +1,40 @@
|
||||||
|
# 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"]
|
2
LICENSE
2
LICENSE
|
@ -1,6 +1,6 @@
|
||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2023 LumeWeb
|
Copyright (c) 2023 Hammer Technologies LLC
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
|
|
36
README.md
36
README.md
|
@ -1,2 +1,36 @@
|
||||||
# web3.news
|
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).
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -0,0 +1,229 @@
|
||||||
|
"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;
|
|
@ -0,0 +1,17 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
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;
|
|
@ -0,0 +1,68 @@
|
||||||
|
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;
|
|
@ -0,0 +1,225 @@
|
||||||
|
"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;
|
|
@ -0,0 +1,121 @@
|
||||||
|
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;
|
|
@ -0,0 +1,83 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,56 @@
|
||||||
|
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 };
|
|
@ -0,0 +1,155 @@
|
||||||
|
"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,
|
||||||
|
}
|
|
@ -0,0 +1,122 @@
|
||||||
|
"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,
|
||||||
|
}
|
|
@ -0,0 +1,31 @@
|
||||||
|
"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 }
|
|
@ -0,0 +1,160 @@
|
||||||
|
"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,
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
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.
After Width: | Height: | Size: 137 KiB |
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
|
@ -0,0 +1,26 @@
|
||||||
|
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;
|
|
@ -0,0 +1,56 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
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);
|
||||||
|
}
|
|
@ -0,0 +1,34 @@
|
||||||
|
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 };
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { MeiliSearch } from "meilisearch";
|
||||||
|
|
||||||
|
const meili = new MeiliSearch({ host: "http://melli:7700" });
|
||||||
|
|
||||||
|
export default meili;
|
|
@ -0,0 +1,56 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,84 @@
|
||||||
|
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>.
|
||||||
|
Let’s 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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,83 @@
|
||||||
|
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. We’re
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -0,0 +1,96 @@
|
||||||
|
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. Let’s 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,97 @@
|
||||||
|
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 });
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,66 @@
|
||||||
|
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;
|
|
@ -0,0 +1,90 @@
|
||||||
|
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;
|
|
@ -0,0 +1,21 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,76 @@
|
||||||
|
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" },
|
||||||
|
];
|
|
@ -0,0 +1,43 @@
|
||||||
|
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"]
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="@remix-run/dev" />
|
||||||
|
/// <reference types="@remix-run/node" />
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,77 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
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;
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,20 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,35 @@
|
||||||
|
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();
|
||||||
|
});
|
|
@ -0,0 +1,27 @@
|
||||||
|
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 {}
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
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"));
|
|
@ -0,0 +1,7 @@
|
||||||
|
import search from "../app/lib/search";
|
||||||
|
|
||||||
|
await search.createIndex("articles", { primaryKey: "id" });
|
||||||
|
|
||||||
|
const index = search.index("articles");
|
||||||
|
|
||||||
|
await index.updateFilterableAttributes(["site", "createdTimestamp"]);
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"blog.lumeweb.com": {
|
||||||
|
"name": "Lume Web News",
|
||||||
|
"pubkey": "2F66BFFAD819A6BF221D26EE0FFE75E48D154F13761EAAE575896F17DBDA5FD3",
|
||||||
|
"rss": "post/index.xml"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
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;
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"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"]
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
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" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
Loading…
Reference in New Issue