diff --git a/backend/api/blogs/new.go b/backend/api/blogs/new.go index 761618a..aee82ba 100644 --- a/backend/api/blogs/new.go +++ b/backend/api/blogs/new.go @@ -7,6 +7,8 @@ import ( core "fr.latosa-escrima/core" "fr.latosa-escrima/core/models" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) func HandleNew(w http.ResponseWriter, r *http.Request) { @@ -18,11 +20,44 @@ func HandleNew(w http.ResponseWriter, r *http.Request) { }.Respond(w, http.StatusBadRequest) } - if _, err := core.DB.NewInsert().Model(&blog).Exec(context.Background()); err != nil { + token, ok := r.Context().Value("token").(*jwt.Token) + if !ok { + core.JSONError{ + Status: core.Error, + Message: "Couldn't retrieve your JWT.", + }.Respond(w, http.StatusInternalServerError) + return + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + core.JSONError{ + Status: core.Error, + Message: "Invalid token claims.", + }.Respond(w, http.StatusInternalServerError) + return + } + + id := claims["user_id"].(string) + author_uuid, err := uuid.Parse(id) + if err != nil { + core.JSONError{ + Status: core.Error, + Message: "Invalid token claims.", + }.Respond(w, http.StatusInternalServerError) + return + } + + blog.AuthorID = author_uuid + + if _, err := core.DB.NewInsert(). + Model(&blog). + Exec(context.Background()); err != nil { core.JSONError{ Status: core.Error, Message: err.Error(), }.Respond(w, http.StatusNotAcceptable) + return } core.JSONSuccess{ diff --git a/backend/cmd/migrate/migrations/20250221184348_update_blogid_default.go b/backend/cmd/migrate/migrations/20250221184348_update_blogid_default.go new file mode 100644 index 0000000..8174a11 --- /dev/null +++ b/backend/cmd/migrate/migrations/20250221184348_update_blogid_default.go @@ -0,0 +1,19 @@ +package migrations + +import ( + "context" + "fmt" + + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + fmt.Print(" [up migration] ") + _, err := db.Exec(`ALTER TABLE blogs ALTER COLUMN blog_id SET DEFAULT gen_random_uuid()`) + 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 cec999e..902b790 100644 --- a/backend/core/models/blogs.go +++ b/backend/core/models/blogs.go @@ -10,7 +10,7 @@ import ( type Blog struct { bun.BaseModel `bun:"table:blogs"` - BlogID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"blogID"` + BlogID uuid.UUID `bun:"blog_id,type:uuid,pk,default:gen_random_uuid()" json:"blogID"` Slug string `bun:"slug,unique,notnull" json:"slug"` Content string `bun:"content,notnull" json:"content"` Title string `bun:"title,notnull" json:"title"` diff --git a/frontend/app/(auth)/dashboard/blogs/new/new.tsx b/frontend/app/(auth)/dashboard/blogs/new/new.tsx new file mode 100644 index 0000000..60b9f0f --- /dev/null +++ b/frontend/app/(auth)/dashboard/blogs/new/new.tsx @@ -0,0 +1,141 @@ +"use client"; +import EditableText from "@/components/editable-text"; +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 sluggify from "@/lib/sluggify"; +import { NewBlog } from "@/types/types"; +import { useEffect, useMemo, useState } from "react"; +import { DialogTitle } from "@radix-ui/react-dialog"; +import { useToast } from "@/hooks/use-toast"; +import { + ActionButton, + ActionButtonDefault, + ActionButtonError, + ActionButtonLoading, + ActionButtonSuccess, +} from "@/components/action-button"; + +export default function BlogEditor() { + const { toast } = useToast(); + const [title, setTitle] = useState(""); + const [imageUrl, setImageUrl] = useState("/placeholder.svg"); + + const content = localStorage.getItem("blog_draft") ?? ""; + + useEffect(() => { + const localImage = localStorage.getItem("blog_draft_image"); + setImageUrl( + localImage && localImage.length > 0 + ? localImage + : "/placeholder.svg", + ); + }, []); + + const [summary, setSummary] = useState(""); + const slug = useMemo(() => sluggify(title), [title]); + const { + trigger: newBlog, + isMutating: isSending, + isSuccess, + error, + } = useApiMutation( + "/blogs/new", + {}, + "POST", + true, + false, + ); + + return ( +
+
+ +

+ {title.length > 0 ? title : "Un titre doit-être fourni"} +

+
+

{slug}

+ + + Blog cover + + + + { + setImageUrl(e.target.value); + localStorage.setItem( + "blog_draft_image", + e.currentTarget.value, + ); + }} + /> + + + setSummary(e.target.value)} + /> +
+
+
+ +
+
+ + { + try { + const blogContent = localStorage.getItem("blog_draft"); + if (!blogContent) return; + if (title.length < 1) return; + const res = await newBlog({ + title, + summary, + image: imageUrl, + slug, + content: blogContent, + }); + if (!res) { + toast({ title: "Aucune réponse du serveur." }); + return; + } + if (res.status === "Error") { + toast({ + title: "Erreur.", + content: "Une erreur est survenue.", + }); + } + if (res.data) console.log(res.data); + return res; + } catch (error: any) { + toast({ + title: "Erreur.", + content: "Une erreur est survenue.", + }); + } + }} + > + Publier + + + + +
+ ); +} diff --git a/frontend/app/(auth)/dashboard/blogs/new/page.tsx b/frontend/app/(auth)/dashboard/blogs/new/page.tsx index 1780b8a..a655364 100644 --- a/frontend/app/(auth)/dashboard/blogs/new/page.tsx +++ b/frontend/app/(auth)/dashboard/blogs/new/page.tsx @@ -1,73 +1,13 @@ "use client"; -import EditableText from "@/components/editable-text"; -import { LocalEditor } from "@/components/editor"; -import { Button } from "@/components/ui/button"; -import useApiMutation from "@/hooks/use-api"; -import sluggify from "@/lib/sluggify"; -import { NewBlog } from "@/types/types"; -import { useMemo, useState } from "react"; -export default function BlogEditor() { - const [title, setTitle] = useState(""); - const slug = useMemo(() => sluggify(title), [title]); - const { - trigger: newBlog, - isMutating: loading, - isSuccess, - } = useApiMutation( - "/blog/new", - undefined, - "POST", - true, - false, - ); +import { Loader2 } from "lucide-react"; +import dynamic from "next/dynamic"; - const content = localStorage.getItem("blog_draft") ?? ""; +const BlogEditor = dynamic(() => import("./new").then((mod) => mod.default), { + ssr: false, + loading: () => , +}); - return ( -
- {/* This div should have a proper layout, ONLY PART TO BE MODIFIED*/} -
- {/*Should have an image or a placeholder that opens a dialog when clicked on to set the url of an image.*/} - -

{title}

-
-

{slug}

- {/*Should have a summary input box or textarea*/} -
-
-
- -
-
- - -
- ); +export default function Page() { + return ; } diff --git a/frontend/app/globals.css b/frontend/app/globals.css index f4f4112..724f19f 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -72,4 +72,8 @@ body { body { @apply bg-background text-foreground; } + + h1 { + @apply text-4xl font-bold; + } } diff --git a/frontend/components/action-button.tsx b/frontend/components/action-button.tsx new file mode 100644 index 0000000..8b0c91e --- /dev/null +++ b/frontend/components/action-button.tsx @@ -0,0 +1,100 @@ +import { Loader2 } from "lucide-react"; +import { Button } from "./ui/button"; +import React from "react"; +import { cn } from "@/lib/utils"; +import { ButtonProps } from "./ui/button"; + +interface ActionButtonProps extends ButtonProps { + isLoading?: boolean; + isSuccess?: boolean; + error?: any; + className?: string; + children?: React.ReactNode; // Allow custom subcomponents as children +} + +const ActionButtonDefault = React.forwardRef< + HTMLSpanElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => ( + + {children ?? "Submit"} + +)); + +const ActionButtonLoading = React.forwardRef< + HTMLSpanElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => ( + + {children ?? ( + <> + Loading... + + )} + +)); + +const ActionButtonSuccess = React.forwardRef< + HTMLSpanElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => ( + + {children ?? <>Success!} + +)); + +const ActionButtonError = React.forwardRef< + HTMLSpanElement, + React.HTMLAttributes +>(({ className, children, ...props }, ref) => ( + + {children ?? <>Error occurred.} + +)); + +const ActionButton = React.forwardRef( + ( + { variant, isLoading, isSuccess, error, className, children, ...props }, + ref, + ) => { + let buttonContent = null; + React.Children.forEach(children, (child) => { + if (React.isValidElement(child)) { + if (child.type === ActionButtonLoading && isLoading) { + buttonContent = child; + } else if (child.type === ActionButtonSuccess && isSuccess) { + buttonContent = child; + } else if (child.type === ActionButtonError && error) { + buttonContent = child; + } else if ( + child.type === ActionButtonDefault && + !isLoading && + !isSuccess && + !error + ) { + buttonContent = child; + } + } + }); + + return ( + + ); + }, +); + +export { + ActionButton, + ActionButtonLoading, + ActionButtonError, + ActionButtonSuccess, + ActionButtonDefault, +}; diff --git a/frontend/components/contact.tsx b/frontend/components/contact.tsx index 906983f..8711e01 100644 --- a/frontend/components/contact.tsx +++ b/frontend/components/contact.tsx @@ -1,12 +1,18 @@ "use client"; -import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import useApiMutation from "@/hooks/use-api"; import { useToast } from "@/hooks/use-toast"; -import { Loader2 } from "lucide-react"; +import { CircleCheckIcon, CircleXIcon, Loader2, SendIcon } from "lucide-react"; import { useState } from "react"; +import { + ActionButton, + ActionButtonDefault, + ActionButtonError, + ActionButtonLoading, + ActionButtonSuccess, +} from "./action-button"; interface FormData { firstname: string; @@ -159,26 +165,27 @@ const Contact = () => { required /> - + + + Message envoyé + + + {" "} + Chargement... + + + + Une erreur est survenue + + + Envoyer + + diff --git a/frontend/components/editor.tsx b/frontend/components/editor.tsx index 9019764..3bcfbfa 100644 --- a/frontend/components/editor.tsx +++ b/frontend/components/editor.tsx @@ -28,14 +28,32 @@ export function LocalEditor({ setTitle, }: EditorProps) { const getTitle = (editor: Editor) => { - const h1s: string[] = []; - editor.state.doc.descendants((node, pos) => { - if (node.type.name === "heading" && node.attrs.level === 1) { - h1s.push(node.textContent); - } - }); + const firstNode = editor.state.doc.firstChild; - return h1s.length > 0 ? h1s[0] : null; + if (!firstNode) { + editor.commands.setNode("heading", { + level: 1, + content: [{ type: "text", text: "Titre" }], + }); + } + + if ( + firstNode && + !(firstNode.type.name === "heading" && firstNode.attrs.level === 1) + ) { + setFirstLineAsH1(editor); + } + + return firstNode?.textContent; + }; + + const setFirstLineAsH1 = (editor: Editor) => { + const firstNode = editor.state.doc.firstChild; + + // Check if the first node is a paragraph and make it h1 + if (firstNode && firstNode.type.name === "paragraph") { + editor.commands.setNode("heading", { level: 1 }); + } }; const editor = useEditor({ @@ -70,7 +88,6 @@ export function LocalEditor({ }, onUpdate: ({ editor }) => { localStorage.setItem("blog_draft", editor.getHTML()); - // Set the first H1 if it exists const title = getTitle(editor); setTitle?.(title ?? ""); },