From 793e3748f96b6f1cc51df60474f76993cdae90cb Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Tue, 25 Feb 2025 00:13:53 +0100 Subject: [PATCH] Blogs listing + Categories --- backend/api/blogs/blogs.go | 12 +- backend/api/blogs/categories.go | 41 +++++ backend/api/blogs_routes.go | 8 + .../20250224080441_update_blog_category.go | 24 +++ backend/core/models/blogs.go | 1 + .../app/(auth)/dashboard/blogs/new/new.tsx | 59 ++++++- frontend/app/(main)/about/page.tsx | 4 +- frontend/app/(main)/blogs/categories.tsx | 137 +++++++++++++++ frontend/app/(main)/blogs/no-blogs.tsx | 27 +++ frontend/app/(main)/blogs/page.tsx | 33 +++- frontend/app/globals.css | 9 + frontend/components/article.tsx | 9 +- frontend/components/blog-card.tsx | 39 +++++ frontend/components/blog.tsx | 104 ++---------- frontend/components/blogItem.tsx | 78 --------- frontend/components/ui/combobox.tsx | 114 +++++++++++++ frontend/components/ui/command.tsx | 156 ++++++++++++++++++ frontend/package-lock.json | 19 ++- frontend/package.json | 3 +- frontend/tailwind.config.ts | 5 + frontend/types/types.tsx | 5 + 21 files changed, 695 insertions(+), 192 deletions(-) create mode 100644 backend/api/blogs/categories.go create mode 100644 backend/cmd/migrate/migrations/20250224080441_update_blog_category.go create mode 100644 frontend/app/(main)/blogs/categories.tsx create mode 100644 frontend/app/(main)/blogs/no-blogs.tsx create mode 100644 frontend/components/blog-card.tsx delete mode 100644 frontend/components/blogItem.tsx create mode 100644 frontend/components/ui/combobox.tsx create mode 100644 frontend/components/ui/command.tsx diff --git a/backend/api/blogs/blogs.go b/backend/api/blogs/blogs.go index 3b0ee3b..2e2ebe6 100644 --- a/backend/api/blogs/blogs.go +++ b/backend/api/blogs/blogs.go @@ -10,11 +10,17 @@ import ( ) func HandleGetBlogs(w http.ResponseWriter, r *http.Request) { + category := r.URL.Query().Get("category") var blog []models.Blog - count, err := core.DB.NewSelect(). + q := core.DB.NewSelect(). Model(&blog). - Relation("Author"). - ScanAndCount(context.Background()) + Relation("Author") + + if len(category) > 0 { + q.Where("category = ?", category) + } + + count, err := q.ScanAndCount(context.Background()) if err != nil { core.JSONError{ Status: core.Error, diff --git a/backend/api/blogs/categories.go b/backend/api/blogs/categories.go new file mode 100644 index 0000000..4103e1a --- /dev/null +++ b/backend/api/blogs/categories.go @@ -0,0 +1,41 @@ +package blogs + +import ( + "context" + "net/http" + + "fr.latosa-escrima/core" + "fr.latosa-escrima/core/models" +) + +func HandleCategories(w http.ResponseWriter, r *http.Request) { + var categories []struct { + Category string `json:"category"` + Count int `json:"count"` + } + err := core.DB.NewSelect(). + Model((*models.Blog)(nil)). + Column("category"). + ColumnExpr("COUNT(*) AS count"). + Where("category IS NOT NULL AND category != ''"). + Group("category"). + // Count the occurrences of each distinct category + Having("COUNT(category) > 0"). + // Sort the results by the count in descending order + Order("count DESC"). + Scan(context.Background(), &categories) + + if err != nil { + core.JSONError{ + Status: core.Error, + Message: err.Error(), + }.Respond(w, http.StatusInternalServerError) + return + } + + core.JSONSuccess{ + Status: core.Success, + Message: "Categories found.", + Data: categories, + }.Respond(w, http.StatusOK) +} diff --git a/backend/api/blogs_routes.go b/backend/api/blogs_routes.go index d3b261b..d6a205b 100644 --- a/backend/api/blogs_routes.go +++ b/backend/api/blogs_routes.go @@ -6,10 +6,18 @@ import ( ) var BlogsRoutes = map[string]core.Handler{ + "/blogs": { + Handler: blogs.HandleGetBlogs, + Middlewares: []core.Middleware{Methods("GET")}, + }, "/blogs/new": { Handler: blogs.HandleNew, Middlewares: []core.Middleware{Methods(("POST")), HasPermissions("blogs", "insert"), AuthJWT}}, + "/blogs/categories": { + Handler: blogs.HandleCategories, + Middlewares: []core.Middleware{Methods("GET")}, + }, "/blogs/{slug}": { Handler: blogs.HandleBlog, Middlewares: []core.Middleware{Methods("GET")}}, diff --git a/backend/cmd/migrate/migrations/20250224080441_update_blog_category.go b/backend/cmd/migrate/migrations/20250224080441_update_blog_category.go new file mode 100644 index 0000000..1b1bf09 --- /dev/null +++ b/backend/cmd/migrate/migrations/20250224080441_update_blog_category.go @@ -0,0 +1,24 @@ +package migrations + +import ( + "context" + "fmt" + + "fr.latosa-escrima/core/models" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [up migration] ") + _, err := db. + NewAddColumn(). + Model((*models.Blog)(nil)). + ColumnExpr("category TEXT"). + Exec(ctx) + return err + }, func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [down migration] ") + return nil + }) +} diff --git a/backend/core/models/blogs.go b/backend/core/models/blogs.go index 902b790..89f76a0 100644 --- a/backend/core/models/blogs.go +++ b/backend/core/models/blogs.go @@ -18,6 +18,7 @@ type Blog struct { Published time.Time `bun:"published,default:current_timestamp" json:"published"` Summary string `bun:"summary" json:"summary"` Image string `bun:"image" json:"image"` + Category string `bun:"category" json:"category,omitempty"` Author User `bun:"rel:belongs-to,join:author_id=user_id" json:"author"` } diff --git a/frontend/app/(auth)/dashboard/blogs/new/new.tsx b/frontend/app/(auth)/dashboard/blogs/new/new.tsx index 60b9f0f..d58cf3b 100644 --- a/frontend/app/(auth)/dashboard/blogs/new/new.tsx +++ b/frontend/app/(auth)/dashboard/blogs/new/new.tsx @@ -4,9 +4,9 @@ import { LocalEditor } from "@/components/editor"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog"; -import useApiMutation from "@/hooks/use-api"; +import useApiMutation, { useApi } from "@/hooks/use-api"; import sluggify from "@/lib/sluggify"; -import { NewBlog } from "@/types/types"; +import { Category, NewBlog } from "@/types/types"; import { useEffect, useMemo, useState } from "react"; import { DialogTitle } from "@radix-ui/react-dialog"; import { useToast } from "@/hooks/use-toast"; @@ -17,12 +17,28 @@ import { ActionButtonLoading, ActionButtonSuccess, } from "@/components/action-button"; +import ComboBox from "@/components/ui/combobox"; export default function BlogEditor() { const { toast } = useToast(); const [title, setTitle] = useState(""); const [imageUrl, setImageUrl] = useState("/placeholder.svg"); + const [category, setCategory] = useState(""); + + const { data } = useApi( + "/blogs/categories", + undefined, + false, + false, + ); + + const [categories, setCategories] = useState([]); + + useEffect(() => { + if (data) setCategories(data.map((c) => c.category) ?? []); + }, [data]); + const content = localStorage.getItem("blog_draft") ?? ""; useEffect(() => { @@ -82,12 +98,38 @@ export default function BlogEditor() { /> - setSummary(e.target.value)} - /> +
+ setSummary(e.target.value)} + /> + ( + + )} + onSubmit={(value) => { + setCategories((prev) => { + if (prev.includes(value)) return prev; + return [...prev, value]; + }); + setCategory(value); + }} + > + {(Item, element) => ( + + {element} + + )} + +
@@ -110,6 +152,7 @@ export default function BlogEditor() { image: imageUrl, slug, content: blogContent, + category, }); if (!res) { toast({ title: "Aucune réponse du serveur." }); diff --git a/frontend/app/(main)/about/page.tsx b/frontend/app/(main)/about/page.tsx index 2d2e9e0..5808219 100644 --- a/frontend/app/(main)/about/page.tsx +++ b/frontend/app/(main)/about/page.tsx @@ -36,8 +36,8 @@ export default async function About() { Nicolas GORUK - Président l'association française de Latosa - Escrima + Président de l'association française de + Latosa Escrima diff --git a/frontend/app/(main)/blogs/categories.tsx b/frontend/app/(main)/blogs/categories.tsx new file mode 100644 index 0000000..839c0a4 --- /dev/null +++ b/frontend/app/(main)/blogs/categories.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { useSearchParams, useRouter, usePathname } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import request from "@/lib/request"; +import { useEffect, useState } from "react"; +import { Category } from "@/types/types"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, +} from "@/components/ui/select"; +import { SelectValue } from "@radix-ui/react-select"; +import { CircleXIcon } from "lucide-react"; + +export default function Categories({ + selectedCategory, +}: { + selectedCategory?: string; +}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [categories, setCategories] = useState([]); + + const [selected, setSelected] = useState( + selectedCategory, + ); + + useEffect(() => { + setSelected(selectedCategory); + }, [selectedCategory]); // Sync local state when URL changes + + const [isReady, setIsReady] = useState(false); + const isMobile = useIsMobile(); + + useEffect(() => { + setIsReady(true); // Ensures component renders only after first render + }, []); + + // Fetch categories on mount + useEffect(() => { + const fetchCategories = async () => { + const response = await request("/blogs/categories", { + requiresAuth: false, + }); + if (response.status !== "Error" && response.data) { + setCategories(response.data); + } + }; + + fetchCategories(); + }, []); + + // Function to update query params + const updateQueryParams = (category: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (category === selectedCategory) { + params.delete("category"); + } else { + params.set("category", category); + } + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }; + + if (!isReady) { + return null; // Prevents rendering the wrong section before isMobile is determined + } + + if (isMobile) { + return ( +
+ + {selected && ( + + )} +
+ ); + } + + return ( +
+

Catégories

+ {categories.map((cat) => ( + + ))} +
+ ); +} diff --git a/frontend/app/(main)/blogs/no-blogs.tsx b/frontend/app/(main)/blogs/no-blogs.tsx new file mode 100644 index 0000000..135bba0 --- /dev/null +++ b/frontend/app/(main)/blogs/no-blogs.tsx @@ -0,0 +1,27 @@ +"use client"; +import { Button } from "@/components/ui/button"; // You can use your button component from your UI library +import { Card } from "@/components/ui/card"; +import { CircleXIcon, RotateCwIcon } from "lucide-react"; // Optional icon + +export default function NoBlogs() { + return ( +
+ +

