Started working on the blogs
This commit is contained in:
@@ -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{
|
||||||
|
|||||||
25
backend/cmd/migrate/migrations/20250221153208_update_blog.go
Normal file
25
backend/cmd/migrate/migrations/20250221153208_update_blog.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
40
frontend/components/editable-text.tsx
Normal file
40
frontend/components/editable-text.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
11
frontend/lib/sluggify.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user