diff --git a/client/app/community/community.tsx b/client/app/community/community.tsx new file mode 100644 index 0000000..e183d11 --- /dev/null +++ b/client/app/community/community.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { CommunityIdeaData } from "@/interfaces"; + +import { getCommunityGeneratedIdea } from "@/lib/index"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useToast } from "@/components/ui/use-toast"; +import ProjectCard from "@/components/community/diy-card"; + +export default function CommunityGeneratedIdeaList() { + const [communityData, setCommunityData] = useState([]); + const [loading, setLoading] = useState(true); + const { toast } = useToast(); + + useEffect(() => { + const fetchCommunityGeneratedIdeas = async () => { + try { + const fetchedCommunityGeneratedIdea = await getCommunityGeneratedIdea(); + setCommunityData(fetchedCommunityGeneratedIdea.data); + } catch (error) { + console.error(error); + toast({ + title: "Oops!", + description: "Failed to fetch community generated ideas. Please try again later.", + }); + } finally { + setLoading(false); + } + }; + + fetchCommunityGeneratedIdeas(); + }, [toast]); + + return ( +
+
+

“MakeMeDIYspire” Community Generated Idea

+ +
+ +
+ {loading + ? [...Array(3)].map((_, i) => ( +
+ +
+ + +
+ +
+ )) + : communityData.map(({ id, title, description, tags, projectImage, createdAt }) => ( + + ))} +
+
+ ); +} diff --git a/client/app/community/loading.tsx b/client/app/community/loading.tsx new file mode 100644 index 0000000..0115131 --- /dev/null +++ b/client/app/community/loading.tsx @@ -0,0 +1,5 @@ +import PageLoader from "@/components/page-loader"; + +export default function Loading() { + return ; +} diff --git a/client/app/community/page.tsx b/client/app/community/page.tsx new file mode 100644 index 0000000..f68d1b0 --- /dev/null +++ b/client/app/community/page.tsx @@ -0,0 +1,25 @@ +import { Metadata } from "next"; + +import CommunityGeneratedIdeaList from "./community"; + +export const metadata: Metadata = { + title: "MakeMeDIYspire Community Generated Ideas", + description: + "Join MakeMeDIYspire and discover an ever-growing list of DIY projects and ideas, all generated by our vibrant community of crafting enthusiasts. Dive into innovative DIY adventures and ignite your creativity with unique, user-driven project concepts.", + keywords: [ + "Community-Generated DIY Ideas", + "MakeMeDIYspire Projects", + "User-Created Crafting Ideas", + "DIY Community Inspiration", + "Innovative DIY Concepts", + "Crafting Community", + "DIY Idea List", + "Creative Project Inspiration", + ], + metadataBase: new URL("https://www.diyspire/community"), + applicationName: "MakeMeDIYspire", +}; + +export default function Page() { + return ; +} diff --git a/client/app/guide/[guide_name]/page.tsx b/client/app/guide/[guide_name]/page.tsx index 7d75559..20eb51f 100644 --- a/client/app/guide/[guide_name]/page.tsx +++ b/client/app/guide/[guide_name]/page.tsx @@ -7,7 +7,7 @@ export async function generateMetadata({ params }: { params: { guide_name: strin const guide = await getGuideByPathMetadata(params.guide_name); return { - title: guide.data.metadata.title, + title: `How to make ${guide.data.metadata.title}`, description: guide.data.metadata.description, keywords: ["DIY How-to Guides", "MakeMeDIYspire Tutorials", "DIY Project Instructions", "Step-by-Step DIY", "DIY Project Help", "DIY Creation Guide", "DIY Project Steps"], metadataBase: new URL("https://www.diyspire/guide/" + guide.data.path), diff --git a/client/app/guide/guide.tsx b/client/app/guide/guide.tsx index 0358e61..c780b1e 100644 --- a/client/app/guide/guide.tsx +++ b/client/app/guide/guide.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; +import { GuideData } from "@/interfaces"; import { getAllGuides } from "@/lib/index"; import { Label } from "@/components/ui/label"; @@ -9,15 +10,8 @@ import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; import { useToast } from "@/components/ui/use-toast"; -type Guide = { - path: string; - metadata: { - title: string; - }; -}; - export default function HowToGuidesList() { - const [guides, setGuides] = useState([]); + const [guides, setGuides] = useState([]); const [loading, setLoading] = useState(true); const { toast } = useToast(); diff --git a/client/components/community/diy-card.tsx b/client/components/community/diy-card.tsx new file mode 100644 index 0000000..d06b771 --- /dev/null +++ b/client/components/community/diy-card.tsx @@ -0,0 +1,63 @@ +import Image from "next/image"; +import Link from "next/link"; +import { BookOpen } from "lucide-react"; + +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; + +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Label } from "../ui/label"; + +interface ProjectCardProps { + id: string | number; + title: string; + createdAt: string; + description: string; + imgUrl: string; + badges: string[]; +} + +const ProjectCard: React.FC = ({ id, title, createdAt, description, imgUrl, badges }) => { + return ( + +
+ {title} +
+ +

+ How to make {title} +

+ +
+ +
+ {badges?.slice(0, 3).map((badge) => ( + + {badge} + + ))} +
+ +
+ + + + + +
+ ); +}; + +export default ProjectCard; diff --git a/client/constants/index.ts b/client/constants/index.ts index 4872c52..5c3319d 100644 --- a/client/constants/index.ts +++ b/client/constants/index.ts @@ -186,6 +186,7 @@ export const footerData: Footer[] = [ links: [ { label: "Generate DIY Project Idea", path: "/" }, { label: "How-to Guides", path: "/guide" }, + { label: "Community", path: "/community" }, ], }, { diff --git a/client/interfaces/index.ts b/client/interfaces/index.ts index 7b39218..ae09e0f 100644 --- a/client/interfaces/index.ts +++ b/client/interfaces/index.ts @@ -219,3 +219,22 @@ export interface ShareLinkResponse { export interface ShareLinkResponseData { id: string; } + +export interface CommunityIdeaResponse { + success: boolean; + data: CommunityIdeaData[]; +} + +export interface CommunityIdeaData { + id: number; + title: string; + description: string; + tags: string[]; + projectImage: CommunityIdeaProjectImages; + createdAt: string; +} + +export interface CommunityIdeaProjectImages { + urls: ProjectImageUrls; + alt_description?: string; +} diff --git a/client/lib/index.ts b/client/lib/index.ts index 922e615..2cd7776 100644 --- a/client/lib/index.ts +++ b/client/lib/index.ts @@ -1,4 +1,5 @@ import { + CommunityIdeaResponse, CounterResponse, GeneratedIdeaResponse, GuidePathMetadataResponse, @@ -108,6 +109,10 @@ export const incrementCounterOfGeneratedIdea = async (): Promise => { return fetchApi("/v1/counter", { method: "POST" }); }; +export const getCommunityGeneratedIdea = async (limit = 20, orderBy = "desc"): Promise => { + return fetchApi(`/v1/community?limit=${limit}&orderBy=${orderBy}`); +}; + export const cleanMarkdown = (content: string): string => { return content .split("\n") diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 558b140..8744e3f 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -27,10 +27,11 @@ model Metadata { } model ProjectShareLink { - id String @id @default(uuid()) + id String @id @default(uuid()) projectDetails Json projectImage Json explanation String? + createdAt DateTime @default(now()) } model IdeaGenerationCounter { diff --git a/server/src/controllers/community.controller.ts b/server/src/controllers/community.controller.ts new file mode 100644 index 0000000..18ab10e --- /dev/null +++ b/server/src/controllers/community.controller.ts @@ -0,0 +1,67 @@ +import { Request, Response, NextFunction } from "express"; +import { sendSuccess } from "../utils/response-template"; +import { PrismaClient } from "@prisma/client"; +import { parsePrisma, removeDuplicates, validateQueryParams } from "../utils"; + +export const getCommunityGeneratedIdea = async (req: Request, res: Response, next: NextFunction) => { + try { + const { limit, orderBy } = req.query; + + const { validLimit, validOrderBy } = validateQueryParams(limit as string | undefined, orderBy as string | undefined); + + const prisma = req.app.get("prisma") as PrismaClient; + + const projects = await prisma.projectShareLink.findMany({ + select: { + id: true, + projectDetails: true, + projectImage: true, + createdAt: true, + }, + orderBy: { + createdAt: validOrderBy, + }, + take: validLimit, + }); + + if (projects.length <= 0) { + sendSuccess(res, { message: "No community project exist" }, 404); + return; + } + + const formattedProject = projects.map(({ id, projectDetails, projectImage, createdAt }) => { + const { urls, alt_description } = parsePrisma<{ + urls: { + raw: string; + full: string; + small: string; + thumb: string; + regular: string; + small_s3: string; + }; + alt_description: string; + }>(projectImage); + const { title, description, tags } = parsePrisma<{ + title: string; + description: string; + tags: string[]; + }>(projectDetails); + + return { + id, + title, + description, + tags, + projectImage: { + urls, + alt_description, + }, + createdAt, + }; + }); + + return sendSuccess(res, removeDuplicates(formattedProject, ["title"])); + } catch (error) { + next(error); + } +}; diff --git a/server/src/routes/community.routes.ts b/server/src/routes/community.routes.ts new file mode 100644 index 0000000..dadf8b5 --- /dev/null +++ b/server/src/routes/community.routes.ts @@ -0,0 +1,8 @@ +import express from "express"; +import { getCommunityGeneratedIdea } from "../controllers/community.controller"; + +const router = express.Router(); + +router.get("", getCommunityGeneratedIdea); + +export default router; diff --git a/server/src/routes/index.routes.ts b/server/src/routes/index.routes.ts index 74abef4..4bdcdec 100644 --- a/server/src/routes/index.routes.ts +++ b/server/src/routes/index.routes.ts @@ -4,6 +4,7 @@ import unsplashRoutes from "./unsplash.routes"; import guideRoutes from "./guide.routes"; import shareRoutes from "./share.routes"; import counterRoutes from "./counter.routes"; +import communityRoutes from "./community.routes"; const router = express.Router(); @@ -12,5 +13,6 @@ router.use("/image", unsplashRoutes); router.use("/guide", guideRoutes); router.use("/share", shareRoutes); router.use("/counter", counterRoutes); +router.use("/community", communityRoutes); -export { openaiRoutes, unsplashRoutes, guideRoutes, shareRoutes, counterRoutes }; +export { openaiRoutes, unsplashRoutes, guideRoutes, shareRoutes, counterRoutes, communityRoutes }; diff --git a/server/src/server.ts b/server/src/server.ts index eb97b46..203deef 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -8,7 +8,7 @@ import { createApi } from "unsplash-js"; import * as nodeFetch from "node-fetch"; import { PrismaClient } from "@prisma/client"; -import { guideRoutes, unsplashRoutes, openaiRoutes, shareRoutes, counterRoutes } from "./routes/index.routes"; +import { guideRoutes, unsplashRoutes, openaiRoutes, shareRoutes, counterRoutes, communityRoutes } from "./routes/index.routes"; import { sendError, sendSuccess } from "./utils/response-template"; import errorHandlerMiddleware from "./middleware/error-handler"; import limiter from "./middleware/request-limit"; @@ -71,6 +71,7 @@ app.get( const oneDayCacheMiddleware = getConditionalCache("24 hours"); app.use("/api/v1/guide", oneDayCacheMiddleware, guideRoutes); app.use("/api/v1/image", oneDayCacheMiddleware, unsplashRoutes); +app.use("/api/v1/community", oneDayCacheMiddleware, communityRoutes); const oneYearCacheMiddleware = getConditionalCache("12 months"); app.use("/api/v1/share", oneYearCacheMiddleware, shareRoutes); diff --git a/server/src/utils/index.ts b/server/src/utils/index.ts index a1b5106..535e1de 100644 --- a/server/src/utils/index.ts +++ b/server/src/utils/index.ts @@ -35,3 +35,47 @@ export const parsePrisma = (json: JsonValue): T => { throw new Error("Unsupported type"); } }; + +export const validateQueryParams = ( + limit?: string | undefined, + orderBy?: string | undefined, + options: { + DEFAULT_LIMIT: number; + DEFAULT_ORDER_BY: string; + MAX_LIMIT: number; + } = { DEFAULT_LIMIT: 20, DEFAULT_ORDER_BY: "desc", MAX_LIMIT: 100 }, +) => { + const { DEFAULT_LIMIT, DEFAULT_ORDER_BY, MAX_LIMIT } = options; + + const limitStr = typeof limit === "string" ? limit : String(DEFAULT_LIMIT); + const validLimit = parseInt(limitStr, 10); + if (isNaN(validLimit) || validLimit <= 0 || validLimit > (MAX_LIMIT ?? Number.POSITIVE_INFINITY)) { + throw new Error(`Invalid limit. It should be a number between 1 and ${MAX_LIMIT}`); + } + + const orderByStr = typeof orderBy === "string" ? orderBy : DEFAULT_ORDER_BY; + const lowerCasedOrderBy = orderByStr.toLowerCase(); + if (!["asc", "desc"].includes(lowerCasedOrderBy)) { + throw new Error(`Invalid orderBy. It should be '${DEFAULT_ORDER_BY}' or 'desc'`); + } + + return { validLimit, validOrderBy: lowerCasedOrderBy as "asc" | "desc" }; +}; + +const getNestedProperty = (obj: { [key: string]: any }, propPath: string): any => { + return propPath.split(".").reduce((acc, part) => acc?.[part], obj); +}; + +export const removeDuplicates = (data: { [key: string]: any }[], props: string[]): { [key: string]: any }[] => { + const seen = new Set(); + + return data.filter((item) => { + const key = JSON.stringify(props.map((prop) => getNestedProperty(item, prop))); + + if (!seen.has(key)) { + seen.add(key); + return true; + } + return false; + }); +};