From 6039eb1765b7bbad3a1bd29dc9ac66b73a8d338d Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Wed, 29 Nov 2023 18:07:31 -0500 Subject: [PATCH 1/2] feat: initial database implementation with api route --- package-lock.json | 49 ++++++++++- package.json | 4 +- prisma/schema.prisma | 18 ++++ src/app/page.tsx | 53 +++++------- src/components/Feed.tsx | 184 ++++++++++++++++++---------------------- src/lib/feed.ts | 56 ++++++++++++ src/lib/prisma.ts | 12 +++ src/pages/api/feed.ts | 38 +++++++++ tsconfig.json | 62 ++++++++------ 9 files changed, 316 insertions(+), 160 deletions(-) create mode 100644 prisma/schema.prisma create mode 100644 src/lib/feed.ts create mode 100644 src/lib/prisma.ts create mode 100644 src/pages/api/feed.ts diff --git a/package-lock.json b/package-lock.json index 9a50d50..81d1c3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "@heroicons/react": "^2.0.18", + "@prisma/client": "^5.6.0", "@radix-ui/react-scroll-area": "^1.0.5", "date-fns": "^2.30.0", "next": "14.0.2", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "swr": "^2.2.4" }, "devDependencies": { "@types/node": "^20", @@ -389,6 +391,31 @@ "node": ">= 8" } }, + "node_modules/@prisma/client": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.6.0.tgz", + "integrity": "sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==", + "hasInstallScript": true, + "dependencies": { + "@prisma/engines-version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee" + }, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee.tgz", + "integrity": "sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw==" + }, "node_modules/@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -4135,6 +4162,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.4.tgz", + "integrity": "sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", @@ -4426,6 +4465,14 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index de0c9d6..e51f6af 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,13 @@ }, "dependencies": { "@heroicons/react": "^2.0.18", + "@prisma/client": "^5.6.0", "@radix-ui/react-scroll-area": "^1.0.5", "date-fns": "^2.30.0", "next": "14.0.2", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "swr": "^2.2.4" }, "devDependencies": { "@types/node": "^20", diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..32acbca --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,18 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = "file:./dev.db" +} + +model Article { + id Int @id @default(autoincrement()) + title String + slug String + url String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + siteKey String +} diff --git a/src/app/page.tsx b/src/app/page.tsx index d141f6f..ff44935 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,22 +1,25 @@ -import {redirect} from "next/navigation" -import { headers } from 'next/headers' -import Feed from "@/components/Feed" -import SearchBar from "@/components/SearchBar" +import { redirect } from "next/navigation"; +import { headers } from "next/headers"; +import Feed from "@/components/Feed"; +import SearchBar from "@/components/SearchBar"; +import { fetchFeedData } from "@/lib/feed.ts"; type Props = { - searchParams?: { - q?: string | undefined - }; -} + searchParams?: { + q?: string | undefined; + }; +}; -export default function Home({searchParams}: Props) { - const headerList = headers() - const referer = headerList.get("referer") +export default async function Home({ searchParams }: Props) { + const headerList = headers(); + const referer = headerList.get("referer"); - if(!referer && searchParams?.q) { - redirect(`/search?q=${searchParams.q}`) + if (!referer && searchParams?.q) { + redirect(`/search?q=${searchParams.q}`); } - + + const data = await fetchFeedData({}); + return ( <> @@ -24,26 +27,12 @@ export default function Home({searchParams}: Props) {
- -
- - ) -} \ No newline at end of file + ); +} diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx index bda4766..02aeb9c 100644 --- a/src/components/Feed.tsx +++ b/src/components/Feed.tsx @@ -1,57 +1,70 @@ -"use client" +"use client"; -import { formatDate } from "@/utils" -import * as ScrollArea from "@radix-ui/react-scroll-area" -import { sub } from "date-fns" -import { useState, useCallback, useEffect } from "react" +import { formatDate } from "@/utils"; +import * as ScrollArea from "@radix-ui/react-scroll-area"; +import { useState, useEffect } from "react"; +import { Article } from "../lib/prisma.ts"; +import useSWR from "swr"; +import { ApiResponse, fetchFeedData } from "../lib/feed.ts"; + +const fetcher = (url: string) => fetch(url).then((r) => r.json()); const Feed = ({ className, variant = "col", icon, - title + title, + initialData, }: { - className?: string - variant?: "row" | "col" - title: string - icon: keyof typeof ICON_DICT + className?: string; + variant?: "row" | "col"; + title: string; + icon: keyof typeof ICON_DICT; + initialData: Article[]; }) => { - const filters = ["latest", "day", "week", "month"] as const - const [dataResponse, setDataResponse] = useState>>(); - const [content, setContent] = useState['data']>([]) + const filters = ["latest", "day", "week", "month"] as const; + const [dataResponse, setDataResponse] = + useState>>(); + const [content, setContent] = useState>(initialData); const [selectedFilter, setSelectedFilter] = - useState<(typeof filters)[number]>("latest") + useState<(typeof filters)[number]>("latest"); + const [currentPage, setCurrentPage] = useState(0); - const Icon = ICON_DICT[icon] + const Icon = ICON_DICT[icon]; - const fetchContent = useCallback( - async (overwrite: boolean = false) => { - const response = await fetchFeedData({ - filter: { timerange: selectedFilter }, - next: dataResponse?.next ?? undefined, - current: dataResponse?.current, - limit: 5 - }) - setDataResponse(response) - setContent((current) => { - if (overwrite) { - return response.data - } - return [...current, ...response.data] - }) - console.log("Fetched data") + const { data: swrData, error } = useSWR, any>( + `/api/feed?filter=${selectedFilter}&page=${currentPage}`, + fetcher, + { + revalidateOnFocus: false, + revalidateIfStale: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + fallbackData: + currentPage === 0 ? { data: initialData, current: 0 } : undefined, // Use initialData only for the first page }, - [setContent, dataResponse, selectedFilter] - ) - - const handleFilterChange = (filter: (typeof filters)[number]) => { - setSelectedFilter(filter) - fetchContent(true) - } + ); useEffect(() => { - fetchContent() - }, []) + if (swrData && currentPage !== 0) { + setContent((prevContent) => [...prevContent, ...swrData.data]); + } else if (swrData) { + setContent(swrData.data); + } + }, [swrData, currentPage]); + + const handleFilterChange = (filter: (typeof filters)[number]) => { + setSelectedFilter(filter); + setCurrentPage(0); // Reset to the first page when the filter changes + }; + + const handleLoadMore = () => { + setCurrentPage((prevPage) => prevPage + 1); // Increment page number to fetch next set of data + }; + + if (error) { + return
Failed to load
; + } return (
@@ -78,7 +91,9 @@ const Feed = ({ - +
{content.map((item, index) => { @@ -88,71 +103,36 @@ const Feed = ({ className="flex bg-gray-800 flex-col justify-between w-full py-4 px-6 rounded" > - {formatDate(item.date)} + {formatDate(item.createdAt)}

- {item.content} + {item.title}

- ) + ); })} - {dataResponse?.next ? : null} + {dataResponse?.next ? ( + + ) : null}
- +
- ) -} + ); +}; -const data = Array.from({ length: 20 }, (_, i) => ({ - id: i, - date: sub(new Date(), { days: i }).toISOString(), - content: `Content ${i}` -})) - -// Filter data by timerange and pagination -// Randomly sort the data -const randomlySortedData = data.sort(() => Math.random() - 0.5) - -type ApiResponse> = { - data: T[], - current: number, - next?: number | null, - amount?: number -} - -async function fetchFeedData({ - filter, - limit = 5, - next = 5, - current = 0 -}: { - filter?: { timerange?: "latest" | "day" | "week" | "month" } - next?: number - limit?: number - current?: number -}): Promise> { - const data = filter?.timerange - ? randomlySortedData.filter(() => Math.random() > 0.5) - : randomlySortedData - const sliced = data.slice(current, next) - const nextPointer = sliced.length >= limit ? next + limit : null - return { - data: sliced, - current: next, - next: nextPointer, - amount: sliced.length - } -} -export default Feed +export default Feed; const PaperIcon = ({ className }: { className?: string }) => { return ( @@ -169,8 +149,8 @@ const PaperIcon = ({ className }: { className?: string }) => { fill="currentColor" /> - ) -} + ); +}; const TopArrowLodashIcon = ({ className }: { className?: string }) => { return ( { fill="currentColor" /> - ) -} + ); +}; const TrendUpIcon = ({ className }: { className?: string }) => { return ( { - ) -} + ); +}; const ICON_DICT = { "paper-icon": PaperIcon, "trend-up-icon": TrendUpIcon, - "top-arrow-icon": TopArrowLodashIcon -} as const + "top-arrow-icon": TopArrowLodashIcon, +} as const; diff --git a/src/lib/feed.ts b/src/lib/feed.ts new file mode 100644 index 0000000..2cdbe0f --- /dev/null +++ b/src/lib/feed.ts @@ -0,0 +1,56 @@ +import prisma, { Article } from "../lib/prisma.ts"; +export type ApiResponse> = { + 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> { + 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, + }; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts new file mode 100644 index 0000000..5161e45 --- /dev/null +++ b/src/lib/prisma.ts @@ -0,0 +1,12 @@ +import { PrismaClient, Article } from "@prisma/client"; + +const globalForPrisma = global as unknown as { prisma: PrismaClient }; + +export const prisma = globalForPrisma.prisma || new PrismaClient(); + +if (process.env.NODE_ENV !== "production") { + globalForPrisma.prisma = prisma; +} + +export default prisma; +export type { Article }; diff --git a/src/pages/api/feed.ts b/src/pages/api/feed.ts new file mode 100644 index 0000000..f4038fe --- /dev/null +++ b/src/pages/api/feed.ts @@ -0,0 +1,38 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { fetchFeedData } from "@/lib/feed.ts"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { filter, page = "0" } = req.query as any as { + filter: "latest" | "day" | "week" | "month"; + 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); + + // Send the response back + res.status(200).json(dataResponse); + } catch (error) { + // Handle any errors + res.status(500).json({ error: "Internal Server Error" }); + } +} diff --git a/tsconfig.json b/tsconfig.json index e59724b..9423dac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,27 +1,41 @@ { - "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" ], - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } From 5c17f888d7562249bff574ba220d773eaf13fc61 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Wed, 29 Nov 2023 18:07:53 -0500 Subject: [PATCH 2/2] refactor: remove astro css --- src/app/globals.css | 104 +++++++++++++++++++++----------------------- 1 file changed, 49 insertions(+), 55 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 995bab6..960f26d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,80 +3,74 @@ @tailwind utilities; @layer base { - :root { - --background: 0 0% 100%; - --foreground: 222.2 84% 4.9%; + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; - --card: 0 0% 100%; - --card-foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; - --popover: 0 0% 100%; - --popover-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; - --primary: 136, 87%, 83%; - --primary-foreground: black; + --primary: 136, 87%, 83%; + --primary-foreground: black; - --secondary: 169 46% 37%; - --secondary-foreground: 222.2 47.4% 11.2%; + --secondary: 169 46% 37%; + --secondary-foreground: 222.2 47.4% 11.2%; - --muted: 0 0% 32%; - --muted-foreground: 0 0% 32%; + --muted: 0 0% 32%; + --muted-foreground: 0 0% 32%; - --accent: 210 40% 96.1%; - --accent-foreground: 222.2 47.4% 11.2%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; - --destructive: 0 84.2% 60.2%; - --destructive-foreground: 210 40% 98%; + --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%; + --border: 0 0% 32%; + --input: 0 0% 32%; + --ring: 222.2 84% 4.9%; - --radius: 0.5rem; - } + --radius: 0.5rem; + } - .dark { - --background: 222.2 84% 4.9%; - --foreground: 210 40% 98%; + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; - --card: 222.2 84% 4.9%; - --card-foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; - --popover: 222.2 84% 4.9%; - --popover-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; - --primary: 210 40% 98%; - --primary-foreground: 222.2 47.4% 11.2%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; - --secondary: 217.2 32.6% 17.5%; - --secondary-foreground: 210 40% 98%; + --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%; + --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%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; - --destructive: 0 62.8% 30.6%; - --destructive-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%; - } + --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; - } -} - -main astro-island { - display: flex; - flex-direction: column; - height: 100vh; + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } }