Compare commits

..

10 commits

27 changed files with 844 additions and 130 deletions

View file

@ -1,11 +1,15 @@
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 { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models"; import { CategoryColor, Category, ArticleStatus, ArticleView, ArticleDto } from "../model/Models";
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
// @ts-ignore
import markdownit from "markdown-it"; import markdownit from "markdown-it";
// @ts-ignore
import markdownitmark from "markdown-it-mark"; import markdownitmark from "markdown-it-mark";
import "groupby-polyfill/lib/polyfill.js"; import "groupby-polyfill/lib/polyfill.js";
import TextareaMarkdownEditor from 'react-textarea-markdown-editor';
const nameof = function<T>(name: keyof T) { return name; } const nameof = function<T>(name: keyof T) { return name; }
@ -62,6 +66,7 @@ export default function Editor() {
const [isPublished, setIsPublished] = useState(false); const [isPublished, setIsPublished] = useState(false);
const [article, setArticle] = useState<ArticleView|null>(null); const [article, setArticle] = useState<ArticleView|null>(null);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [imageDialog, setImageDialog] = useState(false);
const [model, setModel] = useState<ArticleDto>({ const [model, setModel] = useState<ArticleDto>({
body: "", body: "",
categories: [], categories: [],
@ -155,6 +160,7 @@ export default function Editor() {
categories: result.categories.map(c => c.id), categories: result.categories.map(c => c.id),
})); }));
setArticle(result); setArticle(result);
// setMarkdown(result.body)
console.log("Article loaded"); console.log("Article loaded");
}) })
.catch(error => { .catch(error => {
@ -165,16 +171,27 @@ export default function Editor() {
} }
}, ([setArticle, setNotice, console, location]) as any[]); }, ([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 ( return (
<> <>
{ {
dirty && dirty &&
<div role="alert" className="alert alert-warning sticky left-4 right-4 top-4 mb-4 z-50 rounded-sm"> <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" /> <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> </svg>
<p>{t("editor.unsaved_changes_notice")}</p> <p className="line-clamp-1">{t("editor.unsaved_changes_notice")}</p>
</div> </div>
} }
{ {
@ -198,6 +215,11 @@ 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} t={t} onClose={() => setImageDialog(false)} callback={(location, description) => {
textAreaMarkdown.current?.append(`\n![${description}](${location})\n`)
setImageDialog(false)
}} />
<form method="post" onSubmit={onSubmit}> <form method="post" onSubmit={onSubmit}>
<fieldset className="grid grid-cols-1 lg:grid-cols-2 gap-x-8"> <fieldset className="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
<LabelInput label={t("Title_Label")}> <LabelInput label={t("Title_Label")}>
@ -213,6 +235,7 @@ export default function Editor() {
onChange={onChangeModel} name={nameof<ArticleDto>("categories")} onChange={onChangeModel} name={nameof<ArticleDto>("categories")}
defaultValue={article.categories.map(c => c.id)}> defaultValue={article.categories.map(c => c.id)}>
{ {
// @ts-ignore
Array.from(Map.groupBy(categories, (c: Category) => c.color) as Map<CategoryColor, Category[]>) Array.from(Map.groupBy(categories, (c: Category) => c.color) as Map<CategoryColor, Category[]>)
.map((value, _) => .map((value, _) =>
<optgroup className="font-bold not-italic my-3" <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"> <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="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"> role="toolbar">
<div className="join join-horizontal"> <div className="join join-horizontal">
<ToolBarButton title={t("Tools.H1_Tooltip")} <ToolBarButton title={t("Tools.H1_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "# ", true)}> onClick={() => textAreaMarkdown.current?.markLine("# ")}>
<strong>{t("Tools.H1_Label")}</strong> <strong>{t("Tools.H1_Label")}</strong>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.H2_Tooltip")} <ToolBarButton title={t("Tools.H2_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "## ", true)}> onClick={() => textAreaMarkdown.current?.markLine("## ")}>
<strong>{t("Tools.H2_Label")}</strong> <strong>{t("Tools.H2_Label")}</strong>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.H3_Tooltip")} <ToolBarButton title={t("Tools.H3_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "### ", true)}> onClick={() => textAreaMarkdown.current?.markLine("### ")}>
<strong>{t("Tools.H3_Label")}</strong> <strong>{t("Tools.H3_Label")}</strong>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.H4_Tooltip")} <ToolBarButton title={t("Tools.H4_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "#### ", true)}> onClick={() => textAreaMarkdown.current?.markLine("#### ")}>
<strong>{t("Tools.H4_Label")}</strong> <strong>{t("Tools.H4_Label")}</strong>
</ToolBarButton> </ToolBarButton>
</div> </div>
<div className="join join-horizontal"> <div className="join join-horizontal">
<ToolBarButton title={t("Tools.Bold_Tooltip")} <ToolBarButton title={t("Tools.Bold_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "**")}> onClick={() => textAreaMarkdown.current?.mark('**', '**',t("Tools.Bold_Tooltip"))}>
<strong>B</strong> <strong>B</strong>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.Italic_Tooltip")} <ToolBarButton title={t("Tools.Italic_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "*")}> onClick={() => textAreaMarkdown.current?.mark('*', '*',t("Tools.Italic_Tooltip"))}>
<em>I</em> <em>I</em>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.Underline_Tooltip")} <ToolBarButton title={t("Tools.Underline_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "++")}> onClick={() => textAreaMarkdown.current?.mark('+', '+',t("Tools.Underline_Tooltip"))}>
<span className="underline">U</span> <span className="underline">U</span>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.StrikeThrough_Tooltip")} <ToolBarButton title={t("Tools.StrikeThrough_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "~~")}> onClick={() => textAreaMarkdown.current?.mark('~~', '~~',t("Tools.StrikeThrough_Tooltip"))}>
<del>{t("Tools.StrikeThrough_Label")}</del> <del>{t("Tools.StrikeThrough_Label")}</del>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.Mark_Tooltip")} <ToolBarButton title={t("Tools.Mark_Tooltip")}
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "==")}> onClick={() => textAreaMarkdown.current?.mark('==', '==',t("Tools.Mark_Tooltip"))}>
<mark>{t("Tools.Mark_Label")}</mark> <mark>{t("Tools.Mark_Label")}</mark>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.Mark_Tooltip")} <ToolBarButton title={t("Tools.Mark_Tooltip")}
onClick={() => insertBeforeSelection(markdownArea.current, "> ", true)}> onClick={() => textAreaMarkdown.current?.markLine("> ")}>
| <em>{t("Tools.Cite_Label")}</em> | <em>{t("Tools.Cite_Label")}</em>
</ToolBarButton> </ToolBarButton>
</div> </div>
<div className="join join-horizontal"> <div className="join join-horizontal">
<ToolBarButton <ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "1. ", true)}> onClick={() => textAreaMarkdown.current?.markLine("1. ")}>
1. 1.
</ToolBarButton> </ToolBarButton>
<ToolBarButton <ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "a. ", true)}> onClick={() => textAreaMarkdown.current?.markLine("a. ")}>
a. a.
</ToolBarButton> </ToolBarButton>
<ToolBarButton <ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "A. ", true)}> onClick={() => textAreaMarkdown.current?.markLine("A. ")}>
A. A.
</ToolBarButton> </ToolBarButton>
<ToolBarButton <ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "i. ", true)}> onClick={() => textAreaMarkdown.current?.markLine("i. ")}>
i. i.
</ToolBarButton> </ToolBarButton>
<ToolBarButton <ToolBarButton
onClick={() => insertBeforeSelection(markdownArea.current, "I. ", true)}> onClick={() => textAreaMarkdown.current?.markLine("I. ")}>
I. I.
</ToolBarButton> </ToolBarButton>
</div> </div>
<div className="join join-horizontal"> <div className="join join-horizontal">
<ToolBarButton title={t("Tools.CodeLine_Tooltip")} <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" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className="w-4 h-4"> className="w-4 h-4">
@ -322,7 +345,7 @@ export default function Editor() {
</svg> </svg>
</ToolBarButton> </ToolBarButton>
<ToolBarButton title={t("Tools.CodeBlock_Tooltip")} <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" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className="w-4 h-4"> className="w-4 h-4">
@ -332,12 +355,25 @@ export default function Editor() {
</svg> </svg>
</ToolBarButton> </ToolBarButton>
</div> </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> </div>
<textarea ref={markdownArea} id="tool-target" <TextareaMarkdownEditor ref={textAreaMarkdown} markers={[]}
className="resize-none textarea textarea-bordered outline-none w-full flex-1 join-item" textareaId="tool-target"
required aria-required placeholder={t("Body_Placeholder")} value={model.body} onChange={markdownOnChange}
autoComplete="off" 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"
name={nameof<ArticleDto>("body")} value={model.body} onChange={onChangeModel}/> placeholder={t("Body_Placeholder")}
doParse={md.render}/>
</div> </div>
<div className="bg-base-200 p-2"> <div className="bg-base-200 p-2">
<h2 className="text-2xl lg:text-4xl font-bold mb-6 hyphens-auto"> <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}) { 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} title={title}
onClick={onClick}> onClick={onClick}>
{children ?? "err"} {children ?? "err"}

View file

@ -0,0 +1,95 @@
import React, {ChangeEvent, useEffect, useState} from "react";
import Modal from "./Modal";
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;
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);
}
}
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 (
<Modal open={open} onClose={onClose} t={t}>
<div className="grid grid-rows-1 md:grid-rows-1 md:grid-cols-2 gap-2 max-w-2xl">
<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}/>
<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>
<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>
)
}
export default ImageEditor;

View file

@ -0,0 +1,33 @@
import React, {useEffect, useRef} from "react";
interface ModalProperties {
open: boolean,
onClose: () => void,
className?: string,
children: React.ReactNode,
t: any,
}
const Modal = function({open, onClose, children, t}: 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} 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}
<button type="button" className="btn btn-error self-end" onClick={onClose}>
{t("dialog.Cancel")}
</button>
</div>
</dialog>
)
}
export default Modal;

View file

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

View file

@ -1,7 +1,15 @@
@using Wave.Data @using Wave.Data
@if (Articles.Count < 1) { @if (Articles.Count < 1) {
<p>No Articles</p> <div class="flex space-x-3 bg-base-200 text-base-content p-2 rounded">
<div class="skeleton h-32 w-32 max-lg:hidden"></div>
<div class="flex flex-col space-y-1 p-2">
<h2 class="card-title line-clamp-1">No Articles</h2>
<div class="skeleton h-4 w-28"></div>
<div class="skeleton h-4 w-28"></div>
<div class="skeleton h-4 w-20"></div>
</div>
</div>
} else { } else {
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@foreach (var article in Articles.OrderByDescending(a => a.PublishDate)) { @foreach (var article in Articles.OrderByDescending(a => a.PublishDate)) {

View file

@ -47,37 +47,52 @@
</header> </header>
</SectionContent> </SectionContent>
@if (Article.Headings.Count > 0) { @if (Article.Headings.Count > 1) {
<section class="mb-3 p-2 bg-base-200 rounded-box w-80 float-start mr-2 mb-2" data-nosnippet> <section class="p-2 bg-base-200 max-w-[28rem] rounded-box" data-nosnippet>
<h2 class="text-xl font-bold mb-3">@Localizer["TableOfContents"]</h2> <details class="group" open>
<ul class="menu p-0 [&_li>*]:rounded-none"> <summary class="list-none">
@{ <div class="flex gap-2 mb-2">
int level = 1; <div class="cursor-pointer grid place-content-center">
foreach (var heading in Article.Headings.OrderBy(h => h.Order)) { <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 block group-open:hidden">
int headingLevel = heading.Order % 10; <path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 hidden group-open:block">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</div>
<h2 class="cursor-pointer text-xl font-bold select-none">@Localizer["TableOfContents"]</h2>
</div>
</summary>
<ul class="menu p-0 pl-4 [&_li>*]:rounded-none">
@{
int level = 1;
foreach (var heading in Article.Headings.OrderBy(h => h.Order)) {
int headingLevel = heading.Order % 10;
while (headingLevel < level) { while (headingLevel < level) {
level--; level--;
@(new MarkupString("</ul></li>")) @(new MarkupString("</ul></li>"))
}
while (headingLevel > level) {
level++;
@(new MarkupString("<li><ul>"))
}
<li>
<a href="/@Navigation.ToBaseRelativePath(Navigation.Uri)#@heading.Anchor">@((MarkupString)heading.Label)</a>
</li>
} }
while (headingLevel > level) { while (level > 1) {
level++; level--;
@(new MarkupString("<li><ul>")) @(new MarkupString("<li><ul>"))
} }
<li>
<a href="/@Navigation.ToBaseRelativePath(Navigation.Uri)#@heading.Anchor">@((MarkupString)heading.Label)</a>
</li>
} }
</ul>
while (level > 1) { </details>
level--;
@(new MarkupString("<li><ul>"))
}
}
</ul>
</section> </section>
<hr class="my-6" />
} }
<article class="mb-6"> <article class="mb-6">

View file

@ -45,6 +45,7 @@
<li><NavLink href="manage/api">@Localizer["ManageApi_Label"]</NavLink></li> <li><NavLink href="manage/api">@Localizer["ManageApi_Label"]</NavLink></li>
<li><NavLink href="newsletter">@Localizer["Newsletter_Label"]</NavLink></li> <li><NavLink href="newsletter">@Localizer["Newsletter_Label"]</NavLink></li>
<li><NavLink href="subscribers">@Localizer["Subscribers_Label"]</NavLink></li> <li><NavLink href="subscribers">@Localizer["Subscribers_Label"]</NavLink></li>
<li><NavLink href="settings">@Localizer["Settings_Label"]</NavLink></li>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
</ul> </ul>

View file

@ -0,0 +1,78 @@
@using System.Net
@using Microsoft.Extensions.Options
@using Wave.Data
@using Wave.Utilities
@inject IOptions<Customization> Customizations
@inject IOptions<Features> Features
@inject NavigationManager Navigation
@if (Subject is null) {
<meta property="og:type" content="website">
<meta property="og:title" content="@Customizations.Value.AppName">
<meta property="og:site_name" content="@Customizations.Value.AppName">
<meta property="og:url" content="@Navigation.BaseUri">
@if (!string.IsNullOrWhiteSpace(Customizations.Value.LogoLink)) {
<meta property="og:image" content="@Customizations.Value.LogoLink">
} else {
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/img/logo.png")">
}
@if (!string.IsNullOrWhiteSpace(Customizations.Value.AppDescription)) {
<meta name="description" content="@Customizations.Value.AppDescription">
<meta property="og:description" content="@Customizations.Value.AppDescription">
}
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName" href="@Navigation.ToAbsoluteUri("/rss/rss.xml")">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName" href="@Navigation.ToAbsoluteUri("/rss/atom.xml")">
}
} else if (Subject is Article article) {
<meta property="og:title" content="@article.Title">
<meta property="og:description" content="@string.Format(Localizer!["Meta_Description"], Customizations.Value.AppName, article.BodyPlain[..Math.Min(80, article.BodyPlain.Length)] + "... ")">
<meta property="og:url" content="@Navigation.ToAbsoluteUri("/article/" + article.Id)">
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/api/user/pfp/" + article.Author.Id)">
<meta property="og:type" content="article">
<meta property="og:article:author" content="@article.Author.Name">
<meta property="og:article:published_time" content="@article.PublishDate.ToString("u")">
@if (article.LastModified.HasValue) {
<meta property="og:article:modified_time" content="@article.LastModified.Value.ToString("u")">
}
<meta property="og:site_name" content="@Customizations.Value.AppName">
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName" href="@Navigation.ToAbsoluteUri("/rss/rss.xml")">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName" href="@Navigation.ToAbsoluteUri("/rss/atom.xml")">
}
<link rel="canonical" href="@ArticleUtilities.GenerateArticleLink(article, new Uri(Navigation.BaseUri))" />
} else if (Subject is ApplicationUser user) {
<meta property="og:title" content="@string.Format(Localizer!["Meta_OpenGraph_Title"], user.Name, Customizations.Value.AppName)">
<meta property="og:description" content="@string.Format(Localizer["Meta_Description"], user.Name, Customizations.Value.AppName)">
<meta property="og:type" content="profile">
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/api/user/pfp/" + user.Id)">
<meta property="og:site_name" content="@Customizations.Value.AppName">
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName | User @user.FullName" href="@Navigation.ToAbsoluteUri("/rss/rss.xml?author=@user.Id")">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName | User @user.FullName" href="@Navigation.ToAbsoluteUri("/rss/atom.xml?author=@user.Id")">
}
} else if (Subject is Category category) {
<meta property="og:title" content="@Localizer!["Title"] - @category.Name">
<meta property="og:description" content="Articles in the @category.Name Category on @Customizations.Value.AppName">
<meta property="og:type" content="website" />
@if (!string.IsNullOrWhiteSpace(Customizations.Value.LogoLink)) {
<meta property="og:image" content="@Customizations.Value.LogoLink">
} else {
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/img/logo.png")">
}
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName | @category.Name" href="@Navigation.ToAbsoluteUri("/rss/rss.xml?category=" + WebUtility.UrlEncode(category.Name))">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName | @category.Name" href="@Navigation.ToAbsoluteUri("/rss/atom.xml?category=" + WebUtility.UrlEncode(category.Name))">
}
}
@code {
[Parameter]
public object? Subject { get; set; }
[Parameter]
public IStringLocalizer? Localizer { get; set; }
}

View file

@ -1,7 +1,6 @@
@page "/article/new" @page "/article/new"
@page "/article/{id:guid}/edit" @page "/article/{id:guid}/edit"
@using System.ComponentModel.DataAnnotations
@using Vite.AspNetCore @using Vite.AspNetCore
@using Wave.Data @using Wave.Data
@using Wave.Utilities @using Wave.Utilities
@ -22,8 +21,10 @@
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1> <h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
<div id="editor"> <div id="editor">
<div class="flex place-content-center"> <div class="flex place-content-center">
<p>Loading Interactive Editor </p> <div class="flex flex-col gap-2 items-center">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
<p>Loading Interactive Editor </p>
</div>
</div> </div>
</div> </div>

View file

@ -28,26 +28,7 @@
<meta name="author" content="@Article.Author.Name"> <meta name="author" content="@Article.Author.Name">
<meta name="description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.BodyPlain[..Math.Min(80, Article.BodyPlain.Length)] + "... ")"> <meta name="description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.BodyPlain[..Math.Min(80, Article.BodyPlain.Length)] + "... ")">
<!-- Open Graph --> <OpenGraph Subject="Article" Localizer="Localizer" />
<meta property="og:title" content="@Article.Title">
<meta property="og:description" content="@string.Format(Localizer["Meta_Description"], Customizations.Value.AppName, Article.BodyPlain[..Math.Min(80, Article.BodyPlain.Length)] + "... ")">
<meta property="og:url" content="@Navigation.ToAbsoluteUri("/article/" + Article.Id)">
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/api/user/pfp/" + Article.Author.Id)">
<meta property="og:type" content="article">
<meta property="og:article:author" content="@Article.Author.Name">
<meta property="og:article:published_time" content="@Article.PublishDate.ToString("u")">
@if (Article.LastModified.HasValue) {
<meta property="og:article:modified_time" content="@Article.LastModified.Value.ToString("u")">
}
<meta property="og:site_name" content="@Customizations.Value.AppName">
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName" href="/rss/rss.xml">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName" href="/rss/atom.xml">
}
@if (Article is not null) {
<link rel="canonical" href="@ArticleUtilities.GenerateArticleLink(Article, new Uri(Navigation.BaseUri))" />
}
</HeadContent> </HeadContent>
} }

