Implemented Image quality settings
This commit is contained in:
		
							parent
							
								
									1750542182
								
							
						
					
					
						commit
						807eda0870
					
				|  | @ -1,5 +1,5 @@ | ||||||
| import React, { useState, useEffect, useRef } from "react"; | 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 { LabelInput, ToolBarButton } from "./Forms"; | ||||||
| import ImageEditor from "./ImageEditor"; | import ImageEditor from "./ImageEditor"; | ||||||
| import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models"; | import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models"; | ||||||
|  | @ -215,8 +215,8 @@ export default function Editor() { | ||||||
| 								<li className={`step w-24 ${article.status === ArticleStatus.Published ? "step-primary" : ""}`}>{t("Published")}</li> | 								<li className={`step w-24 ${article.status === ArticleStatus.Published ? "step-primary" : ""}`}>{t("Published")}</li> | ||||||
| 							</ul> | 							</ul> | ||||||
| 
 | 
 | ||||||
| 							<ImageEditor open={imageDialog} onClose={() => setImageDialog(false)} callback={(location) => { | 							<ImageEditor open={imageDialog} t={t} onClose={() => setImageDialog(false)} callback={(location, description) => { | ||||||
| 								textAreaMarkdown.current?.append(`\n\n`) | 								textAreaMarkdown.current?.append(`\n\n`) | ||||||
| 								setImageDialog(false) | 								setImageDialog(false) | ||||||
| 							}} /> | 							}} /> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,11 +1,28 @@ | ||||||
| import React, {useEffect} from "react"; | import React, {ChangeEvent, useEffect, useState} from "react"; | ||||||
| import Modal from "./Modal"; | 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) { |     async function onSubmit(event: React.FormEvent) { | ||||||
|         event.preventDefault(); |         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); |         if (busy) return; | ||||||
|  |         setBusy(true); | ||||||
|  | 
 | ||||||
|  |         try { | ||||||
|  |             const formData = new FormData(elem); | ||||||
|             let response = await fetch("/images/create", { |             let response = await fetch("/images/create", { | ||||||
|                 method: "PUT", |                 method: "PUT", | ||||||
|                 body: formData |                 body: formData | ||||||
|  | @ -15,19 +32,62 @@ const ImageEditor = function({open = false, onClose, callback}: {open: boolean, | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             (event.target as HTMLFormElement)?.reset() |             (event.target as HTMLFormElement)?.reset() | ||||||
|  |             setFile("") | ||||||
| 
 | 
 | ||||||
|             const loc = response.headers.get("Location") as string; |             const loc = response.headers.get("Location") as string; | ||||||
|         callback(loc); |             callback(loc, formData.get("imageAlt") as string); | ||||||
|  |         } finally { | ||||||
|  |             setBusy(false); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     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 ( |     return ( | ||||||
|         <Modal open={open} onClose={onClose}> |         <Modal open={open} onClose={onClose} t={t}> | ||||||
|             <form onSubmit={onSubmit} className="flex gap-2 p-2"> |             <div className="grid grid-rows-1 md:grid-rows-1 md:grid-cols-2 gap-2 max-w-2xl"> | ||||||
|                 <input id="file" name="file" type="file" alt="Image" |                 <form onSubmit={onSubmit} className="flex flex-col justify-stretch items-center gap-2 order-2"> | ||||||
|  |                     <input id="file" name="file" type="file" alt="Image" onChange={onImageChange} | ||||||
|                            className="file-input file-input-bordered w-full max-w-xs" autoFocus={true}/> |                            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> |                     <label className="form-control w-full max-w-xs"> | ||||||
|  |                         <div className="label"> | ||||||
|  |                             <span className="label-text">{t("image.Quality")}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <select id="quality" name="quality" className="select select-bordered w-full"> | ||||||
|  |                             <option value={0}>{t("image.quality.Normal")}</option> | ||||||
|  |                             <option value={1}>{t("image.quality.High")}</option> | ||||||
|  |                             <option value={2}>{t("image.quality.Source")}</option> | ||||||
|  |                         </select> | ||||||
|  |                     </label> | ||||||
|  | 
 | ||||||
|  |                     <label className="form-control w-full max-w-xs"> | ||||||
|  |                         <div className="label"> | ||||||
|  |                             <span className="label-text">{t("image.Alt")}</span> | ||||||
|  |                         </div> | ||||||
|  |                         <input type="text" name="imageAlt" className="input input-bordered w-full" autoComplete={"off"} /> | ||||||
|  |                     </label> | ||||||
|  | 
 | ||||||
|  |                     <button type="submit" className="btn btn-primary w-full max-w-xs" disabled={busy}> | ||||||
|  |                         {t("image.Save")} | ||||||
|  |                     </button> | ||||||
|  | 
 | ||||||
|  |                     {busy && | ||||||
|  |                         <div className="w-full flex gap-2 items-center justify-center"> | ||||||
|  |                             <span className="loading loading-spinner loading-lg"></span> | ||||||
|  |                             {t("image.Uploading")} | ||||||
|  |                         </div> | ||||||
|  |                     } | ||||||
|                 </form> |                 </form> | ||||||
|  |                 <figure className="border-2 bg-base-300 border-primary rounded-lg overflow-hidden order-1 md:order-3"> | ||||||
|  |                     <img className="w-full object-center object-contain" src={file} | ||||||
|  |                          alt=""/> | ||||||
|  |                 </figure> | ||||||
|  |             </div> | ||||||
|         </Modal> |         </Modal> | ||||||
|     ) |     ) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -5,9 +5,10 @@ interface ModalProperties { | ||||||
|     onClose: () => void, |     onClose: () => void, | ||||||
|     className?: string, |     className?: string, | ||||||
|     children: React.ReactNode, |     children: React.ReactNode, | ||||||
|  |     t: any, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const Modal = function({open, onClose, children}: ModalProperties) { | const Modal = function({open, onClose, children, t}: ModalProperties) { | ||||||
|     const ref = useRef<HTMLDialogElement>(null); |     const ref = useRef<HTMLDialogElement>(null); | ||||||
| 
 | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|  | @ -16,12 +17,15 @@ const Modal = function({open, onClose, children}: ModalProperties) { | ||||||
|     }, [open]) |     }, [open]) | ||||||
| 
 | 
 | ||||||
|     return ( |     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"> |         <dialog ref={ref} onCancel={onClose} id="test" | ||||||
|  |                 className="p-4 rounded-lg bg-base-200 border border-base-300 shadow z-[100] backdrop:bg-base-100 backdrop:bg-opacity-50"> | ||||||
|  |             <div className="flex flex-col gap-2 sm:min-w-80"> | ||||||
|                 {children} |                 {children} | ||||||
| 
 | 
 | ||||||
|             <button type="button" className="btn btn-primary w-full sm:btn-wide" onClick={onClose}> |                 <button type="button" className="btn btn-error self-end" onClick={onClose}> | ||||||
|                 Cancel |                     {t("dialog.Cancel")} | ||||||
|                 </button> |                 </button> | ||||||
|  |             </div> | ||||||
|         </dialog> |         </dialog> | ||||||
|     ) |     ) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -96,6 +96,20 @@ if (domNode) { | ||||||
|                         }, |                         }, | ||||||
|                         editor: { |                         editor: { | ||||||
|                             unsaved_changes_notice: "You have unsaved changes, save now so you don't loose them!", |                             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: { |                         editor: { | ||||||
|                             unsaved_changes_notice: "Sie haben ungesicherte Änderungen, speichern Sie jetzt um diese nicht zu verlieren!", |                             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", | ||||||
|  |                             } | ||||||
|                         } |                         } | ||||||
|                     } |                     } | ||||||
|                 } |                 } | ||||||
|  |  | ||||||
|  | @ -26,7 +26,7 @@ public class ImageController(ImageService imageService) : ControllerBase { | ||||||
| 	[Consumes("multipart/form-data")] | 	[Consumes("multipart/form-data")] | ||||||
| 	public async Task<IActionResult> CreateImageAsync( | 	public async Task<IActionResult> CreateImageAsync( | ||||||
| 			[FromForm] IFormFile file,  | 			[FromForm] IFormFile file,  | ||||||
| 			ImageService.ImageQuality quality = ImageService.ImageQuality.Normal) { | 			[FromForm] ImageService.ImageQuality quality = ImageService.ImageQuality.Normal) { | ||||||
| 		try { | 		try { | ||||||
| 			string tempFile = Path.GetTempFileName(); | 			string tempFile = Path.GetTempFileName(); | ||||||
| 			{ | 			{ | ||||||
|  | @ -34,7 +34,7 @@ public class ImageController(ImageService imageService) : ControllerBase { | ||||||
| 				await file.CopyToAsync(stream); | 				await file.CopyToAsync(stream); | ||||||
| 				stream.Close(); | 				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."); | 			if (id is null) throw new ApplicationException("Saving image failed unexpectedly."); | ||||||
| 			return Created($"/images/{id}", new CreateResponse(id.Value)); | 			return Created($"/images/{id}", new CreateResponse(id.Value)); | ||||||
| 		} catch (Exception ex) { | 		} catch (Exception ex) { | ||||||
|  |  | ||||||
|  | @ -9,11 +9,15 @@ public enum ImageQuality { | ||||||
| 
 | 
 | ||||||
| 	private ILogger<ImageService> Logger { get; } = logger; | 	private ILogger<ImageService> Logger { get; } = logger; | ||||||
| 	private const string BasePath = "./files/images"; | 	private const string BasePath = "./files/images"; | ||||||
| 	private const string ImageExtension = ".jpg"; | 	private const string ImageExtension = ".webp"; | ||||||
| 	public string ImageMimeType => "image/jpg"; | 	public string ImageMimeType => "image/webp"; | ||||||
| 
 | 
 | ||||||
| 	public string? GetPath(Guid imageId) { | 	public string? GetPath(Guid imageId) { | ||||||
| 		string path = Path.Combine(BasePath, imageId + ImageExtension); | 		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; | 		return File.Exists(path) ? path : null; | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -27,7 +31,7 @@ public enum ImageQuality { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	public async ValueTask<Guid?> StoreImageAsync(string temporaryPath, int size = 800, bool enforceSize = false, | 	public async ValueTask<Guid?> 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; | 		if (File.Exists(temporaryPath) is not true) return null; | ||||||
| 
 | 
 | ||||||
| 		try { | 		try { | ||||||
|  | @ -36,11 +40,23 @@ public enum ImageQuality { | ||||||
| 			var image = new MagickImage(); | 			var image = new MagickImage(); | ||||||
| 			await image.ReadAsync(temporaryPath, cancellation); | 			await image.ReadAsync(temporaryPath, cancellation); | ||||||
| 			 | 			 | ||||||
| 			// Jpeg with 90% compression should look decent | 			image.Format = MagickFormat.WebP; | ||||||
| 			image.Resize(new MagickGeometry(size)); // this preserves aspect ratio | 			if (quality is ImageQuality.Source) { | ||||||
| 			if (enforceSize) image.Extent(new MagickGeometry(size), Gravity.Center, MagickColors.Black); | 				image.Quality = 100; | ||||||
| 			image.Format = MagickFormat.Jpeg; | 				// Do not resize | ||||||
| 			image.Quality = 90; | 			} else { | ||||||
|  | 				int storedSize = size; | ||||||
|  | 				if (quality is ImageQuality.Normal && storedSize < 800) storedSize = 800; | ||||||
|  | 				if (quality is ImageQuality.High && storedSize < 1600) storedSize = 1600; | ||||||
|  | 
 | ||||||
|  | 				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); | 			if (image.GetExifProfile() is { } exifProfile) image.RemoveProfile(exifProfile); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue