Implemented Image Upload Modal in Article Editor
This commit is contained in:
parent
f61188784d
commit
1750542182
|
@ -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>
|
||||
</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}/>
|
||||
<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">
|
||||
|
|
|
@ -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"}
|
||||
|
|
35
Wave/Assets/React/ImageEditor.tsx
Normal file
35
Wave/Assets/React/ImageEditor.tsx
Normal 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;
|
29
Wave/Assets/React/Modal.tsx
Normal file
29
Wave/Assets/React/Modal.tsx
Normal 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;
|
|
@ -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",
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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
BIN
Wave/package-lock.json
generated
Binary file not shown.
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue