Compare commits
No commits in common. "2a9513b21bdaa71c1bc841f2693c68b280968f0e" and "69a5d51214d746a1824ea582f9dd4862097292a3" have entirely different histories.
2a9513b21b
...
69a5d51214
|
@ -1,15 +1,11 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { updateCharactersLeft } from "../utilities/md_functions";
|
||||
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; }
|
||||
|
||||
|
@ -66,7 +62,6 @@ 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: [],
|
||||
|
@ -160,7 +155,6 @@ export default function Editor() {
|
|||
categories: result.categories.map(c => c.id),
|
||||
}));
|
||||
setArticle(result);
|
||||
// setMarkdown(result.body)
|
||||
console.log("Article loaded");
|
||||
})
|
||||
.catch(error => {
|
||||
|
@ -171,27 +165,16 @@ export default function Editor() {
|
|||
}
|
||||
}, ([setArticle, setNotice, console, location]) as any[]);
|
||||
|
||||
const textAreaMarkdown = useRef<TextareaMarkdownEditor>(null);
|
||||
const markdownOnChange = function(v: string) {
|
||||
onChangeModel({
|
||||
target: {
|
||||
// @ts-ignore
|
||||
value: v,
|
||||
name: nameof<ArticleDto>("body")
|
||||
}
|
||||
});
|
||||
return {};
|
||||
}
|
||||
// @ts-ignore
|
||||
const markdownArea = useRef<HTMLTextAreaElement>(null);
|
||||
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 hidden md:inline">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="size-6">
|
||||
<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 className="line-clamp-1">{t("editor.unsaved_changes_notice")}</p>
|
||||
<p>{t("editor.unsaved_changes_notice")}</p>
|
||||
</div>
|
||||
}
|
||||
{
|
||||
|
@ -215,11 +198,6 @@ export default function Editor() {
|
|||
<li className={`step w-24 ${article.status === ArticleStatus.Published ? "step-primary" : ""}`}>{t("Published")}</li>
|
||||
</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}>
|
||||
<fieldset className="grid grid-cols-1 lg:grid-cols-2 gap-x-8">
|
||||
<LabelInput label={t("Title_Label")}>
|
||||
|
@ -235,7 +213,6 @@ 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"
|
||||
|
@ -265,77 +242,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-20 rounded-b-none rounded-t-sm"
|
||||
<div className="flex flex-wrap gap-1 p-2 z-50 bg-base-200 sticky top-0"
|
||||
role="toolbar">
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton title={t("Tools.H1_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.markLine("# ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "# ", true)}>
|
||||
<strong>{t("Tools.H1_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.H2_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.markLine("## ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "## ", true)}>
|
||||
<strong>{t("Tools.H2_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.H3_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.markLine("### ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "### ", true)}>
|
||||
<strong>{t("Tools.H3_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.H4_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.markLine("#### ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "#### ", true)}>
|
||||
<strong>{t("Tools.H4_Label")}</strong>
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton title={t("Tools.Bold_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('**', '**',t("Tools.Bold_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "**")}>
|
||||
<strong>B</strong>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Italic_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('*', '*',t("Tools.Italic_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "*")}>
|
||||
<em>I</em>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Underline_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('+', '+',t("Tools.Underline_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "++")}>
|
||||
<span className="underline">U</span>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.StrikeThrough_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('~~', '~~',t("Tools.StrikeThrough_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "~~")}>
|
||||
<del>{t("Tools.StrikeThrough_Label")}</del>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Mark_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('==', '==',t("Tools.Mark_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "==")}>
|
||||
<mark>{t("Tools.Mark_Label")}</mark>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.Mark_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.markLine("> ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "> ", true)}>
|
||||
| <em>{t("Tools.Cite_Label")}</em>
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton
|
||||
onClick={() => textAreaMarkdown.current?.markLine("1. ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "1. ", true)}>
|
||||
1.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => textAreaMarkdown.current?.markLine("a. ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "a. ", true)}>
|
||||
a.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => textAreaMarkdown.current?.markLine("A. ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "A. ", true)}>
|
||||
A.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => textAreaMarkdown.current?.markLine("i. ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "i. ", true)}>
|
||||
i.
|
||||
</ToolBarButton>
|
||||
<ToolBarButton
|
||||
onClick={() => textAreaMarkdown.current?.markLine("I. ")}>
|
||||
onClick={() => insertBeforeSelection(markdownArea.current, "I. ", true)}>
|
||||
I.
|
||||
</ToolBarButton>
|
||||
</div>
|
||||
<div className="join join-horizontal">
|
||||
<ToolBarButton title={t("Tools.CodeLine_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('`', '`',t("Tools.CodeLine_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "`")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4">
|
||||
|
@ -345,7 +322,7 @@ export default function Editor() {
|
|||
</svg>
|
||||
</ToolBarButton>
|
||||
<ToolBarButton title={t("Tools.CodeBlock_Tooltip")}
|
||||
onClick={() => textAreaMarkdown.current?.mark('```\n', '\n```\n',t("Tools.CodeBlock_Tooltip"))}>
|
||||
onClick={() => insertBeforeAndAfterSelection(markdownArea.current, "```")}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4">
|
||||
|
@ -355,25 +332,12 @@ 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>
|
||||
<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}/>
|
||||
<textarea ref={markdownArea} id="tool-target"
|
||||
className="resize-none textarea textarea-bordered outline-none w-full flex-1 join-item"
|
||||
required aria-required placeholder={t("Body_Placeholder")}
|
||||
autoComplete="off"
|
||||
name={nameof<ArticleDto>("body")} value={model.body} onChange={onChangeModel}/>
|
||||
</div>
|
||||
<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 text-sm justify-center items-center outline-none font-normal join-item"
|
||||
return <button type="button" className="btn btn-accent btn-sm outline-none font-normal join-item"
|
||||
title={title}
|
||||
onClick={onClick}>
|
||||
{children ?? "err"}
|
||||
|
|
|
@ -1,95 +0,0 @@
|
|||
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;
|
|
@ -1,33 +0,0 @@
|
|||
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;
|
|
@ -78,9 +78,6 @@ 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",
|
||||
|
@ -96,20 +93,6 @@ 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -151,9 +134,6 @@ if (domNode) {
|
|||
|
||||
CodeLine_Tooltip: "Selektierten text als programmcode markieren",
|
||||
CodeBlock_Tooltip: "Programmierblock einfügen",
|
||||
|
||||
ImageAdd_Label: "Bild",
|
||||
ImageAdd_Tooltip: "Bild Hochladen",
|
||||
},
|
||||
Category: {
|
||||
Primary: "Hauptkategorie",
|
||||
|
@ -169,20 +149,6 @@ 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
@using Wave.Data
|
||||
|
||||
@if (Articles.Count < 1) {
|
||||
<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>
|
||||
<p>No Articles</p>
|
||||
} else {
|
||||
<div class="flex flex-col gap-4">
|
||||
@foreach (var article in Articles.OrderByDescending(a => a.PublishDate)) {
|
||||
|
|
|
@ -47,23 +47,10 @@
|
|||
</header>
|
||||
</SectionContent>
|
||||
|
||||
@if (Article.Headings.Count > 1) {
|
||||
<section class="p-2 bg-base-200 max-w-[28rem] rounded-box" data-nosnippet>
|
||||
<details class="group" open>
|
||||
<summary class="list-none">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<div class="cursor-pointer grid place-content-center">
|
||||
<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">
|
||||
<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">
|
||||
@if (Article.Headings.Count > 0) {
|
||||
<section class="mb-3 p-2 bg-base-200 rounded-box w-80 float-start mr-2 mb-2" data-nosnippet>
|
||||
<h2 class="text-xl font-bold mb-3">@Localizer["TableOfContents"]</h2>
|
||||
<ul class="menu p-0 [&_li>*]:rounded-none">
|
||||
@{
|
||||
int level = 1;
|
||||
foreach (var heading in Article.Headings.OrderBy(h => h.Order)) {
|
||||
|
@ -90,9 +77,7 @@
|
|||
}
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
<hr class="my-6" />
|
||||
}
|
||||
|
||||
<article class="mb-6">
|
||||
|
|
|
@ -45,7 +45,6 @@
|
|||
<li><NavLink href="manage/api">@Localizer["ManageApi_Label"]</NavLink></li>
|
||||
<li><NavLink href="newsletter">@Localizer["Newsletter_Label"]</NavLink></li>
|
||||
<li><NavLink href="subscribers">@Localizer["Subscribers_Label"]</NavLink></li>
|
||||
<li><NavLink href="settings">@Localizer["Settings_Label"]</NavLink></li>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</ul>
|
||||
|
|
|
@ -1,78 +0,0 @@
|
|||
@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; }
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
@page "/article/new"
|
||||
@page "/article/{id:guid}/edit"
|
||||
|
||||
@using System.ComponentModel.DataAnnotations
|
||||
@using Vite.AspNetCore
|
||||
@using Wave.Data
|
||||
@using Wave.Utilities
|
||||
|
@ -21,10 +22,8 @@
|
|||
<h1 class="text-3xl lg:text-5xl font-light mb-6 text-primary">@Localizer["EditorTitle"]</h1>
|
||||
<div id="editor">
|
||||
<div class="flex place-content-center">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<p>Loading Interactive Editor </p>
|
||||
</div>
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -28,7 +28,26 @@
|
|||
<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)] + "... ")">
|
||||
|
||||
<OpenGraph Subject="Article" Localizer="Localizer" />
|
||||
<!-- Open Graph -->
|
||||
<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>
|
||||
}
|
||||
|
||||
|
|
|
@ -2,14 +2,19 @@
|
|||
@using Microsoft.EntityFrameworkCore
|
||||
@using Wave.Data
|
||||
@using System.Net
|
||||
@using Microsoft.Extensions.Options
|
||||
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||
@inject IOptions<Customization> Customizations
|
||||
@inject IOptions<Features> Features
|
||||
@inject IStringLocalizer<CategoryView> Localizer
|
||||
|
||||
<HeadContent>
|
||||
@if (Category is not null) {
|
||||
<OpenGraph Subject="Category" Localizer="Localizer" />
|
||||
} else {
|
||||
@if (Features.Value.Rss && 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)">
|
||||
<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)">
|
||||
}
|
||||
@if (Category is null) {
|
||||
<meta name="robots" content="noindex">
|
||||
}
|
||||
</HeadContent>
|
||||
|
|
|
@ -8,12 +8,28 @@
|
|||
@inject IOptions<Customization> Customizations
|
||||
@inject IOptions<Features> Features
|
||||
@inject NavigationManager Navigation
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory;
|
||||
@inject IStringLocalizer<Home> Localizer
|
||||
@inject IMessageDisplay Message
|
||||
|
||||
<HeadContent>
|
||||
<OpenGraph />
|
||||
<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="/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) {
|
||||
<meta name="robots" content="noindex">
|
||||
|
@ -24,7 +40,7 @@
|
|||
}
|
||||
</HeadContent>
|
||||
|
||||
<PageTitle>@(Customizations.Value.AppName)</PageTitle>
|
||||
<PageTitle>@(Customizations.Value.AppName ?? "Wave")</PageTitle>
|
||||
|
||||
<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">
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,8 +4,10 @@
|
|||
@using Microsoft.Extensions.Options
|
||||
@using Wave.Utilities
|
||||
|
||||
@inject NavigationManager Navigation
|
||||
@inject IDbContextFactory<ApplicationDbContext> ContextFactory
|
||||
@inject IOptions<Customization> Customizations
|
||||
@inject IOptions<Features> Features
|
||||
@inject IStringLocalizer<UserView> Localizer
|
||||
@inject IMessageDisplay Message
|
||||
|
||||
|
@ -14,7 +16,17 @@
|
|||
<meta name="author" content="@User.Name">
|
||||
<meta name="description" content="@string.Format(Localizer["Meta_Description"], User.Name, Customizations.Value.AppName)">
|
||||
|
||||
<OpenGraph Subject="User" Localizer="Localizer" />
|
||||
<!-- Open Graph -->
|
||||
<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) {
|
||||
<meta name="robots" content="noindex">
|
||||
|
|
|
@ -9,10 +9,8 @@
|
|||
@if (message.Title is null) {
|
||||
@message.Body
|
||||
} else {
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-bold">@message.Title</span>
|
||||
<span><small>@message.Body</small></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Wave.Services;
|
||||
|
||||
namespace Wave.Controllers;
|
||||
|
@ -20,27 +19,4 @@ 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,
|
||||
[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);
|
||||
}
|
|
@ -21,7 +21,7 @@ FROM node:20-alpine AS vite-build
|
|||
WORKDIR /src
|
||||
RUN mkdir -p "wwwroot"
|
||||
COPY ["Wave/package.json", "Wave/package-lock.json", "./"]
|
||||
RUN npm install --legacy-peer-deps
|
||||
RUN npm install
|
||||
COPY [ \
|
||||
"Wave/tsconfig.json", \
|
||||
"Wave/tsconfig.node.json", \
|
||||
|
|
|
@ -137,7 +137,4 @@
|
|||
<data name="Subscribers_Label" xml:space="preserve">
|
||||
<value>Abonnenten</value>
|
||||
</data>
|
||||
<data name="Settings_Label" xml:space="preserve">
|
||||
<value>Einstellungen</value>
|
||||
</data>
|
||||
</root>
|
|
@ -140,7 +140,4 @@
|
|||
<data name="Subscribers_Label" xml:space="preserve">
|
||||
<value>Subscribers</value>
|
||||
</data>
|
||||
<data name="Settings_Label" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
</data>
|
||||
</root>
|
|
@ -1,107 +0,0 @@
|
|||
<?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>
|
|
@ -1,101 +0,0 @@
|
|||
<?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>
|
|
@ -1,110 +0,0 @@
|
|||
<?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>
|
|
@ -3,21 +3,13 @@
|
|||
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 = ".webp";
|
||||
public string ImageMimeType => "image/webp";
|
||||
private const string ImageExtension = ".jpg";
|
||||
public string ImageMimeType => "image/jpg";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -31,7 +23,7 @@ public enum ImageQuality {
|
|||
}
|
||||
|
||||
public async ValueTask<Guid?> StoreImageAsync(string temporaryPath, int size = 800, bool enforceSize = false,
|
||||
CancellationToken cancellation = default, ImageQuality quality = ImageQuality.Normal) {
|
||||
CancellationToken cancellation = default) {
|
||||
if (File.Exists(temporaryPath) is not true) return null;
|
||||
|
||||
try {
|
||||
|
@ -40,23 +32,11 @@ 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;
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
// 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;
|
||||
|
||||
if (image.GetExifProfile() is { } exifProfile) image.RemoveProfile(exifProfile);
|
||||
|
||||
|
@ -72,7 +52,7 @@ public enum ImageQuality {
|
|||
await image.WriteAsync(path, cancellation);
|
||||
return guid;
|
||||
} catch (Exception ex) {
|
||||
Logger.LogWarning(ex, "Failed to process uploaded image.");
|
||||
Logger.LogInformation(ex, "Failed to process uploaded image.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,7 +66,7 @@ public class NewsletterBackgroundService(ILogger<NewsletterBackgroundService> lo
|
|||
newsletter.Article.BodyHtml + aboutTheAuthor,
|
||||
newsletter.Article.BodyPlain,
|
||||
"newsletter-" + newsletter.Id, replyTo);
|
||||
await client.SendEmailAsync(email, cancellationToken);
|
||||
await client.SendEmailAsync(email);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
BIN
Wave/package-lock.json
generated
BIN
Wave/package-lock.json
generated
Binary file not shown.
|
@ -33,7 +33,6 @@
|
|||
"vite": "^5.2.13"
|
||||
},
|
||||
"dependencies": {
|
||||
"groupby-polyfill": "^1.0.0",
|
||||
"react-textarea-markdown-editor": "^2.3.0"
|
||||
"groupby-polyfill": "^1.0.0"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue