Compare commits

...

136 Commits

Author SHA1 Message Date
Derrick Hammer c1897e4d7c
Revert "chore: debug"
This reverts commit f2aee7482b.
2023-12-28 04:00:13 -05:00
Derrick Hammer 9c15ff8983
dep: update s5-js 2023-12-28 03:59:33 -05:00
Derrick Hammer 4ea378d9a0
fix: wrong rss path 2023-12-28 03:54:51 -05:00
Derrick Hammer e434640ce4
dep: update s5-js 2023-12-28 03:06:12 -05:00
Derrick Hammer 1b0ca47d82
dep: update s5-js 2023-12-28 01:52:41 -05:00
Derrick Hammer f2aee7482b
chore: debug 2023-12-28 01:46:51 -05:00
Derrick Hammer 11a6027e0c
fix: wrong url to app 2023-12-28 01:41:51 -05:00
Derrick Hammer 15758f442a
chore: add missing script 2023-12-28 01:40:20 -05:00
Derrick Hammer 494c7e9ac3
chore: remove file, committed by mistake 2023-12-28 00:30:45 -05:00
Derrick Hammer 6f9735044c
fix: need to copy sites.json 2023-12-28 00:24:12 -05:00
Derrick Hammer 318d797c5f
feat: add simple helper script to create a seed and pubkey for adding a site 2023-12-28 00:19:40 -05:00
Derrick Hammer 9c5aceb6e5
feat: add first site 2023-12-28 00:17:34 -05:00
Derrick Hammer 3a1d4d89d3
fix: add twitter:title 2023-12-27 13:14:53 -05:00
Derrick Hammer d1b834437c
fix: add image alt tag 2023-12-27 13:03:36 -05:00
Derrick Hammer 6c87defd14
feat: add analytics 2023-12-27 12:35:31 -05:00
Derrick Hammer 09ecf1b2d3
feat: add twitter:card 2023-12-27 12:13:50 -05:00
Derrick Hammer 0334b1dcc5
fix: update homepage title 2023-12-27 12:11:37 -05:00
Derrick Hammer 964c1eed0d
fix: update SEO descriptions, need to be shorter 2023-12-27 12:11:17 -05:00
Derrick Hammer f3c38dea8e
fix: prevent duplicate tags 2023-12-27 11:41:13 -05:00
Derrick Hammer 3ca61c2b7a
fix: ensure parent meta is merged properly 2023-12-27 11:21:25 -05:00
Derrick Hammer 024f000bf9
fix: need to set title tag 2023-12-27 10:43:22 -05:00
Derrick Hammer 37ae9f7044
fix: need to add fonts back in 2023-12-27 10:18:07 -05:00
Derrick Hammer 2b637cca37
feat: add sitemap 2023-12-27 10:17:19 -05:00
Derrick Hammer 04022af1df
chore: remove unneeded sass generation 2023-12-27 07:29:10 -05:00
Derrick Hammer af4bbdb778
feat: initial seo efforts 2023-12-27 07:19:03 -05:00
Derrick Hammer 9271b5e801
fix: add dot 2023-12-25 04:17:41 -05:00
Derrick Hammer 1fea45aaf7
fix: need to pass config to caddy explicitly 2023-12-25 04:03:42 -05:00
Derrick Hammer 4358538e21
fix: add Caddyfile mapping back 2023-12-25 04:00:56 -05:00
Derrick Hammer 9ba24aff51
refactor: switch to caddy image with cloudflare dns 2023-12-25 03:57:31 -05:00
Derrick Hammer 135267fdd3
fix: wrong caddy command 2023-12-25 02:41:09 -05:00
Derrick Hammer 4bb5470b3a
refactor: remove ports 2023-12-25 02:36:47 -05:00
Derrick Hammer 0593fa80b4
fix: update port 2023-12-25 02:36:17 -05:00
Derrick Hammer 37d6e172c7
fix: ensure a static port 2023-12-25 02:34:56 -05:00
Derrick Hammer 10b1826214
feat: add caddy to docker compose 2023-12-25 02:32:53 -05:00
Derrick Hammer 5a1341ce40
chore: update LICENSE 2023-12-25 02:20:15 -05:00
Derrick Hammer cc1b0d9294
fix: need to add depends_on for melli_init to melli 2023-12-25 02:19:34 -05:00
Derrick Hammer 48b7db9193
fix: need to copy prisma folder 2023-12-25 00:40:50 -05:00
Derrick Hammer 5174b0077e
feat: initial donate page content 2023-12-25 00:39:19 -05:00
Derrick Hammer d7014de62a
feat: initial about page content 2023-12-24 23:55:02 -05:00
Derrick Hammer 72e277833e
refactor: switch to published image 2023-12-24 23:09:34 -05:00
Derrick Hammer 5cf0e15e2d
feat: add db init 2023-12-24 23:09:12 -05:00
Derrick Hammer 0ca9099d31
feat: add melli db init to docker services 2023-12-24 22:45:49 -05:00
Derrick Hammer 1ea38add64
fix: need to copy scripts folder 2023-12-24 22:45:08 -05:00
Derrick Hammer 1998c66779
fix: update import path 2023-12-24 22:43:58 -05:00
Derrick Hammer b9b0cabdcf
feat: add melli support in docker 2023-12-24 22:26:04 -05:00
Derrick Hammer c0e4b4a9a9
refactor: move bridge server to scripts folder 2023-12-24 22:25:32 -05:00
Derrick Hammer eb25182cb7
feat: initial docker support 2023-12-24 22:17:45 -05:00
Derrick Hammer f083140006
dep: add bun 2023-12-24 22:13:47 -05:00
Derrick Hammer c20b48ea90
fix: need to patch remix serve due to sourcemap bug https://github.com/remix-run/remix/issues/8309#issuecomment-1868402299 2023-12-24 21:58:19 -05:00
Derrick Hammer ec03ef8e49
Revert "fix: remove use client"
This reverts commit 78207d0494.
2023-12-24 21:45:08 -05:00
Derrick Hammer 8799779e77
fix: update scripts 2023-12-24 21:44:26 -05:00
Derrick Hammer dadf92bbce
fix: need to set binaryTargets for docker 2023-12-24 21:44:03 -05:00
Derrick Hammer eecacf65b1
fix: bad import 2023-12-24 21:13:29 -05:00
Derrick Hammer 539882fef1
fix: missing cid field 2023-12-24 21:11:49 -05:00
Derrick Hammer ec82c6b411
fix: bad import 2023-12-24 21:11:35 -05:00
Derrick Hammer 23bca2458a
fix: add missing pubkey to SiteList 2023-12-24 21:09:25 -05:00
Derrick Hammer 78207d0494
fix: remove use client 2023-12-24 20:54:39 -05:00
Derrick Hammer d3b9cadb54
refactor: create proper article links 2023-12-24 11:43:41 -05:00
Derrick Hammer 6d6f684b47
feat: implement article page 2023-12-24 11:43:15 -05:00
Derrick Hammer 9a1a2dfecf
refactor: we no longer need to deal with slugs 2023-12-24 08:20:09 -05:00
Derrick Hammer e1ba8a7656
refactor: remove slug column 2023-12-24 08:18:44 -05:00
Derrick Hammer 4a3030596f
refactor: rename siteKey column to site 2023-12-24 08:18:30 -05:00
Derrick Hammer 232df2b60a
refactor: move to using RSS 2023-12-24 08:16:23 -05:00
Derrick Hammer 368e42e709
dep: remove cheerio 2023-12-24 08:16:03 -05:00
Derrick Hammer c47e31685e
Merge remote-tracking branch 'origin/develop' into develop 2023-12-24 06:26:40 -05:00
Derrick Hammer 2c7f15a2df
refactor: require sitemap filename to be provided, as it should only target the posts, not the whole site 2023-12-24 06:25:32 -05:00
Derrick Hammer 794f71b0e7
fix: use vite css side effects 2023-12-24 06:24:09 -05:00
Derrick Hammer cdedc10e56
fic: use vite css side effects 2023-12-23 20:15:45 -05:00
Derrick Hammer edc345fcdf
dep: update remix 2023-12-23 20:15:14 -05:00
Derrick Hammer cf6be488a8
fix: update build and run scripts 2023-12-23 07:53:23 -05:00
Derrick Hammer 446131bf47
feat: implement filtering in search page 2023-12-23 07:28:52 -05:00
Derrick Hammer 35ca7b1ea5
fix: selectedSite doesn't need to be in useCallback deps 2023-12-23 07:28:20 -05:00
Derrick Hammer 917511a449
feat: implement time filter 2023-12-23 07:10:18 -05:00
Derrick Hammer 0c404bd3b1
feat: implement site filter 2023-12-23 06:42:46 -05:00
Derrick Hammer 0ad37ad433
feat: implement real search 2023-12-23 05:51:12 -05:00
Derrick Hammer 9b964dd4f4
fix: wrong tag name 2023-12-22 15:40:06 -05:00
Derrick Hammer 87ac4c63f6
refactor: switch to using vite 2023-12-22 15:35:40 -05:00
Derrick Hammer 74642f0930
feat: initial search 2023-12-22 06:05:06 -05:00
Derrick Hammer 369996ff2c
fix: get search results working 2023-12-22 04:42:21 -05:00
Derrick Hammer 7251656bef
refactor: property show site list 2023-12-22 04:11:03 -05:00
Derrick Hammer 0aadf6f6f7
fix: need to parse search query from request 2023-12-22 03:28:16 -05:00
Derrick Hammer 2bd0cf429c
chore: remove commented code 2023-12-18 02:27:55 -05:00
Derrick Hammer b337822a2d
fix: use a initialLoad state and activeContent to better track when we should be fetching data 2023-12-18 02:27:33 -05:00
Derrick Hammer 57412148de
fix: return http 200 2023-12-18 02:02:06 -05:00
Derrick Hammer 8277c3e6df
fix: we need to read json, not form data 2023-12-18 02:01:47 -05:00
Derrick Hammer 64c5642010
dep: update s5-js 2023-12-18 02:01:17 -05:00
Derrick Hammer 316d881fbb
fix: set module config to ES2022 2023-12-18 01:48:01 -05:00
Derrick Hammer 41e3a34ab7
fix: only check fetcher if we are greater than page 0 2023-12-18 01:47:38 -05:00
Derrick Hammer b88c14407f
fix: add the siteUpdateReceived back in 2023-12-18 01:47:08 -05:00
Derrick Hammer 5b9bdb9c8f
fix: update start command 2023-12-17 22:52:58 -05:00
Derrick Hammer 3b28616f63
fix: types and getAvailableSites return val 2023-12-17 22:45:37 -05:00
Derrick Hammer ba5fc45b4a
style: format 2023-12-17 22:45:17 -05:00
Derrick Hammer bb8fd35b49
fix: add missing types 2023-12-17 22:43:46 -05:00
Derrick Hammer e18c76ca8b
refactor: swf not needed for now 2023-12-17 22:39:09 -05:00
Derrick Hammer 81412d8cc8
refactor: move from swr to fetcher 2023-12-17 22:37:05 -05:00
Derrick Hammer 2d339f2ebe
refactor: migrate to remix 2023-12-17 22:18:17 -05:00
Derrick Hammer f6e627e045
refactor: use getServerSideProps 2023-12-17 18:57:24 -05:00
Derrick Hammer 175eac054f
fix: ensure model table is lowercase, plural 2023-12-17 18:10:58 -05:00
Derrick Hammer cf10bd0201
refactor: switch to POST, fix url processing to find directory indexes 2023-12-17 18:02:21 -05:00
Derrick Hammer 6604fdfad2
refactor: use custom axios instance and add try/catch 2023-12-17 18:01:04 -05:00
Derrick Hammer 4f4cebb721
dep: update s5-js 2023-12-17 18:00:06 -05:00
Derrick Hammer 538241b858
dep: update s5-js 2023-12-12 16:40:32 -05:00
Derrick Hammer cabac4d1b2
feat: initial site update webhook 2023-12-11 20:23:44 -05:00
Derrick Hammer 8151d8a7ec
feat: initial s5 network bridge 2023-12-11 20:23:17 -05:00
Derrick Hammer dc7110d361
dep: add deps 2023-12-11 20:23:01 -05:00
Derrick Hammer 1da5a340e3
fix: generate a random dumber for cid in seed 2023-12-11 20:09:05 -05:00
Derrick Hammer bc2cacf204
fix: cid needs to be unique 2023-12-11 20:08:50 -05:00
Derrick Hammer e720c245a2
ci: update compilerOptions target 2023-12-11 20:04:55 -05:00
Derrick Hammer c6849e9a2d
refactor: update prisma seed to use ts-node-esm with a mts file, not ts, and add a dummy cid string in the articles array 2023-12-11 20:04:22 -05:00
Derrick Hammer f94813d4c2
refactor: add cid to article 2023-12-11 20:02:52 -05:00
Juan Di Toro 47bdaf9b88 fix: clickable logo 2023-12-11 19:05:33 +01:00
Juan Di Toro 487c5de10a Merge branch 'develop' of git.lumeweb.com:LumeWeb/web3.news into develop 2023-12-11 19:04:12 +01:00
Juan Di Toro 0114428786 fix: add header menu 2023-12-11 19:04:03 +01:00
Derrick Hammer 88e710051e
refactor: move feed api to the app router 2023-12-09 20:09:05 -05:00
Juan Di Toro 56107acef5 fix: use the proper filters on all instances of search 2023-12-09 16:26:32 +01:00
Juan Di Toro d203ad3400 feat: connect the combobox to the api (sortof) 2023-12-09 16:22:22 +01:00
Juan Di Toro 192d7049c8 feat: select and combobox components 2023-12-08 19:29:17 +01:00
Juan Di Toro 6e952c1514 feat: static pages are up 2023-12-05 18:38:27 +01:00
Juan Di Toro 0990b9c3fd fix: db seeding 2023-12-05 17:37:38 +01:00
Juan Di Toro 0df362a891 Merge branch 'develop' of git.lumeweb.com:LumeWeb/web3.news into develop 2023-12-05 17:21:20 +01:00
Juan Di Toro cc090a1f33 fix: install proper fonts 2023-12-05 17:19:31 +01:00
Derrick Hammer 4a7c6b9a66
Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	package-lock.json
#	package.json
2023-12-05 11:13:00 -05:00
Juan Di Toro c8c77b536e feat: added graphics 2023-12-05 17:06:54 +01:00
Juan Di Toro 1ff20dcf73 Merge branch 'develop' of git.lumeweb.com:LumeWeb/web3.news into develop 2023-12-05 15:04:48 +01:00
Juan Di Toro 34dd31d6a8 wip: adding filter 2023-12-05 15:03:21 +01:00
Derrick Hammer b3188b5303
feat: add script to create dummy seed data 2023-11-29 18:20:26 -05:00
Derrick Hammer 5c17f888d7
refactor: remove astro css 2023-11-29 18:07:53 -05:00
Derrick Hammer 6039eb1765
feat: initial database implementation with api route 2023-11-29 18:07:31 -05:00
Juan Di Toro 5d4a7ae552 fix: improved searchbar 2023-11-22 12:59:45 +01:00
Juan Di Toro 211b8ceb3f feat: some interactivity to the searchbar 2023-11-22 12:46:56 +01:00
Juan Di Toro f44a5a35df feat: fix feeds and add dynamic fetching 2023-11-22 12:27:11 +01:00
Juan Di Toro a7d124073f feat: all features are covered 2023-11-15 10:35:32 +01:00
Juan Di Toro 50ba72f950 feat: site is pretty much done now, all functionality is cover just losing some details 2023-11-15 10:16:20 +01:00
Juan Di Toro fa67c88c6c feat: add results page 2023-11-14 16:52:42 +01:00
Juan Di Toro b9b7a9a6cc feat: initial components 2023-11-10 18:17:30 +01:00
Juan Di Toro 7c58be9d6f chore: initial bootstrap 2023-11-10 14:34:04 +01:00
55 changed files with 18117 additions and 2 deletions

3
.eslintrc.json Normal file
View File

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

36
.gitignore vendored Normal file
View File

@ -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

40
Dockerfile Normal file
View File

@ -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"]

View File

@ -1,6 +1,6 @@
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:

View File

@ -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.

View File

@ -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>
)
}

229
app/components/Feed.tsx Normal file
View File

@ -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;

View File

@ -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
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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 };

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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,
}

142
app/entry.server.tsx Normal file
View File

@ -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);
});
}

BIN
app/images/lume-logo-bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

BIN
app/images/lume-logo-sm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
app/images/og_default.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

26
app/lib/analytics.ts Normal file
View File

@ -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;

56
app/lib/feed.ts Normal file
View File

@ -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,
};
}

76
app/lib/meta.ts Normal file
View File

@ -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);
}

34
app/lib/prisma.ts Normal file
View File

@ -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 };

5
app/lib/search.ts Normal file
View File

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

56
app/root.tsx Normal file
View File

@ -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>
);
}

View File

@ -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>.
Lets build an open, user-owned web together.
</p>
<div className="link mt-2 flex text-gray-400">
<span>Learn more about Lume and join our community</span>
<ArrowIcon className=" ml-2 mt-2" />
</div>
</div>
</GraphicSection.Foreground>
</GraphicSection.Root>
</span>
);
}

View File

@ -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. Were
committed to <strong>transparency and open collaboration</strong>.
</p>
<p>
At web3.news, we are dedicated to{" "}
<strong>building technology, fostering community,</strong> and{" "}
<strong>providing education</strong>. Our vision is powered by your
participation and support.
</p>
<p>
Currently, web3.news operates thanks to your generous donations and
grant funding, at this time primarily from the{" "}
<Link to="https://sia.tech/" className="text-white">
<strong>Sia Foundation</strong>
</Link>
, a 501c3 blockchain foundation focused on decentralized storage. This
significant support aligns closely with our mission of{" "}
<strong>building an open and decentralized web</strong>. These
contributions are vital for our ongoing operations and development,
ensuring web3.news remains a vibrant and valuable resource for everyone,
and paving the way for our future growth and sustainability.
</p>
<p>
The support from the Sia Foundation not only bolsters our present
efforts but also aligns with our vision of leveraging blockchain
technology for{" "}
<strong>decentralized information sharing and storage</strong>.
</p>
<p>
Looking to the future, the Lume Web project is set to introduce
separate, community-supported paid products. This strategic move aims to
ensure the <strong>long-term sustainability of web3.news</strong> and
the ongoing development of innovative solutions. Aligned with our core
values and community focus, these products are envisioned as providing
essential services and value back to the community, shaped in part by
your feedback and participation.
</p>
<p>
We pledge to maintain web3.news as a platform{" "}
<strong>free from sponsorships and advertising</strong>, focusing on
solving problems through collaboration and innovation.
</p>
<p>
<strong>Join us in shaping the future of Web3.</strong>
</p>
<Link to="https://lumeweb.com/donate" aria-label="Support Our Mission">
<button className="my-6 p-8 text-gray-500 bg-gray-800 hover:bg-gray-800/70">
Support Our Mission
</button>
</Link>
</article>
);
}

65
app/routes/__info.tsx Normal file
View File

@ -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>
);
};

96
app/routes/_index.tsx Normal file
View File

@ -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. Lets build an open, user-owned
web together.
</p>
<div className="link flex text-gray-400">
<span>Learn more about Lume and join our community</span>
<ArrowIcon className=" ml-2 mt-2" />
</div>
</div>
</GraphicSection.Foreground>
</GraphicSection.Root>
</div>
</>
);
}

View File

@ -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 });
}

44
app/routes/api.feed.ts Normal file
View File

@ -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,
});
}
}

40
app/routes/api.search.ts Normal file
View File

@ -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,
};
});
}

View File

@ -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;

90
app/routes/search.tsx Normal file
View File

@ -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;

21
app/types.d.ts vendored Normal file
View File

@ -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;
};
};

76
app/utils.ts Normal file
View File

@ -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" },
];

43
docker-compose.yml Normal file
View File

@ -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"]

2
env.d.ts vendored Normal file
View File

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

15005
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

77
package.json Normal file
View File

@ -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"
}
}

View File

@ -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;

6
postcss.config.js Normal file
View File

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

20
prisma/schema.prisma Normal file
View File

@ -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
}

35
prisma/seed.mts Normal file
View File

@ -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();
});

27
scripts/bridge.mts Normal file
View File

@ -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 {}
});
}

View File

@ -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"));

7
scripts/melli-init.mts Normal file
View File

@ -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"]);

7
sites.json Normal file
View File

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

76
styles/global.scss Normal file
View File

@ -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;
}
}

78
tailwind.config.ts Normal file
View File

@ -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;

36
tsconfig.json Normal file
View File

@ -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"]
}
}

25
vite.config.ts Normal file
View File

@ -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" },
],
},
}),
],
});