View file

@ -2,19 +2,14 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Wave.Data @using Wave.Data
@using System.Net @using System.Net
@using Microsoft.Extensions.Options
@inject IDbContextFactory<ApplicationDbContext> ContextFactory @inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject IOptions<Customization> Customizations
@inject IOptions<Features> Features
@inject IStringLocalizer<CategoryView> Localizer @inject IStringLocalizer<CategoryView> Localizer
<HeadContent> <HeadContent>
@if (Features.Value.Rss && Category is not null) { @if (Category is not null) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName | @Category.Name" href="/rss/rss.xml?category=@WebUtility.UrlEncode(Category.Name)"> <OpenGraph Subject="Category" Localizer="Localizer" />
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName | @Category.Name" href="/rss/atom.xml?category=@WebUtility.UrlEncode(Category.Name)"> } else {
}
@if (Category is null) {
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
} }
</HeadContent> </HeadContent>

View file

@ -8,28 +8,12 @@
@inject IOptions<Customization> Customizations @inject IOptions<Customization> Customizations
@inject IOptions<Features> Features @inject IOptions<Features> Features
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject IDbContextFactory<ApplicationDbContext> ContextFactory; @inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject IStringLocalizer<Home> Localizer @inject IStringLocalizer<Home> Localizer
@inject IMessageDisplay Message @inject IMessageDisplay Message
<HeadContent> <HeadContent>
<meta property="og:type" content="website"> <OpenGraph />
<meta property="og:title" content="@Customizations.Value.AppName">
<meta property="og:site_name" content="@Customizations.Value.AppName">
<meta property="og:url" content="@Navigation.BaseUri">
@if (!string.IsNullOrWhiteSpace(Customizations.Value.LogoLink)) {
<meta property="og:image" content="@Customizations.Value.LogoLink">
} else {
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/img/logo.png")">
}
@if (!string.IsNullOrWhiteSpace(Customizations.Value.AppDescription)) {
<meta name="description" content="@Customizations.Value.AppDescription">
<meta property="og:description" content="@Customizations.Value.AppDescription">
}
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName" href="/rss/rss.xml">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName" href="/rss/atom.xml">
}
@if (Page >= TotalPages) { @if (Page >= TotalPages) {
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">
@ -40,7 +24,7 @@
} }
</HeadContent> </HeadContent>
<PageTitle>@(Customizations.Value.AppName ?? "Wave")</PageTitle> <PageTitle>@(Customizations.Value.AppName)</PageTitle>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 xl:grid-rows-4 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 xl:grid-rows-4 gap-4">
<div class="sm:col-span-2 flex flex-col"> <div class="sm:col-span-2 flex flex-col">

View file

@ -0,0 +1,94 @@
@page "/settings"
@using Microsoft.AspNetCore.Identity
@using Microsoft.Extensions.Options
@using Wave.Data
@using Wave.Services
@using Wave.Utilities
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject IStringLocalizer<Settings> Localizer
@inject IOptions<Features> Features
@inject IServiceProvider ServiceProvider
@inject UserManager<ApplicationUser> UserManager
@inject ILogger<Settings> Logger
@inject IMessageDisplay Message
@inject NavigationManager Navigation
<PageTitle>@(Localizer["Title"] + TitlePostfix)</PageTitle>
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["Title"]</h1>
<BoardComponent CenterContent="false">
<BoardCardComponent Heading="@Localizer["About_Title"]">
<p>@Localizer["Wave_Version_Label"] @Version</p>
</BoardCardComponent>
@if (Features.Value.EmailSubscriptions) {
<BoardCardComponent Heading="@Localizer["Email_Title"]">
<div class="form-control w-full">
<div>
<span class="label-text">Email</span>
</div>
<div class="join">
<InputText class="input input-bordered input-sm join-item flex-1"
type="email" autofill="off"
@bind-Value="@Email" DisplayName="Email"/>
<InputSelect @bind-Value="@EmailType" class="select select-bordered select-sm">
<option value="default" selected>Default</option>
<option value="welcome">Welcome</option>
<option value="newsletter">Newsletter</option>
</InputSelect>
<button class="btn btn-sm btn-info join-item @(EmailBusy ? "btn-disabled" : null)" @onclick="TestEmail">Test</button>
</div>
</div>
</BoardCardComponent>
}
</BoardComponent>
@code {
[CascadingParameter(Name = "TitlePostfix")]
private string TitlePostfix { get; set; } = default!;
[CascadingParameter(Name = "Version")]
private string Version { get; set; } = string.Empty;
[CascadingParameter]
private Task<AuthenticationState> AuthenticationState { get; set; } = default!;
private string Email { get; set; } = string.Empty;
private string EmailType { get; set; } = "default";
private bool EmailBusy = false;
private async Task TestEmail() {
if (EmailBusy) return;
try {
EmailBusy = true;
await using var client = ServiceProvider.GetRequiredService<IEmailService>();
await client.ConnectAsync(CancellationToken.None);
var factory = ServiceProvider.GetRequiredService<EmailFactory>();
const string title = "Test Email";
const string body = "Hello from Wave";
EmailSubscriber sub = new() {Email = Email, Language = "en-US"};
string author = await factory.CreateAuthorCard(
(await UserManager.GetUserAsync((await AuthenticationState).User))!,
new Uri(Navigation.BaseUri, UriKind.Absolute));
var email = EmailType switch {
"welcome" => await factory.CreateWelcomeEmail(sub, [], title, title, $"<p>{body}</p>", body),
"newsletter" => await factory.CreateSubscribedEmail(sub,
new Uri(Navigation.BaseUri, UriKind.Absolute).AbsoluteUri,
title, title, $"<p>{body}</p>" + author, body),
var _ => await factory.CreateDefaultEmail(sub.Email, sub.Name, title, title, $"<p>{body}</p>", body)
};
await client.SendEmailAsync(email, CancellationToken.None);
Message.ShowSuccess("Test Email send", "Test Email");
} catch (Exception ex) {
Message.ShowError("Failed to send email: " + ex.Message, "Test Email");
Logger.LogError(ex, "Failed to send email");
} finally {
EmailBusy = false;
}
}
}

View file

@ -4,10 +4,8 @@
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using Wave.Utilities @using Wave.Utilities
@inject NavigationManager Navigation
@inject IDbContextFactory<ApplicationDbContext> ContextFactory @inject IDbContextFactory<ApplicationDbContext> ContextFactory
@inject IOptions<Customization> Customizations @inject IOptions<Customization> Customizations
@inject IOptions<Features> Features
@inject IStringLocalizer<UserView> Localizer @inject IStringLocalizer<UserView> Localizer
@inject IMessageDisplay Message @inject IMessageDisplay Message
@ -16,17 +14,7 @@
<meta name="author" content="@User.Name"> <meta name="author" content="@User.Name">
<meta name="description" content="@string.Format(Localizer["Meta_Description"], User.Name, Customizations.Value.AppName)"> <meta name="description" content="@string.Format(Localizer["Meta_Description"], User.Name, Customizations.Value.AppName)">
<!-- Open Graph --> <OpenGraph Subject="User" Localizer="Localizer" />
<meta property="og:title" content="@string.Format(Localizer["Meta_OpenGraph_Title"], User.Name, Customizations.Value.AppName)">
<meta property="og:description" content="@string.Format(Localizer["Meta_Description"], User.Name, Customizations.Value.AppName)">
<meta property="og:type" content="profile">
<meta property="og:image" content="@Navigation.ToAbsoluteUri("/api/user/pfp/" + User.Id)">
<meta property="og:site_name" content="@Customizations.Value.AppName">
@if (Features.Value.Rss) {
<link rel="alternate" type="application/rss+xml" title="RSS Feed on @Customizations.Value.AppName | User @User.FullName" href="/rss/rss.xml?author=@User.Id">
<link rel="alternate" type="application/atom+xml" title="Atom RSS Feed on @Customizations.Value.AppName | User @User.FullName" href="/rss/atom.xml?author=@User.Id">
}
} }
@if (User is null) { @if (User is null) {
<meta name="robots" content="noindex"> <meta name="robots" content="noindex">

View file

@ -9,8 +9,10 @@
@if (message.Title is null) { @if (message.Title is null) {
@message.Body @message.Body
} else { } else {
<span class="font-bold">@message.Title</span> <div class="flex flex-col gap-2">
<span><small>@message.Body</small></span> <span class="font-bold">@message.Title</span>
<span><small>@message.Body</small></span>
</div>
} }
</div> </div>
} }

View file

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Wave.Services; using Wave.Services;
namespace Wave.Controllers; 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); if (size < 800) return File(await ImageService.GetResized(path, size), ImageService.ImageMimeType);
return File(System.IO.File.OpenRead(path), 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,
[FromForm] 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, quality:quality);
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

@ -21,7 +21,7 @@ FROM node:20-alpine AS vite-build
WORKDIR /src WORKDIR /src
RUN mkdir -p "wwwroot" RUN mkdir -p "wwwroot"
COPY ["Wave/package.json", "Wave/package-lock.json", "./"] COPY ["Wave/package.json", "Wave/package-lock.json", "./"]
RUN npm install RUN npm install --legacy-peer-deps
COPY [ \ COPY [ \
"Wave/tsconfig.json", \ "Wave/tsconfig.json", \
"Wave/tsconfig.node.json", \ "Wave/tsconfig.node.json", \

View file

@ -137,4 +137,7 @@
<data name="Subscribers_Label" xml:space="preserve"> <data name="Subscribers_Label" xml:space="preserve">
<value>Abonnenten</value> <value>Abonnenten</value>
</data> </data>
<data name="Settings_Label" xml:space="preserve">
<value>Einstellungen</value>
</data>
</root> </root>

View file

@ -140,4 +140,7 @@
<data name="Subscribers_Label" xml:space="preserve"> <data name="Subscribers_Label" xml:space="preserve">
<value>Subscribers</value> <value>Subscribers</value>
</data> </data>
<data name="Settings_Label" xml:space="preserve">
<value>Settings</value>
</data>
</root> </root>

View file

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Title" xml:space="preserve">
<value>Einstellungen</value>
</data>
<data name="About_Title" xml:space="preserve">
<value>Über Wave</value>
</data>
</root>

View file

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View file

@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 1.3
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">1.3</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1">this is my long string</data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
[base64 mime encoded serialized .NET Framework object]
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
[base64 mime encoded string representing a byte array form of the .NET Framework object]
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="Title" xml:space="preserve">
<value>Settings</value>
</data>
<data name="About_Title" xml:space="preserve">
<value>About Wave</value>
</data>
<data name="Wave_Version_Label" xml:space="preserve">
<value>Wave Version</value>
</data>
</root>

View file

@ -3,13 +3,21 @@
namespace Wave.Services; namespace Wave.Services;
public class ImageService(ILogger<ImageService> logger) { public class ImageService(ILogger<ImageService> logger) {
public enum ImageQuality {
Normal, High, Source
}
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;
} }
@ -23,7 +31,7 @@ public class ImageService(ILogger<ImageService> logger) {
} }
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 {
@ -31,12 +39,24 @@ public class ImageService(ILogger<ImageService> logger) {
var image = new MagickImage(); var image = new MagickImage();
await image.ReadAsync(temporaryPath, cancellation); 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(storedSize)); // this preserves aspect ratio
image.Resize(new MagickGeometry(size)); // this preserves aspect ratio if (enforceSize) image.Extent(new MagickGeometry(storedSize), Gravity.Center, MagickColors.Black);
if (enforceSize) image.Extent(new MagickGeometry(size), Gravity.Center, MagickColors.Black); image.Quality = quality switch {
image.Format = MagickFormat.Jpeg; ImageQuality.Normal => 85,
image.Quality = 90; 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);
@ -52,7 +72,7 @@ public class ImageService(ILogger<ImageService> logger) {
await image.WriteAsync(path, cancellation); await image.WriteAsync(path, cancellation);
return guid; return guid;
} catch (Exception ex) { } catch (Exception ex) {
Logger.LogInformation(ex, "Failed to process uploaded image."); Logger.LogWarning(ex, "Failed to process uploaded image.");
return null; return null;
} }
} }

View file

@ -66,7 +66,7 @@ public class NewsletterBackgroundService(ILogger<NewsletterBackgroundService> lo
newsletter.Article.BodyHtml + aboutTheAuthor, newsletter.Article.BodyHtml + aboutTheAuthor,
newsletter.Article.BodyPlain, newsletter.Article.BodyPlain,
"newsletter-" + newsletter.Id, replyTo); "newsletter-" + newsletter.Id, replyTo);
await client.SendEmailAsync(email); await client.SendEmailAsync(email, cancellationToken);
} }
} }
} }

BIN
Wave/package-lock.json generated

Binary file not shown.

View file

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