Started working on the blogs

This commit is contained in:
cdricms
2025-02-21 17:49:42 +01:00
parent de828d4c13
commit 7a97961fef
9 changed files with 178 additions and 79 deletions

View File

@@ -3,7 +3,6 @@ package blogs
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
core "fr.latosa-escrima/core" core "fr.latosa-escrima/core"
@@ -11,14 +10,6 @@ import (
) )
func HandleNew(w http.ResponseWriter, r *http.Request) { func HandleNew(w http.ResponseWriter, r *http.Request) {
_, err := io.ReadAll(r.Body)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusNoContent)
return
}
var blog models.Blog var blog models.Blog
if err := json.NewDecoder(r.Body).Decode(&blog); err != nil { if err := json.NewDecoder(r.Body).Decode(&blog); err != nil {
core.JSONError{ core.JSONError{

View File

@@ -0,0 +1,25 @@
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.NewDropColumn().
Model((*models.Blog)(nil)).
Column("href").
Exec(ctx)
_, err = db.Exec(`ALTER TABLE blogs RENAME COLUMN label TO title;
ALTER TABLE blogs ALTER COLUMN title SET NOT NULL;`)
return err
}, func(ctx context.Context, db *bun.DB) error {
fmt.Print(" [down migration] ")
return nil
})
}

View File

@@ -13,12 +13,11 @@ type Blog struct {
BlogID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"blogID"` BlogID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"blogID"`
Slug string `bun:"slug,unique,notnull" json:"slug"` Slug string `bun:"slug,unique,notnull" json:"slug"`
Content string `bun:"content,notnull" json:"content"` Content string `bun:"content,notnull" json:"content"`
Label string `bun:"label" json:"label"` Title string `bun:"title,notnull" json:"title"`
AuthorID uuid.UUID `bun:"author_id,type:uuid,notnull" json:"authorID"` AuthorID uuid.UUID `bun:"author_id,type:uuid,notnull" json:"authorID"`
Published time.Time `bun:"published,default:current_timestamp" json:"published"` Published time.Time `bun:"published,default:current_timestamp" json:"published"`
Summary string `bun:"summary" json:"summary"` Summary string `bun:"summary" json:"summary"`
Image string `bun:"image" json:"image"` Image string `bun:"image" json:"image"`
Href string `bun:"href" json:"href"`
Author User `bun:"rel:belongs-to,join:author_id=user_id" json:"author"` Author User `bun:"rel:belongs-to,join:author_id=user_id" json:"author"`
} }

View File

@@ -1,54 +0,0 @@
"use client";
import { Editor } from "@/components/editor";
import { Button } from "@/components/ui/button";
import useApiMutation from "@/hooks/use-api";
import { useState } from "react";
export default function BlogEditor() {
const {
trigger,
isMutating: loading,
isSuccess,
} = useApiMutation("/blog/new", undefined, "POST", false, true);
const content = localStorage.getItem("blog_draft") ?? "";
return (
<section className="m-10">
<div className="flex">
<div className="flex-1">
<Editor content={content} />
</div>
</div>
<Button
className="text-black bg-white"
onClick={async () => {
try {
// const blogContent = localStorage.getItem("blog_draft");
const res = await trigger({
label: "This is my label",
summary: "A summary",
image: "none",
href: "none",
blogID: "id",
slug: "myslug",
// content: blogContent,
published: "",
});
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>
);
}

View File

@@ -1,14 +1,73 @@
"use client"; "use client";
import dynamic from "next/dynamic"; 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";
const Editor = dynamic(() => import("./editor").then((mod) => mod.default), { export default function BlogEditor() {
ssr: false, 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,
);
const content = localStorage.getItem("blog_draft") ?? "";
export default async function NewBlog() {
return ( return (
<> <section className="m-10">
<Editor /> {/* 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,
});
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>
); );
} }

View File

@@ -0,0 +1,40 @@
import { useState, ReactNode, cloneElement } from "react";
import { Input } from "@/components/ui/input";
interface EditableTextProps {
children: ReactNode;
onChange: (newText: string) => void;
}
export default function EditableText({
children,
onChange,
}: EditableTextProps) {
const [isEditing, setIsEditing] = useState(false);
const child = Array.isArray(children) ? children[0] : children;
const text = child?.props.children || "";
const handleBlur = () => {
setIsEditing(false);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value);
};
return (
<div onClick={() => setIsEditing(true)}>
{isEditing ? (
<Input
autoFocus
value={text}
onChange={handleChange}
onBlur={handleBlur}
/>
) : (
cloneElement(child, {}, text)
)}
</div>
);
}

View File

@@ -4,7 +4,7 @@ import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { EditorContent, useEditor } from "@tiptap/react"; import { Editor, EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline"; import Underline from "@tiptap/extension-underline";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
@@ -16,11 +16,28 @@ import { EditorMenu } from "./editor-menu";
interface EditorProps { interface EditorProps {
content: string; content: string;
onChange?: (markdown: string) => void; onChange?: (content: string) => void;
className?: string; className?: string;
setTitle?: React.Dispatch<React.SetStateAction<string>>;
} }
export function Editor({ content, onChange, className }: EditorProps) { export function LocalEditor({
content,
onChange,
className,
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);
}
});
return h1s.length > 0 ? h1s[0] : null;
};
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
@@ -46,9 +63,16 @@ export function Editor({ content, onChange, className }: EditorProps) {
class: "prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none dark:prose-invert", class: "prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl m-5 focus:outline-none dark:prose-invert",
}, },
}, },
onCreate: ({ editor }) => {
const title = getTitle(editor);
setTitle?.(title ?? "");
},
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
console.log("Update");
localStorage.setItem("blog_draft", editor.getHTML()); localStorage.setItem("blog_draft", editor.getHTML());
// Set the first H1 if it exists
const title = getTitle(editor);
setTitle?.(title ?? "");
}, },
}); });

11
frontend/lib/sluggify.ts Normal file
View File

@@ -0,0 +1,11 @@
export default function sluggify(text: string): string {
return text
.toLowerCase() // Convert to lowercase
.trim() // Remove leading/trailing whitespace
.normalize("NFD") // Normalize special characters
.replace(/[\u0300-\u036f]/g, "") // Remove diacritics
.replace(/[^a-z0-9\s-]/g, "") // Remove special characters except spaces and hyphens
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Replace multiple hyphens with single hyphen
.slice(0, 100); // Limit length to 100 characters
}

View File

@@ -24,16 +24,20 @@ export interface Blog {
blogID: string; blogID: string;
slug: string; slug: string;
content: string; content: string;
label?: string; title: string;
authorID: string; authorID: string;
published: string; published: string;
summary?: string; summary?: string;
image?: string; image?: string;
href?: string;
author: User; // Relation to User author: User; // Relation to User
} }
export type NewBlog = Omit<
Blog,
"blogID" | "authorID" | "author" | "published"
>;
// User type definition // User type definition
export interface User { export interface User {
userId: string; // UUID represented as a string userId: string; // UUID represented as a string