add blog page

Signed-off-by: Tobias Erbshäußer <tobias@tesoft.dev>
This commit is contained in:
2026-05-24 09:22:55 +02:00
parent 7cd2dee64c
commit 2b6c7074c2
14 changed files with 407 additions and 22 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
} }
:host > div { :host > div {
background-color: var(--gold); background-color: var(--light-gray);
border-radius: 0.4rem; border-radius: 0.4rem;
color: var(--gray); color: var(--gray);
font-weight: 600; font-weight: 600;
@@ -0,0 +1,88 @@
<template id="tesoft-blog-article-list-template">
<style>
@import "/src/styles/default.css";
:host {
display: block;
}
:host > div {
display: grid;
gap: 1rem;
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
tesoft-loader-section, .list, tesoft-error {
min-height: calc(var(--full-content-height) - 10rem);
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.list {
align-content: start;
display: grid;
gap: 2rem;
grid-template-columns: 1fr;
grid-auto-rows: auto;
width: 100%;
}
.item {
display: grid;
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
text-decoration: none;
}
.item > h3 {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-columns: auto;
grid-auto-flow: column;
justify-content: start;
padding-bottom: 0;
}
.item > .dates {
padding-bottom: 0.6rem;
}
tesoft-pagination {
justify-self: center;
}
</style>
<div>
<div class="tags"></div>
<tesoft-loader-section>
<div class="list">
</div>
<tesoft-error class="hidden">
</tesoft-error>
</tesoft-loader-section>
<tesoft-pagination class="hidden">
</tesoft-pagination>
</div>
</template>
<template id="tesoft-blog-article-list-item-template">
<a class="item">
<h3>Title</h3>
<div class="dates">
</div>
<div class="tags">
</div>
</a>
</template>
<script src="/src/components/blog-article-list.ts" type="module"></script>
{% includeOnce 'components/button.njk' %}
{% includeOnce 'components/loader-section.njk' %}
{% includeOnce 'components/pagination.njk' %}
@@ -0,0 +1,174 @@
import {capitalize, sendApiGet, TesoftComponent} from "../scripts/main.ts";
import {TesoftLoaderSection} from "./loader-section.ts";
import {TesoftPagination} from "./pagination.ts";
import {TesoftError} from "./error.ts";
import {TesoftTag} from "./tag.ts";
import {formatArticleDate, getBadgeText} from "./blog-article.ts";
import {TesoftBadge} from "./badge.ts";
export class TesoftBlogArticleList extends TesoftComponent {
private readonly listItemTemplate: HTMLTemplateElement;
private readonly loaderSection: TesoftLoaderSection;
private readonly listDiv: HTMLDivElement;
private readonly tagsDiv: HTMLDivElement;
private readonly pagination: TesoftPagination;
private readonly error: TesoftError;
constructor() {
super();
const template = document.getElementById("tesoft-blog-article-list-template") as HTMLTemplateElement;
const templateContent = template.content;
const shadowRoot = this.attachShadow({mode: "open"});
shadowRoot.appendChild(templateContent.cloneNode(true));
this.listItemTemplate = document.getElementById("tesoft-blog-article-list-item-template") as HTMLTemplateElement;
this.loaderSection = shadowRoot.querySelector<TesoftLoaderSection>("tesoft-loader-section")!;
this.listDiv = shadowRoot.querySelector<HTMLDivElement>(".list")!;
this.tagsDiv = shadowRoot.querySelector<HTMLDivElement>(".tags")!;
this.pagination = shadowRoot.querySelector<TesoftPagination>("tesoft-pagination")!;
this.error = shadowRoot.querySelector<TesoftError>("tesoft-error")!;
}
async load(page: number, tags: string[], onParametersChange: (page: number, tags: string[]) => void) {
try {
this.tagsDiv.innerHTML = "";
for (const tagName of await this.fetchTags()) {
const tag = document.createElement("tesoft-tag") as TesoftTag;
tag.textContent = tagName;
this.tagsDiv.appendChild(tag);
}
} catch (error: any) {
// Ignore
}
this.pagination.addEventListener("update-page", (e) => {
const page = (e as CustomEvent).detail.page;
onParametersChange(page, tags);
this.reload(page, tags, onParametersChange);
});
await this.reload(page, tags, onParametersChange);
}
private async reload(page: number, tags: string[], onParametersChange: (page: number, tags: string[]) => void) {
const itemsPerPage = 20;
for (const tag of this.tagsDiv.children as any as TesoftTag[]) {
const tagName = tag.textContent.trim();
tag.onclick = () => {
const tagSet = new Set(tags);
if (tagSet.has(tagName)) {
tagSet.delete(tagName);
} else {
tagSet.add(tagName);
}
const newTags = Array.from(tagSet);
onParametersChange(page, newTags);
this.reload(page, newTags, onParametersChange);
};
if (tags.includes(tagName)) {
tag.selected = "";
} else {
tag.selected = null;
}
}
this.loaderSection.reset();
this.listDiv.innerHTML = "";
this.pagination.classList.add("hidden");
const parameters: Record<string, any> = {
offset: (page - 1) * itemsPerPage,
limit: itemsPerPage,
};
if (tags) {
parameters.tags = tags.join(",");
}
try {
const response = await sendApiGet("blog", parameters);
if (response.ok) {
const body = await response.json();
if (body.articles.length === 0) {
throw "No articles found.";
}
for (const article of body.articles) {
const anchor = this.listItemTemplate.content.children[0].cloneNode(true) as HTMLAnchorElement;
anchor.href = `/blog?id=${article.id}`;
const heading = anchor.querySelector<HTMLHeadingElement>("h3")!;
heading.textContent = article.title;
const badgeText = getBadgeText(body.status);
if (badgeText) {
const badge = document.createElement("tesoft-badge") as TesoftBadge;
badge.textContent = badgeText;
heading.appendChild(badge);
}
anchor.querySelector<HTMLDivElement>(".dates")!.textContent = formatArticleDate(new Date(article.date), article['mod-date'] ? new Date(article['mod-date']) : null);
const tags = anchor.querySelector<HTMLDivElement>(".tags")!;
for (const tagName of article.tags) {
const badge = document.createElement("tesoft-badge") as TesoftBadge;
badge.textContent = tagName;
tags.appendChild(badge);
}
this.listDiv.appendChild(anchor);
}
this.pagination.curPage = page;
this.pagination.lastPage = Math.ceil(body.total / itemsPerPage);
this.pagination.classList.remove("hidden");
} else {
const body = await response.json();
throw `${capitalize(body.message)}.`;
}
} catch (error: any) {
this.error.textContent = error;
this.error.classList.remove("hidden");
this.listDiv.classList.add("hidden");
}
this.loaderSection.finish();
}
private async fetchTags(): Promise<string[]> {
const tags: string[] = [];
let offset = 0;
const limit = 50;
while (true) {
const response = await sendApiGet("blog/tags", {
offset: offset,
limit: limit,
});
if (!response.ok) {
const body = await response.json();
throw `${capitalize(body.message)}.`;
}
const body = await response.json();
for (const tag of body.tags) {
tags.push(tag);
}
if (body.tags.length < limit) {
break;
}
}
return tags;
}
}
customElements.define("tesoft-blog-article-list", TesoftBlogArticleList);
+2 -2
View File
@@ -6,8 +6,8 @@
display: block; display: block;
} }
tesoft-loader-section { tesoft-loader-section, tesoft-error {
min-height: calc(100vh - 8rem); min-height: var(--full-content-height);
} }
.article { .article {
+19 -16
View File
@@ -41,42 +41,30 @@ export class TesoftBlogArticle extends TesoftComponent {
const response = await sendApiGet(`blog/${id}`, {}); const response = await sendApiGet(`blog/${id}`, {});
if (response.ok) { if (response.ok) {
const body = await response.json(); 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; this.heading.textContent = body.title;
let badgeText: string; const badgeText = getBadgeText(body.status);
switch (body.status) {
case ArticleStatus.Draft:
badgeText = "Draft";
break;
case ArticleStatus.Offline:
badgeText = "Offline";
break;
default:
badgeText = "";
break;
}
if (badgeText) { if (badgeText) {
const badge = document.createElement("tesoft-badge") as TesoftBadge; const badge = document.createElement("tesoft-badge") as TesoftBadge;
badge.textContent = badgeText; badge.textContent = badgeText;
this.heading.appendChild(badge); this.heading.appendChild(badge);
} }
this.datesDiv.textContent = `Posted on ${formatDate(date)}${modDate === null ? "" : `, last modified on ${formatDate(modDate)}`}`; this.datesDiv.textContent = formatArticleDate(new Date(body.date), body['mod-date'] ? new Date(body['mod-date']) : null);
for (const tagName of body.tags) { for (const tagName of body.tags) {
const tag = document.createElement("tesoft-tag") as TesoftTag; const tag = document.createElement("tesoft-tag") as TesoftTag;
tag.innerText = tagName; tag.innerText = tagName;
tag.href = constructUrl(tagName); tag.href = constructUrl(tagName);
tag.selected = "";
this.tagsDiv.appendChild(tag); this.tagsDiv.appendChild(tag);
} }
this.contentDiv.innerHTML = body.content; this.contentDiv.innerHTML = body.content;
} else { } else {
const body = await response.json(); const body = await response.json();
this.error.textContent = capitalize(body.message); this.error.textContent = `${capitalize(body.message)}.`;
this.error.classList.remove("hidden"); this.error.classList.remove("hidden");
this.article.classList.add("hidden"); this.article.classList.add("hidden");
} }
@@ -85,4 +73,19 @@ export class TesoftBlogArticle extends TesoftComponent {
} }
} }
export function getBadgeText(status: ArticleStatus): string | null {
switch (status) {
case ArticleStatus.Draft:
return "Draft";
case ArticleStatus.Offline:
return "Offline";
default:
return null;
}
}
export function formatArticleDate(date: Date, modDate: Date | null): string {
return `Posted on ${formatDate(date)}${modDate === null ? "" : `, last modified on ${formatDate(modDate)}`}`;
}
customElements.define("tesoft-blog-article", TesoftBlogArticle); customElements.define("tesoft-blog-article", TesoftBlogArticle);
-1
View File
@@ -29,7 +29,6 @@
:host([selected]) button, :host([selected]) a { :host([selected]) button, :host([selected]) a {
background-color: var(--light-gray); background-color: var(--light-gray);
border-style: none;
color: var(--gray); color: var(--gray);
font-weight: 800; font-weight: 800;
} }
+8
View File
@@ -64,6 +64,14 @@ export class TesoftButton extends TesoftComponent {
this.setAttribute("selected", ""); this.setAttribute("selected", "");
} }
} }
set transparent(value: string | null) {
if (value === null) {
this.removeAttribute("transparent");
} else {
this.setAttribute("transparent", "");
}
}
} }
customElements.define("tesoft-button", TesoftButton); customElements.define("tesoft-button", TesoftButton);
+1 -1
View File
@@ -3,7 +3,7 @@
@import "/src/styles/default.css"; @import "/src/styles/default.css";
:host { :host {
display: block; display: grid;
} }
:host > div { :host > div {
+1
View File
@@ -72,6 +72,7 @@ export class TesoftNav extends TesoftComponent {
for (const child of this.children) { for (const child of this.children) {
const button = document.createElement("tesoft-button") as TesoftButton; const button = document.createElement("tesoft-button") as TesoftButton;
button.innerText = child.textContent.trim(); button.innerText = child.textContent.trim();
button.transparent = "";
this.smallMenuDiv.appendChild(button); this.smallMenuDiv.appendChild(button);
button.href = (child as HTMLAnchorElement).href; button.href = (child as HTMLAnchorElement).href;
+7
View File
@@ -11,7 +11,14 @@
{% endblock %} {% endblock %}
{% block components %} {% block components %}
{% includeOnce 'components/blog-article.njk' %}
{% includeOnce 'components/blog-article-list.njk' %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<tesoft-blog-article class="hidden">
</tesoft-blog-article>
<tesoft-blog-article-list class="hidden">
</tesoft-blog-article-list>
{% endblock %} {% endblock %}
+96
View File
@@ -0,0 +1,96 @@
import {onReady} from "./main.ts";
import {TesoftBlogArticle} from "../components/blog-article.ts";
import {TesoftBlogArticleList} from "../components/blog-article-list.ts";
class Parameters {
readonly id: number | null;
readonly page: number | null;
readonly tags: string[];
constructor(id: number | null, page: number | null, tags: string[]) {
this.id = id;
this.page = page;
this.tags = tags;
}
constructUrl(): URL {
const url = new URL(window.location.origin + window.location.pathname);
if (this.id !== null) {
url.searchParams.set("id", this.id.toString());
}
if (this.page !== null) {
url.searchParams.set("page", this.page.toString());
}
if (this.tags.length > 0) {
url.searchParams.set("tags", this.tags.join(","));
}
return url;
}
static parse(searchParams: URLSearchParams): Parameters {
let id: number | null = null;
if (searchParams.has("id")) {
id = parseInt(searchParams.get("id")!);
}
let page: number | null = null;
if (searchParams.has("page")) {
page = parseInt(searchParams.get("page")!);
}
let tags: string[] = [];
const tagsStr = searchParams.get("tags");
if (tagsStr) {
tags = tagsStr.split(",");
}
return new Parameters(id, page, tags);
}
}
class BlogSite {
private readonly blogArticle: TesoftBlogArticle;
private readonly blogArticleList: TesoftBlogArticleList;
private parameters: Parameters;
constructor(blogArticle: TesoftBlogArticle, blogArticleList: TesoftBlogArticleList, parameters: Parameters) {
this.blogArticle = blogArticle;
this.blogArticleList = blogArticleList;
this.parameters = parameters;
}
load() {
if (this.parameters.id === null) {
this.blogArticleList.load(this.parameters.page ?? 1, this.parameters.tags, (page, tags) => {
this.parameters = new Parameters(
null,
page,
tags,
);
window.history.pushState(null, "", this.parameters.constructUrl());
});
this.blogArticleList.classList.remove("hidden");
} else {
this.blogArticle.load(this.parameters.id, (tag) => {
return new Parameters(
null,
null,
Array.from(new Set(this.parameters.tags).add(tag)),
).constructUrl().toString();
});
this.blogArticle.classList.remove("hidden");
}
}
}
onReady(async () => {
const blogSite = new BlogSite(
document.querySelector<TesoftBlogArticle>("tesoft-blog-article")!,
document.querySelector<TesoftBlogArticleList>("tesoft-blog-article-list")!,
Parameters.parse(new URLSearchParams(window.location.search)),
);
blogSite.load();
});
+3
View File
@@ -0,0 +1,3 @@
/*tesoft-blog-article {
min-height: calc(100vh - 8rem);
}*/
+6
View File
@@ -8,6 +8,8 @@
--medium-padding: 0.8rem; --medium-padding: 0.8rem;
--large-padding: 1.2rem; --large-padding: 1.2rem;
--button-border-radius: 0.5rem; --button-border-radius: 0.5rem;
--full-content-height: calc(100vh - 8rem);
} }
html { html {
@@ -41,6 +43,10 @@ html {
} }
a { a {
color: var(--light-gray);
}
a:hover {
color: var(--gold); color: var(--gold);
} }
+1 -1
View File
@@ -35,7 +35,7 @@ tesoft-nav {
} }
main { main {
min-height: calc(100vh - 8rem); min-height: var(--full-content-height);
padding: 0 0.5rem; padding: 0 0.5rem;
} }