Implemented Image Upload Modal in Article Editor

This commit is contained in:
Mia Rose Winter 2024-11-18 14:30:06 +01:00
parent f61188784d
commit 1750542182
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
9 changed files with 164 additions and 29 deletions

View file

@ -1,11 +1,15 @@
import React, { useState, useEffect, useRef } from "react";
import { updateCharactersLeft, insertBeforeSelection, insertBeforeAndAfterSelection } from "../utilities/md_functions";
import { LabelInput, ToolBarButton } from "./Forms";
import ImageEditor from "./ImageEditor";
import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models";
import { useTranslation } from 'react-i18next';
// @ts-ignore
import markdownit from "markdown-it";
// @ts-ignore
import markdownitmark from "markdown-it-mark";
import "groupby-polyfill/lib/polyfill.js";
import TextareaMarkdownEditor from 'react-textarea-markdown-editor';
const nameof = function<T>(name: keyof T) { return name; }
@ -62,6 +66,7 @@ export default function Editor() {
const [isPublished, setIsPublished] = useState(false);
const [article, setArticle] = useState<ArticleView|null>(null);
const [categories, setCategories] = useState<Category[]>([]);
const [imageDialog, setImageDialog] = useState(false);
const [model, setModel] = useState<ArticleDto>({
body: "",
categories: [],
@ -155,6 +160,7 @@ export default function Editor() {
categories: result.categories.map(c => c.id),
}));
setArticle(result);
// setMarkdown(result.body)
console.log("Article loaded");
})
.catch(error => {
@ -165,16 +171,27 @@ export default function Editor() {
}
}, ([setArticle, setNotice, console, location]) as any[]);
const markdownArea = useRef<HTMLTextAreaElement>(null);
const textAreaMarkdown = useRef<TextareaMarkdownEditor>(null);
const markdownOnChange = function(v: string) {
onChangeModel({
target: {
// @ts-ignore
value: v,
name: nameof<ArticleDto>("body")
}
});
return {};
}
// @ts-ignore
return (
<>
{
dirty &&
<div role="alert" className="alert alert-warning sticky left-4 right-4 top-4 mb-4 z-50 rounded-sm">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6 hidden md:inline">
<path fillRule="evenodd" clipRule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" />
</svg>
<p>{t("editor.unsaved_changes_notice")}</p>
<p className="line-clamp-1">{t("editor.unsaved_changes_notice")}</p>
</div>
}
{
@ -198,6 +215,11 @@ export default function Editor() {
<li className={`step w-24 ${article.status === ArticleStatus.Published ? "step-primary" : ""}`}>{t("Published")}</li>
</ul>
<ImageEditor open={imageDialog} onClose={() => setImageDialog(false)} callback={(location) => {
textAreaMarkdown.current?.append(`\n![](${location})\n`)
setImageDialog(false)
}} />
<form method="post" onSubmit={onSubmit}>
<fieldset className="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
<LabelInput label={t("Title_Label")}>
@ -213,6 +235,7 @@ export default function Editor() {
onChange={onChangeModel} name={nameof<ArticleDto>("categories")}
defaultValue={article.categories.map(c => c.id)}>
{
// @ts-ignore
Array.from(Map.groupBy(categories, (c: Category) => c.color) as Map<CategoryColor, Category[]>)
.map((value, _) =>
<optgroup className="font-bold not-italic my-3"
@ -242,77 +265,77 @@ export default function Editor() {
<fieldset className="my-6 grid grid-cols-1 lg:grid-cols-2 gap-x-8 gap-y-4">
<div className="join join-vertical min-h-96 h-full w-full">
<div className="flex flex-wrap gap-1 p-2 z-50 bg-base-200 sticky top-0"
<div className="flex flex-wrap gap-1 p-2 z-50 bg-base-200 sticky top-20 rounded-b-none rounded-t-sm"
role="toolbar">
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.H1_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "# ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("# ")}>
<strong>{t("Tools.H1_Label")}</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.H2_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "## ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("## ")}>
<strong>{t("Tools.H2_Label")}</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.H3_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "### ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("### ")}>
<strong>{t("Tools.H3_Label")}</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.H4_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "#### ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("#### ")}>
<strong>{t("Tools.H4_Label")}</strong>
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.Bold_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "**")}>
onClick={() => textAreaMarkdown.current?.mark('**', '**',t("Tools.Bold_Tooltip"))}>
<strong>B</strong>
</ToolBarButton>
<ToolBarButton title={t("Tools.Italic_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "*")}>
onClick={() => textAreaMarkdown.current?.mark('*', '*',t("Tools.Italic_Tooltip"))}>
<em>I</em>
</ToolBarButton>
<ToolBarButton title={t("Tools.Underline_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "++")}>
onClick={() => textAreaMarkdown.current?.mark('+', '+',t("Tools.Underline_Tooltip"))}>
<span className="underline">U</span>
</ToolBarButton>
<ToolBarButton title={t("Tools.StrikeThrough_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "~~")}>
onClick={() => textAreaMarkdown.current?.mark('~~', '~~',t("Tools.StrikeThrough_Tooltip"))}>
<del>{t("Tools.StrikeThrough_Label")}</del>
</ToolBarButton>
<ToolBarButton title={t("Tools.Mark_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "==")}>
onClick={() => textAreaMarkdown.current?.mark('==', '==',t("Tools.Mark_Tooltip"))}>
<mark>{t("Tools.Mark_Label")}</mark>
</ToolBarButton>
<ToolBarButton title={t("Tools.Mark_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "> ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("> ")}>
| <em>{t("Tools.Cite_Label")}</em>
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "1. ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("1. ")}>
1.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "a. ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("a. ")}>
a.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "A. ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("A. ")}>
A.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "i. ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("i. ")}>
i.
</ToolBarButton>
<ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "I. ", true)}>
onClick={() => textAreaMarkdown.current?.markLine("I. ")}>
I.
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.CodeLine_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "`")}>
onClick={() => textAreaMarkdown.current?.mark('`', '`',t("Tools.CodeLine_Tooltip"))}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4">
@ -322,7 +345,7 @@ export default function Editor() {
</svg>
</ToolBarButton>
<ToolBarButton title={t("Tools.CodeBlock_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "```")}>
onClick={() => textAreaMarkdown.current?.mark('```\n', '\n```\n',t("Tools.CodeBlock_Tooltip"))}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor"
className="w-4 h-4">
@ -332,12 +355,25 @@ export default function Editor() {
</svg>
</ToolBarButton>
</div>
<div className="join join-horizontal">
<ToolBarButton title={t("Tools.ImageAdd_Tooltip")}
onClick={() => setImageDialog(true)}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" className="w-4 h-4">
<path fill-rule="evenodd"
d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z"
clip-rule="evenodd"/>
</svg>
<span>{t("Tools.ImageAdd_Label")}</span>
</ToolBarButton>
</div>
<textarea ref={markdownArea} id="tool-target"
className="resize-none textarea textarea-bordered outline-none w-full flex-1 join-item"
required aria-required placeholder={t("Body_Placeholder")}
autoComplete="off"
name={nameof<ArticleDto>("body")} value={model.body} onChange={onChangeModel}/>
</div>
<TextareaMarkdownEditor ref={textAreaMarkdown} markers={[]}
textareaId="tool-target"
value={model.body} onChange={markdownOnChange}
className="first:*:hidden flex-1 flex join-item *:rounded-t-none *:rounded-b-sm w-full *:w-full *:h-full *:flex-1 *:resize-none *:outline-none *:textarea *:textarea-bordered"
placeholder={t("Body_Placeholder")}
doParse={md.render}/>
</div>
<div className="bg-base-200 p-2">
<h2 className="text-2xl lg:text-4xl font-bold mb-6 hyphens-auto">

View file

@ -14,7 +14,7 @@ export function LabelInput({label, className, children} : ILabelProperties) : Re
}
export function ToolBarButton({title, onClick, children}: {title?: string, onClick:React.MouseEventHandler<HTMLButtonElement>, children:any}) {
return <button type="button" className="btn btn-accent btn-sm outline-none font-normal join-item"
return <button type="button" className="btn btn-accent btn-sm text-sm justify-center items-center outline-none font-normal join-item"
title={title}
onClick={onClick}>
{children ?? "err"}

View file

@ -0,0 +1,35 @@
import React, {useEffect} from "react";
import Modal from "./Modal";
const ImageEditor = function({open = false, onClose, callback}: {open: boolean, onClose: () => void, callback: (location: string) => void}){
async function onSubmit(event: React.FormEvent) {
event.preventDefault();
const formData = new FormData(event.target as HTMLFormElement);
let response = await fetch("/images/create", {
method: "PUT",
body: formData
})
if (!response.ok) {
throw new Error(response.statusText);
}
(event.target as HTMLFormElement)?.reset()
const loc = response.headers.get("Location") as string;
callback(loc);
}
return (
<Modal open={open} onClose={onClose}>
<form onSubmit={onSubmit} className="flex gap-2 p-2">
<input id="file" name="file" type="file" alt="Image"
className="file-input file-input-bordered w-full max-w-xs" autoFocus={true}/>
<button type="submit" className="btn btn-primary w-full sm:btn-wide">Upload</button>
</form>
</Modal>
)
}
export default ImageEditor;

View file

@ -0,0 +1,29 @@
import React, {useEffect, useRef} from "react";
interface ModalProperties {
open: boolean,
onClose: () => void,
className?: string,
children: React.ReactNode,
}
const Modal = function({open, onClose, children}: ModalProperties) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (open && ref.current) ref.current.showModal();
else if (ref.current) ref.current.close()
}, [open])
return (
<dialog ref={ref} onCancel={onClose} className="p-4 rounded-lg bg-base-200 border border-base-300 shadow z-[100] backdrop:bg-base-100 backdrop:bg-opacity-50">
{children}
<button type="button" className="btn btn-primary w-full sm:btn-wide" onClick={onClose}>
Cancel
</button>
</dialog>
)
}
export default Modal;

View file

@ -78,6 +78,9 @@ if (domNode) {
CodeLine_Tooltip: "Mark selected text as programming code",
CodeBlock_Tooltip: "Insert program code block",
ImageAdd_Label: "Image",
ImageAdd_Tooltip: "Upload an Image",
},
Category: {
Primary: "Primary Category",
@ -134,6 +137,9 @@ if (domNode) {
CodeLine_Tooltip: "Selektierten text als programmcode markieren",
CodeBlock_Tooltip: "Programmierblock einfügen",
ImageAdd_Label: "Bild",
ImageAdd_Tooltip: "Bild Hochladen",
},
Category: {
Primary: "Hauptkategorie",

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Wave.Services;
namespace Wave.Controllers;
@ -19,4 +20,27 @@ public class ImageController(ImageService imageService) : ControllerBase {
if (size < 800) return File(await ImageService.GetResized(path, size), ImageService.ImageMimeType);
return File(System.IO.File.OpenRead(path), ImageService.ImageMimeType);
}
[HttpPut("create")]
[Authorize(Policy = "ArticleEditPermissions")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> CreateImageAsync(
[FromForm] IFormFile file,
ImageService.ImageQuality quality = ImageService.ImageQuality.Normal) {
try {
string tempFile = Path.GetTempFileName();
{
await using var stream = System.IO.File.OpenWrite(tempFile);
await file.CopyToAsync(stream);
stream.Close();
}
var id = await ImageService.StoreImageAsync(tempFile);
if (id is null) throw new ApplicationException("Saving image failed unexpectedly.");
return Created($"/images/{id}", new CreateResponse(id.Value));
} catch (Exception ex) {
return BadRequest($"Failed to process image: {ex.Message}.");
}
}
record CreateResponse(Guid Id);
}

View file

@ -3,6 +3,10 @@
namespace Wave.Services;
public class ImageService(ILogger<ImageService> logger) {
public enum ImageQuality {
Normal, High, Source
}
private ILogger<ImageService> Logger { get; } = logger;
private const string BasePath = "./files/images";
private const string ImageExtension = ".jpg";
@ -52,7 +56,7 @@ public class ImageService(ILogger<ImageService> logger) {
await image.WriteAsync(path, cancellation);
return guid;
} catch (Exception ex) {
Logger.LogInformation(ex, "Failed to process uploaded image.");
Logger.LogWarning(ex, "Failed to process uploaded image.");
return null;
}
}

BIN
Wave/package-lock.json generated

Binary file not shown.

View file

@ -33,6 +33,7 @@
"vite": "^5.2.13"
},
"dependencies": {
"groupby-polyfill": "^1.0.0"
"groupby-polyfill": "^1.0.0",
"react-textarea-markdown-editor": "^2.3.0"
}
}