add blog article component

Signed-off-by: Tobias Erbshäußer <tobias@tesoft.dev>
This commit is contained in:
2026-05-24 09:22:49 +02:00
parent c0919670ca
commit 5a659e59cd
4 changed files with 200 additions and 1 deletions
+81
View File
@@ -0,0 +1,81 @@
<template id="tesoft-blog-article-template">
<style>
@import "/src/styles/default.css";
:host {
display: block;
}
tesoft-loader-section {
min-height: calc(100vh - 8rem);
}
.article {
display: grid;
gap: 1.6rem;
grid-template-columns: 1fr;
grid-template-rows: auto auto;
width: 100%;
}
.header {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
}
.header > h1 {
align-items: center;
display: grid;
gap: 1rem;
grid-template-columns: auto auto;
grid-template-rows: auto;
justify-content: start;
font-size: 4.4rem;
line-height: 4.4rem;
padding-bottom: 0;
}
.dates {
padding-bottom: 1rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
img {
object-fit: cover;
max-width: 100%;
}
tesoft-error {
height: 100%;
}
</style>
<tesoft-loader-section>
<div class="article">
<div class="header">
<h1></h1>
<div class="dates">
</div>
<div class="tags">
</div>
</div>
<div class="content"></div>
</div>
<tesoft-error class="hidden">
</tesoft-error>
</tesoft-loader-section>
</template>
<script src="/src/components/blog-article.ts" type="module"></script>
{% includeOnce 'components/badge.njk' %}
{% includeOnce 'components/error.njk' %}
{% includeOnce 'components/loader-section.njk' %}
{% includeOnce 'components/tag.njk' %}
+88
View File
@@ -0,0 +1,88 @@
import {capitalize, formatDate, sendApiGet, TesoftComponent} from "../scripts/main.ts";
import {TesoftLoaderSection} from "./loader-section.ts";
import {TesoftBadge} from "./badge.ts";
import {TesoftTag} from "./tag.ts";
import {TesoftError} from "./error.ts";
enum ArticleStatus {
Draft = 0,
Published = 1,
Offline = 2,
}
export class TesoftBlogArticle extends TesoftComponent {
private readonly loaderSection: TesoftLoaderSection;
private readonly article: HTMLDivElement;
private readonly heading: HTMLHeadingElement;
private readonly datesDiv: HTMLDivElement;
private readonly tagsDiv: HTMLDivElement;
private readonly contentDiv: HTMLDivElement;
private readonly error: TesoftError;
constructor() {
super();
const template = document.getElementById("tesoft-blog-article-template") as HTMLTemplateElement;
const templateContent = template.content;
const shadowRoot = this.attachShadow({mode: "open"});
shadowRoot.appendChild(templateContent.cloneNode(true));
this.loaderSection = shadowRoot.querySelector<TesoftLoaderSection>("tesoft-loader-section")!;
this.article = shadowRoot.querySelector<HTMLDivElement>(".article")!;
this.heading = shadowRoot.querySelector<HTMLHeadingElement>("h1")!;
this.datesDiv = shadowRoot.querySelector<HTMLDivElement>(".dates")!;
this.tagsDiv = shadowRoot.querySelector<HTMLDivElement>(".tags")!;
this.contentDiv = shadowRoot.querySelector<HTMLDivElement>(".content")!;
this.error = shadowRoot.querySelector<TesoftError>("tesoft-error")!;
}
async load(id: number, constructUrl: (tag: string) => string) {
const response = await sendApiGet(`blog/${id}`, {});
if (response.ok) {
const body = await response.json();
const date = new Date(body.date);
const modDate = body['mod-date'] ? new Date(body['mod-date']) : null;
this.heading.textContent = body.title;
let badgeText: string;
switch (body.status) {
case ArticleStatus.Draft:
badgeText = "Draft";
break;
case ArticleStatus.Offline:
badgeText = "Offline";
break;
default:
badgeText = "";
break;
}
if (badgeText) {
const badge = document.createElement("tesoft-badge") as TesoftBadge;
badge.textContent = badgeText;
this.heading.appendChild(badge);
}
this.datesDiv.textContent = `Posted on ${formatDate(date)}${modDate === null ? "" : `, last modified on ${formatDate(modDate)}`}`;
for (const tagName of body.tags) {
const tag = document.createElement("tesoft-tag") as TesoftTag;
tag.innerText = tagName;
tag.href = constructUrl(tagName);
this.tagsDiv.appendChild(tag);
}
this.contentDiv.innerHTML = body.content;
} else {
const body = await response.json();
this.error.textContent = capitalize(body.message);
this.error.classList.remove("hidden");
this.article.classList.add("hidden");
}
this.loaderSection.finish();
}
}
customElements.define("tesoft-blog-article", TesoftBlogArticle);
+23
View File
@@ -125,3 +125,26 @@ export function sendApiPost(path: string, data: Record<string, any>): Promise<Re
body: JSON.stringify(data),
});
}
export function capitalize(str: string): string {
if (str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
return str;
}
export function sendApiGet(path: string, query: Record<string, string | number>): Promise<Response> {
const url = new URL(`/api/${path}`, location.origin);
for (const [key, value] of Object.entries(query)) {
url.searchParams.append(key, value.toString());
}
return fetch(url, {
method: "GET",
});
}
export function formatDate(date: Date): string {
return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`;
}
+8 -1
View File
@@ -10,6 +10,11 @@
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
html, body {
min-height: 100vh;
overflow: auto;
}
body {
background-color: var(--dark-gray);
color: var(--light-gray);
@@ -24,12 +29,14 @@ body > div {
}
tesoft-nav {
padding-bottom: 0.8rem;
position: sticky;
top: 0;
}
main {
padding: 0 0.5rem 0 0.5rem;
min-height: calc(100vh - 8rem);
padding: 0 0.5rem;
}
@media (max-width: 1440px) {