diff --git a/Wave/Assets/React/ArticleEditor.tsx b/Wave/Assets/React/ArticleEditor.tsx index c2b1fa4..f4d5982 100644 --- a/Wave/Assets/React/ArticleEditor.tsx +++ b/Wave/Assets/React/ArticleEditor.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { updateCharactersLeft, insertBeforeSelection, insertBeforeAndAfterSelection } from "../utilities/md_functions"; +import { updateCharactersLeft } from "../utilities/md_functions"; import { LabelInput, ToolBarButton } from "./Forms"; import ImageEditor from "./ImageEditor"; import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models"; @@ -215,8 +215,8 @@ export default function Editor() {
  • {t("Published")}
  • - setImageDialog(false)} callback={(location) => { - textAreaMarkdown.current?.append(`\n![](${location})\n`) + setImageDialog(false)} callback={(location, description) => { + textAreaMarkdown.current?.append(`\n![${description}](${location})\n`) setImageDialog(false) }} /> diff --git a/Wave/Assets/React/ImageEditor.tsx b/Wave/Assets/React/ImageEditor.tsx index e54dd78..c90bb3a 100644 --- a/Wave/Assets/React/ImageEditor.tsx +++ b/Wave/Assets/React/ImageEditor.tsx @@ -1,33 +1,93 @@ -import React, {useEffect} from "react"; +import React, {ChangeEvent, useEffect, useState} from "react"; import Modal from "./Modal"; -const ImageEditor = function({open = false, onClose, callback}: {open: boolean, onClose: () => void, callback: (location: string) => void}){ +interface ImageEditorProperties { + open: boolean, + onClose: () => void, + callback: (location: string, description?: string) => void, + t: any +} + +const ImageEditor = function({open = false, onClose, callback, t}: ImageEditorProperties){ + const [busy, setBusy] = useState(false); + const [file, setFile] = useState(""); + async function onSubmit(event: React.FormEvent) { event.preventDefault(); + const elem = event.target as HTMLFormElement; + const fileElem = elem.file as HTMLInputElement; + if (fileElem.value.length < 1) return; - 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); + if (busy) return; + setBusy(true); + + try { + const formData = new FormData(elem); + let response = await fetch("/images/create", { + method: "PUT", + body: formData + }) + if (!response.ok) { + throw new Error(response.statusText); + } + + (event.target as HTMLFormElement)?.reset() + setFile("") + + const loc = response.headers.get("Location") as string; + callback(loc, formData.get("imageAlt") as string); + } finally { + setBusy(false); } + } - (event.target as HTMLFormElement)?.reset() - - const loc = response.headers.get("Location") as string; - callback(loc); + function onImageChange(event: ChangeEvent) { + const fileInput = event.target as HTMLInputElement; + if (!fileInput || !fileInput.files || fileInput.files.length < 1) return; + setFile(URL.createObjectURL(fileInput.files[0])); } return ( - -
    - + +
    + + - - + + + + + + + {busy && +
    + + {t("image.Uploading")} +
    + } + +
    + +
    +
    ) } diff --git a/Wave/Assets/React/Modal.tsx b/Wave/Assets/React/Modal.tsx index 4d5dddd..1e16250 100644 --- a/Wave/Assets/React/Modal.tsx +++ b/Wave/Assets/React/Modal.tsx @@ -5,9 +5,10 @@ interface ModalProperties { onClose: () => void, className?: string, children: React.ReactNode, + t: any, } -const Modal = function({open, onClose, children}: ModalProperties) { +const Modal = function({open, onClose, children, t}: ModalProperties) { const ref = useRef(null); useEffect(() => { @@ -16,12 +17,15 @@ const Modal = function({open, onClose, children}: ModalProperties) { }, [open]) return ( - - {children} + +
    + {children} - + +
    ) } diff --git a/Wave/Assets/main.tsx b/Wave/Assets/main.tsx index 9084a15..ae6981b 100644 --- a/Wave/Assets/main.tsx +++ b/Wave/Assets/main.tsx @@ -96,6 +96,20 @@ if (domNode) { }, editor: { unsaved_changes_notice: "You have unsaved changes, save now so you don't loose them!", + }, + dialog: { + Cancel: "Cancel" + }, + image: { + Uploading: "Loading Image...", + Save: "Upload", + Quality: "Image Quality", + Alt: "Image Description (Optional)", + quality: { + Normal: "Normal", + High: "High", + Source: "Source", + } } } }, @@ -155,6 +169,20 @@ if (domNode) { }, editor: { unsaved_changes_notice: "Sie haben ungesicherte Änderungen, speichern Sie jetzt um diese nicht zu verlieren!", + }, + dialog: { + Cancel: "Abbrechen" + }, + image: { + Uploading: "Lade Bild...", + Save: "Hochladen", + Quality: "Bildqualität", + Alt: "Bildbeschreibung (Optional)", + quality: { + Normal: "Normal", + High: "Hoch", + Source: "Original", + } } } } diff --git a/Wave/Controllers/ImageController.cs b/Wave/Controllers/ImageController.cs index 50d8577..dfdd8e9 100644 --- a/Wave/Controllers/ImageController.cs +++ b/Wave/Controllers/ImageController.cs @@ -26,7 +26,7 @@ public class ImageController(ImageService imageService) : ControllerBase { [Consumes("multipart/form-data")] public async Task CreateImageAsync( [FromForm] IFormFile file, - ImageService.ImageQuality quality = ImageService.ImageQuality.Normal) { + [FromForm] ImageService.ImageQuality quality = ImageService.ImageQuality.Normal) { try { string tempFile = Path.GetTempFileName(); { @@ -34,7 +34,7 @@ public class ImageController(ImageService imageService) : ControllerBase { await file.CopyToAsync(stream); stream.Close(); } - var id = await ImageService.StoreImageAsync(tempFile); + var id = await ImageService.StoreImageAsync(tempFile, quality:quality); if (id is null) throw new ApplicationException("Saving image failed unexpectedly."); return Created($"/images/{id}", new CreateResponse(id.Value)); } catch (Exception ex) { diff --git a/Wave/Services/ImageService.cs b/Wave/Services/ImageService.cs index 85d7336..34fbb0d 100644 --- a/Wave/Services/ImageService.cs +++ b/Wave/Services/ImageService.cs @@ -9,11 +9,15 @@ public enum ImageQuality { private ILogger Logger { get; } = logger; private const string BasePath = "./files/images"; - private const string ImageExtension = ".jpg"; - public string ImageMimeType => "image/jpg"; + private const string ImageExtension = ".webp"; + public string ImageMimeType => "image/webp"; public string? GetPath(Guid imageId) { string path = Path.Combine(BasePath, imageId + ImageExtension); + // Fallback for older version + if (!File.Exists(path)) { + path = Path.Combine(BasePath, imageId + ".jpg"); + } return File.Exists(path) ? path : null; } @@ -27,7 +31,7 @@ public enum ImageQuality { } public async ValueTask StoreImageAsync(string temporaryPath, int size = 800, bool enforceSize = false, - CancellationToken cancellation = default) { + CancellationToken cancellation = default, ImageQuality quality = ImageQuality.Normal) { if (File.Exists(temporaryPath) is not true) return null; try { @@ -35,12 +39,24 @@ public enum ImageQuality { var image = new MagickImage(); await image.ReadAsync(temporaryPath, cancellation); + + image.Format = MagickFormat.WebP; + if (quality is ImageQuality.Source) { + image.Quality = 100; + // Do not resize + } else { + int storedSize = size; + if (quality is ImageQuality.Normal && storedSize < 800) storedSize = 800; + if (quality is ImageQuality.High && storedSize < 1600) storedSize = 1600; - // Jpeg with 90% compression should look decent - image.Resize(new MagickGeometry(size)); // this preserves aspect ratio - if (enforceSize) image.Extent(new MagickGeometry(size), Gravity.Center, MagickColors.Black); - image.Format = MagickFormat.Jpeg; - image.Quality = 90; + image.Resize(new MagickGeometry(storedSize)); // this preserves aspect ratio + if (enforceSize) image.Extent(new MagickGeometry(storedSize), Gravity.Center, MagickColors.Black); + image.Quality = quality switch { + ImageQuality.Normal => 85, + ImageQuality.High => 95, + var _ => throw new ArgumentOutOfRangeException(nameof(quality), quality, null) + }; + } if (image.GetExifProfile() is { } exifProfile) image.RemoveProfile(exifProfile);