feat: initial database implementation with api route

This commit is contained in:
Derrick Hammer 2023-11-29 18:07:31 -05:00
parent 5d4a7ae552
commit 6039eb1765
Signed by: pcfreak30
GPG Key ID: C997C339BE476FF2
9 changed files with 316 additions and 160 deletions

49
package-lock.json generated
View File

@ -9,11 +9,13 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"@prisma/client": "^5.6.0",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"next": "14.0.2", "next": "14.0.2",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18",
"swr": "^2.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",
@ -389,6 +391,31 @@
"node": ">= 8" "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": { "node_modules/@radix-ui/number": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz",
@ -4135,6 +4162,18 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/tailwindcss": {
"version": "3.3.5", "version": "3.3.5",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.5.tgz",
@ -4426,6 +4465,14 @@
"punycode": "^2.1.0" "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": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",

View File

@ -10,11 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"@prisma/client": "^5.6.0",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"next": "14.0.2", "next": "14.0.2",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18",
"swr": "^2.2.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

18
prisma/schema.prisma Normal file
View File

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

View File

@ -1,22 +1,25 @@
import {redirect} from "next/navigation" import { redirect } from "next/navigation";
import { headers } from 'next/headers' import { headers } from "next/headers";
import Feed from "@/components/Feed" import Feed from "@/components/Feed";
import SearchBar from "@/components/SearchBar" import SearchBar from "@/components/SearchBar";
import { fetchFeedData } from "@/lib/feed.ts";
type Props = { type Props = {
searchParams?: { searchParams?: {
q?: string | undefined q?: string | undefined;
};
}; };
}
export default function Home({searchParams}: Props) { export default async function Home({ searchParams }: Props) {
const headerList = headers() const headerList = headers();
const referer = headerList.get("referer") const referer = headerList.get("referer");
if (!referer && searchParams?.q) { if (!referer && searchParams?.q) {
redirect(`/search?q=${searchParams.q}`) redirect(`/search?q=${searchParams.q}`);
} }
const data = await fetchFeedData({});
return ( return (
<> <>
<SearchBar /> <SearchBar />
@ -24,26 +27,12 @@ export default function Home({searchParams}: Props) {
<div className="flex flex-row flex-wrap justify-between w-full"> <div className="flex flex-row flex-wrap justify-between w-full">
<Feed <Feed
title="Latest from the community" title="Latest from the community"
icon={'paper-icon'} icon={"paper-icon"}
className="w-[calc(33%-20px)] max-w-md"
/>
<Feed
title="Rising Posts"
icon={'trend-up-icon'}
className="w-[calc(33%-20px)] max-w-md"
/>
<Feed
title="Top Posts"
icon={'top-arrow-icon'}
className="w-[calc(33%-20px)] max-w-md" className="w-[calc(33%-20px)] max-w-md"
initialData={data.data}
/> />
</div> </div>
<Feed
title="Another heading"
icon={'trend-up-icon'}
className="w-full"
/>
</div> </div>
</> </>
) );
} }

View File

@ -1,57 +1,70 @@
"use client" "use client";
import { formatDate } from "@/utils" import { formatDate } from "@/utils";
import * as ScrollArea from "@radix-ui/react-scroll-area" import * as ScrollArea from "@radix-ui/react-scroll-area";
import { sub } from "date-fns" import { useState, useEffect } from "react";
import { useState, useCallback, 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 = ({ const Feed = ({
className, className,
variant = "col", variant = "col",
icon, icon,
title title,
initialData,
}: { }: {
className?: string className?: string;
variant?: "row" | "col" variant?: "row" | "col";
title: string title: string;
icon: keyof typeof ICON_DICT icon: keyof typeof ICON_DICT;
initialData: Article[];
}) => { }) => {
const filters = ["latest", "day", "week", "month"] as const const filters = ["latest", "day", "week", "month"] as const;
const [dataResponse, setDataResponse] = useState<Awaited<ReturnType<typeof fetchFeedData>>>(); const [dataResponse, setDataResponse] =
const [content, setContent] = useState<NonNullable<typeof dataResponse>['data']>([]) useState<Awaited<ReturnType<typeof fetchFeedData>>>();
const [content, setContent] = useState<NonNullable<Article[]>>(initialData);
const [selectedFilter, setSelectedFilter] = 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( const { data: swrData, error } = useSWR<ApiResponse<Article>, any>(
async (overwrite: boolean = false) => { `/api/feed?filter=${selectedFilter}&page=${currentPage}`,
const response = await fetchFeedData({ fetcher,
filter: { timerange: selectedFilter }, {
next: dataResponse?.next ?? undefined, revalidateOnFocus: false,
current: dataResponse?.current, revalidateIfStale: false,
limit: 5 revalidateOnReconnect: false,
}) shouldRetryOnError: false,
setDataResponse(response) fallbackData:
setContent((current) => { currentPage === 0 ? { data: initialData, current: 0 } : undefined, // Use initialData only for the first page
if (overwrite) {
return response.data
}
return [...current, ...response.data]
})
console.log("Fetched data")
}, },
[setContent, dataResponse, selectedFilter] );
)
const handleFilterChange = (filter: (typeof filters)[number]) => {
setSelectedFilter(filter)
fetchContent(true)
}
useEffect(() => { 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 <div>Failed to load</div>;
}
return ( return (
<section className={`w-full h-full space-y-6 ${className}`}> <section className={`w-full h-full space-y-6 ${className}`}>
@ -78,7 +91,9 @@ const Feed = ({
</ul> </ul>
</nav> </nav>
</header> </header>
<ScrollArea.Root className={`overflow-hidden w-full h-[400px] rounded-md`}> <ScrollArea.Root
className={`overflow-hidden w-full h-[400px] rounded-md`}
>
<ScrollArea.Viewport className="w-full h-full"> <ScrollArea.Viewport className="w-full h-full">
<div className={`flex gap-4 flex-${variant}`}> <div className={`flex gap-4 flex-${variant}`}>
{content.map((item, index) => { {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" 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"> <span className="inline-block text-gray-500 w-full flex-1">
{formatDate(item.date)} {formatDate(item.createdAt)}
</span> </span>
<p className="inline-block text-white w-[25ch] flex-auto"> <p className="inline-block text-white w-[25ch] flex-auto">
{item.content} {item.title}
</p> </p>
</article> </article>
) );
})} })}
{dataResponse?.next ? <button {dataResponse?.next ? (
<button
className="bg-gray-600 text-gray-300 rounded-md p-2 px-4" className="bg-gray-600 text-gray-300 rounded-md p-2 px-4"
onClick={() => fetchContent()} onClick={handleLoadMore}
> >
Fetch more Fetch more
</button> : null} </button>
) : null}
</div> </div>
</ScrollArea.Viewport> </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.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.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.ScrollAreaScrollbar>
</ScrollArea.Root> </ScrollArea.Root>
</section> </section>
) );
} };
const data = Array.from({ length: 20 }, (_, i) => ({ export default Feed;
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<T = Record<string, any>> = {
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<ApiResponse<typeof data[number]>> {
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
const PaperIcon = ({ className }: { className?: string }) => { const PaperIcon = ({ className }: { className?: string }) => {
return ( return (
@ -169,8 +149,8 @@ const PaperIcon = ({ className }: { className?: string }) => {
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
) );
} };
const TopArrowLodashIcon = ({ className }: { className?: string }) => { const TopArrowLodashIcon = ({ className }: { className?: string }) => {
return ( return (
<svg <svg
@ -194,8 +174,8 @@ const TopArrowLodashIcon = ({ className }: { className?: string }) => {
fill="currentColor" fill="currentColor"
/> />
</svg> </svg>
) );
} };
const TrendUpIcon = ({ className }: { className?: string }) => { const TrendUpIcon = ({ className }: { className?: string }) => {
return ( return (
<svg <svg
@ -233,11 +213,11 @@ const TrendUpIcon = ({ className }: { className?: string }) => {
</clipPath> </clipPath>
</defs> </defs>
</svg> </svg>
) );
} };
const ICON_DICT = { const ICON_DICT = {
"paper-icon": PaperIcon, "paper-icon": PaperIcon,
"trend-up-icon": TrendUpIcon, "trend-up-icon": TrendUpIcon,
"top-arrow-icon": TopArrowLodashIcon "top-arrow-icon": TopArrowLodashIcon,
} as const } as const;

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

@ -0,0 +1,56 @@
import prisma, { Article } from "../lib/prisma.ts";
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,
};
}

12
src/lib/prisma.ts Normal file
View File

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

38
src/pages/api/feed.ts Normal file
View File

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

View File

@ -1,8 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "target": "es5",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
@ -19,9 +24,18 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }