Can create new blogs

This commit is contained in:
cdricms
2025-02-21 19:46:36 +01:00
parent 7a97961fef
commit 4b005945b2
9 changed files with 362 additions and 99 deletions

View File

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

View File

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

View File

@@ -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"`

View File

@@ -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<string>("/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<undefined, NewBlog>(
"/blogs/new",
{},
"POST",
true,
false,
);
return (
<section className="m-10 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<EditableText onChange={setTitle}>
<h1>
{title.length > 0 ? title : "Un titre doit-être fourni"}
</h1>
</EditableText>
<p className="italic">{slug}</p>
<Dialog>
<DialogTrigger>
<img
src={imageUrl}
alt="Blog cover"
className="w-full h-60 object-cover cursor-pointer rounded-lg"
/>
</DialogTrigger>
<DialogTitle></DialogTitle>
<DialogContent>
<Input
type="text"
placeholder="Enter image URL"
value={imageUrl}
onChange={(e) => {
setImageUrl(e.target.value);
localStorage.setItem(
"blog_draft_image",
e.currentTarget.value,
);
}}
/>
</DialogContent>
</Dialog>
<Input
type="text"
placeholder="Enter a summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
/>
</div>
<div className="flex">
<div className="flex-1">
<LocalEditor content={content} setTitle={setTitle} />
</div>
</div>
<ActionButton
isLoading={isSending}
isSuccess={isSuccess}
error={error}
onClick={async () => {
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.",
});
}
}}
>
<ActionButtonDefault>Publier</ActionButtonDefault>
<ActionButtonSuccess />
<ActionButtonError />
<ActionButtonLoading />
</ActionButton>
</section>
);
}

View File

@@ -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<undefined, NewBlog>(
"/blog/new",
undefined,
"POST",
true,
false,
);
import { Loader2 } from "lucide-react";
import dynamic from "next/dynamic";
const content = localStorage.getItem("blog_draft") ?? "";
return (
<section className="m-10">
{/* This div should have a proper layout, ONLY PART TO BE MODIFIED*/}
<div>
{/*Should have an image or a placeholder that opens a dialog when clicked on to set the url of an image.*/}
<EditableText onChange={setTitle}>
<h1>{title}</h1>
</EditableText>
<p className="text-muted">{slug}</p>
{/*Should have a summary input box or textarea*/}
</div>
<div className="flex">
<div className="flex-1">
<LocalEditor content={content} setTitle={setTitle} />
</div>
</div>
<Button
className="text-black bg-white"
onClick={async () => {
try {
const blogContent = localStorage.getItem("blog_draft");
if (!blogContent) return;
if (title.length < 1) return;
const res = await newBlog({
title,
summary: "A summary",
image: "none",
slug,
content: blogContent,
const BlogEditor = dynamic(() => import("./new").then((mod) => mod.default), {
ssr: false,
loading: () => <Loader2 className="animate-spin" />,
});
if (!res)
throw new Error("The server hasn't responded.");
if (res.status === "Error")
throw new Error(res.message);
if (res.data) console.log(res.data);
return res;
} catch (error: any) {
throw new Error(error.message);
}
}}
>
Sauvegarder
</Button>
</section>
);
export default function Page() {
return <BlogEditor />;
}

View File

@@ -72,4 +72,8 @@ body {
body {
@apply bg-background text-foreground;
}
h1 {
@apply text-4xl font-bold;
}
}

View File

@@ -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<HTMLSpanElement>
>(({ className, children, ...props }, ref) => (
<span ref={ref} className={cn("flex items-center", className)} {...props}>
{children ?? "Submit"}
</span>
));
const ActionButtonLoading = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, children, ...props }, ref) => (
<ActionButtonDefault ref={ref} className={className} {...props}>
{children ?? (
<>
<Loader2 className="animate-spin mr-2" /> Loading...
</>
)}
</ActionButtonDefault>
));
const ActionButtonSuccess = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, children, ...props }, ref) => (
<ActionButtonDefault ref={ref} className={className} {...props}>
{children ?? <>Success!</>}
</ActionButtonDefault>
));
const ActionButtonError = React.forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(({ className, children, ...props }, ref) => (
<ActionButtonDefault ref={ref} className={className} {...props}>
{children ?? <>Error occurred.</>}
</ActionButtonDefault>
));
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
(
{ 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 (
<Button
ref={ref}
disabled={isLoading || isSuccess || error !== undefined}
type="submit"
className={`w-full transition-all ease-in-out ${isSuccess ? "bg-green-800" : error ? "bg-red-800" : ""} ${className}`}
{...props}
>
{buttonContent}
</Button>
);
},
);
export {
ActionButton,
ActionButtonLoading,
ActionButtonError,
ActionButtonSuccess,
ActionButtonDefault,
};

View File

@@ -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
/>
</div>
<Button
disabled={
isLoading || isSuccess || error !== undefined
}
type="submit"
className={`w-full transition-all ease-in-out ${isSuccess ? "bg-green-800" : error ? "bg-red-800" : ""}`}
<ActionButton
isLoading={isLoading}
isSuccess={isSuccess}
error={error}
>
{isSuccess ? (
<>Message envoyé</>
) : error ? (
<>Échec de l'envoie du mail.</>
) : (
<>
{isLoading && (
<Loader2 className="animate-spin" />
)}
Envoyer
</>
)}
</Button>
<ActionButtonSuccess className="gap-2">
<CircleCheckIcon className="animate-pulse" />
Message envoyé
</ActionButtonSuccess>
<ActionButtonLoading className="gap-2">
<Loader2 className="animate-spin" />{" "}
Chargement...
</ActionButtonLoading>
<ActionButtonError className="gap-2">
<CircleXIcon className="animate-pulse" />
Une erreur est survenue
</ActionButtonError>
<ActionButtonDefault className="gap-2">
<SendIcon /> Envoyer
</ActionButtonDefault>
</ActionButton>
</form>
</div>
</div>

View File

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