+ Aucun blog trouvé. +

+

+ Nous n'avons pu trouver aucun blog. +

+ +
+
+ ); +} diff --git a/frontend/app/(main)/blogs/page.tsx b/frontend/app/(main)/blogs/page.tsx index d27089e..d321c3f 100644 --- a/frontend/app/(main)/blogs/page.tsx +++ b/frontend/app/(main)/blogs/page.tsx @@ -1,9 +1,34 @@ -import Blog from "@/components/blog"; +"use server"; +import Blogs from "@/components/blog"; +import request from "@/lib/request"; +import { Blog } from "@/types/types"; +import Categories from "./categories"; +import NoBlogs from "./no-blogs"; + +export default async function History({ + searchParams, +}: { + searchParams: Promise<{ category?: string }>; +}) { + const { category } = await searchParams; + let url = "/blogs"; + if (category) url += `?category=${category}`; + const blogs = await request(url, { + requiresAuth: false, + }); + + if (!blogs.data || blogs.data.length < 1) { + return ; + } + + if (blogs?.status === "Error") { + return

Un problème est survenue.

; + } -export default function History() { return ( -
- +
+ +
); } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 724f19f..32bac5f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -77,3 +77,12 @@ body { @apply text-4xl font-bold; } } + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/frontend/components/article.tsx b/frontend/components/article.tsx index 698b5e0..7d969cd 100644 --- a/frontend/components/article.tsx +++ b/frontend/components/article.tsx @@ -10,7 +10,9 @@ const BlogArticle: React.FC<{ blog: Blog }> = ({ blog }) => {
- {blog.category} + {blog.category && ( + {blog.category} + )}
-
+
); diff --git a/frontend/components/blog-card.tsx b/frontend/components/blog-card.tsx new file mode 100644 index 0000000..809e0e6 --- /dev/null +++ b/frontend/components/blog-card.tsx @@ -0,0 +1,39 @@ +import { Card, CardContent, CardFooter } from "@/components/ui/card"; +import { Blog } from "@/types/types"; +import { ArrowRight } from "lucide-react"; +import Link from "next/link"; +import { Badge } from "./ui/badge"; + +export function BlogCard({ blog }: { blog: Blog }) { + return ( + + + {blog.title} + + +

+ {blog.title} +

+

+ {blog.summary} +

+ + En savoir plus + + +
+ {blog.category && ( + + {blog.category} + + )} +
+ ); +} diff --git a/frontend/components/blog.tsx b/frontend/components/blog.tsx index 6e4a012..8770538 100644 --- a/frontend/components/blog.tsx +++ b/frontend/components/blog.tsx @@ -1,72 +1,14 @@ -import { ArrowRight, ArrowDown } from "lucide-react"; +import { ArrowDown } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Blog } from "@/types/types"; +import { BlogCard } from "./blog-card"; -export interface BlogInterface { - id: string; - slug: string; - title: string; - content: string; - label: string; - author: string; - published: string; -} - -export interface BlogSummaryInterface extends BlogInterface { - summary: string; - image: string; - href: string; -} - -export const posts: BlogSummaryInterface[] = [ - { - id: "a1b2c3d4-e5f6-7g8h-9i0j-k1l2m3n4o5p6", - slug: "tech-advancements-2025", - title: "Tech Advancements in 2025", - content: - "The year 2025 promises groundbreaking technologies that will reshape industries. In this article, we explore the key advancements that could transform how we work, live, and communicate.", - label: "Technology", - author: "d3f5e6g7-h8i9j0k1-l2m3n4o5p6q7", - published: "2025-01-14", - summary: - "A look at the tech trends to expect in 2025 and beyond, from AI to quantum computing.", - image: "https://shadcnblocks.com/images/block/placeholder-dark-1.svg", - href: "history/tech-advancements-2025", - }, - { - id: "f7g8h9i0-j1k2l3m4-n5o6p7q8r9s0t1u2v3", - slug: "sustainable-fashion-2025", - title: "Sustainable Fashion in 2025", - content: - "Sustainability is no longer a trend, but a movement within the fashion industry. This article discusses how eco-friendly practices are influencing fashion designs and consumer behavior in 2025.", - label: "Fashion", - author: "w4x5y6z7-a8b9c0d1-e2f3g4h5i6j7", - published: "2025-01-12", - summary: - "Exploring how sustainable fashion is evolving in 2025 with innovative materials and ethical brands.", - image: "https://shadcnblocks.com/images/block/placeholder-dark-1.svg", - href: "history/sustainable-fashion-2025", - }, - { - id: "v1w2x3y4-z5a6b7c8-d9e0f1g2h3i4j5k6l7", - slug: "mental-health-awareness-2025", - title: "Mental Health Awareness in 2025", - content: - "As mental health awareness continues to grow, 2025 brings new challenges and opportunities to address psychological well-being. This article focuses on emerging trends in mental health support and public perception.", - label: "Health", - author: "m8n9o0p1-q2r3s4t5-u6v7w8x9y0z1a2b3", - published: "2025-01-10", - summary: - "Highlighting the importance of mental health awareness in 2025, focusing on new treatments and societal changes.", - image: "https://shadcnblocks.com/images/block/placeholder-dark-1.svg", - href: "/history/mental-health-awareness-2025", - }, -]; - -const Blog = () => { +const Blogs: React.FC<{ blogs: Blog[] }> = ({ blogs }) => { return ( -
-
+
+
+ {/*

En savoir plus sur ce sport @@ -76,34 +18,10 @@ const Blog = () => { Elig doloremque mollitia fugiat omnis! Porro facilis quo animi consequatur. Explicabo.

-

+
*/}
- {posts.map((post) => ( - -
- {post.title} -
-
-

- {post.title} -

-

- {post.summary} -

-

- Read more - -

-
-
+ {blogs.map((blog) => ( + ))}