feat: initial database implementation with api route
This commit is contained in:
parent
5d4a7ae552
commit
6039eb1765
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
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 (
|
||||
<>
|
||||
<SearchBar />
|
||||
|
@ -24,26 +27,12 @@ export default function Home({searchParams}: Props) {
|
|||
<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"
|
||||
/>
|
||||
<Feed
|
||||
title="Rising Posts"
|
||||
icon={'trend-up-icon'}
|
||||
className="w-[calc(33%-20px)] max-w-md"
|
||||
/>
|
||||
<Feed
|
||||
title="Top Posts"
|
||||
icon={'top-arrow-icon'}
|
||||
icon={"paper-icon"}
|
||||
className="w-[calc(33%-20px)] max-w-md"
|
||||
initialData={data.data}
|
||||
/>
|
||||
</div>
|
||||
<Feed
|
||||
title="Another heading"
|
||||
icon={'trend-up-icon'}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
|
@ -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<Awaited<ReturnType<typeof fetchFeedData>>>();
|
||||
const [content, setContent] = useState<NonNullable<typeof dataResponse>['data']>([])
|
||||
const filters = ["latest", "day", "week", "month"] as const;
|
||||
const [dataResponse, setDataResponse] =
|
||||
useState<Awaited<ReturnType<typeof fetchFeedData>>>();
|
||||
const [content, setContent] = useState<NonNullable<Article[]>>(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<ApiResponse<Article>, 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 <div>Failed to load</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`w-full h-full space-y-6 ${className}`}>
|
||||
|
@ -78,7 +91,9 @@ const Feed = ({
|
|||
</ul>
|
||||
</nav>
|
||||
</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">
|
||||
<div className={`flex gap-4 flex-${variant}`}>
|
||||
{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"
|
||||
>
|
||||
<span className="inline-block text-gray-500 w-full flex-1">
|
||||
{formatDate(item.date)}
|
||||
{formatDate(item.createdAt)}
|
||||
</span>
|
||||
<p className="inline-block text-white w-[25ch] flex-auto">
|
||||
{item.content}
|
||||
{item.title}
|
||||
</p>
|
||||
</article>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{dataResponse?.next ? <button
|
||||
{dataResponse?.next ? (
|
||||
<button
|
||||
className="bg-gray-600 text-gray-300 rounded-md p-2 px-4"
|
||||
onClick={() => fetchContent()}
|
||||
onClick={handleLoadMore}
|
||||
>
|
||||
Fetch more
|
||||
</button> : null}
|
||||
</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.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>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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<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
|
||||
export default Feed;
|
||||
|
||||
const PaperIcon = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
|
@ -169,8 +149,8 @@ const PaperIcon = ({ className }: { className?: string }) => {
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
const TopArrowLodashIcon = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
|
@ -194,8 +174,8 @@ const TopArrowLodashIcon = ({ className }: { className?: string }) => {
|
|||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
const TrendUpIcon = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
|
@ -233,11 +213,11 @@ const TrendUpIcon = ({ className }: { className?: string }) => {
|
|||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const ICON_DICT = {
|
||||
"paper-icon": PaperIcon,
|
||||
"trend-up-icon": TrendUpIcon,
|
||||
"top-arrow-icon": TopArrowLodashIcon
|
||||
} as const
|
||||
"top-arrow-icon": TopArrowLodashIcon,
|
||||
} as const;
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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 };
|
|
@ -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" });
|
||||
}
|
||||
}
|
|
@ -1,8 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
|
@ -19,9 +24,18 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue