Compare commits
10 commits
69a5d51214
...
2a9513b21b
Author | SHA1 | Date | |
---|---|---|---|
Mia Rose Winter | 2a9513b21b | ||
Mia Rose Winter | 807eda0870 | ||
Mia Rose Winter | 1750542182 | ||
Mia Rose Winter | f61188784d | ||
Mia Rose Winter | f5949b1bca | ||
Mia Rose Winter | 8f4518fef6 | ||
Mia Rose Winter | 969339c04b | ||
Mia Rose Winter | 1bf313237f | ||
Mia Rose Winter | f9fc6397b5 | ||
Mia Rose Winter | ade03f28b9 |
|
@ -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">
|
||||||
|
|
|
@ -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"}
|
||||||
|
|
95
Wave/Assets/React/ImageEditor.tsx
Normal file
95
Wave/Assets/React/ImageEditor.tsx
Normal 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;
|
33
Wave/Assets/React/Modal.tsx
Normal file
33
Wave/Assets/React/Modal.tsx
Normal 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;
|
|
@ -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",
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)) {
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
|
78
Wave/Components/OpenGraph.razor
Normal file
78
Wave/Components/OpenGraph.razor
Normal 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; }
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
94
Wave/Components/Pages/Settings.razor
Normal file
94
Wave/Components/Pages/Settings.razor
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
|
@ -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", \
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
107
Wave/Resources/Components/Pages/Settings.de-DE.resx
Normal file
107
Wave/Resources/Components/Pages/Settings.de-DE.resx
Normal 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>
|
101
Wave/Resources/Components/Pages/Settings.en-GB.resx
Normal file
101
Wave/Resources/Components/Pages/Settings.en-GB.resx
Normal 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>
|
110
Wave/Resources/Components/Pages/Settings.resx
Normal file
110
Wave/Resources/Components/Pages/Settings.resx
Normal 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>
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
BIN
Wave/package-lock.json
generated
Binary file not shown.
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue