diff --git a/frontend/src/components/blog-article.njk b/frontend/src/components/blog-article.njk
new file mode 100644
index 0000000..6082465
--- /dev/null
+++ b/frontend/src/components/blog-article.njk
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+{% includeOnce 'components/badge.njk' %}
+{% includeOnce 'components/error.njk' %}
+{% includeOnce 'components/loader-section.njk' %}
+{% includeOnce 'components/tag.njk' %}
diff --git a/frontend/src/components/blog-article.ts b/frontend/src/components/blog-article.ts
new file mode 100644
index 0000000..c741e8a
--- /dev/null
+++ b/frontend/src/components/blog-article.ts
@@ -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("tesoft-loader-section")!;
+ this.article = shadowRoot.querySelector(".article")!;
+ this.heading = shadowRoot.querySelector("h1")!;
+ this.datesDiv = shadowRoot.querySelector(".dates")!;
+ this.tagsDiv = shadowRoot.querySelector(".tags")!;
+ this.contentDiv = shadowRoot.querySelector(".content")!;
+ this.error = shadowRoot.querySelector("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);
diff --git a/frontend/src/scripts/main.ts b/frontend/src/scripts/main.ts
index 81d858a..9d8b19f 100644
--- a/frontend/src/scripts/main.ts
+++ b/frontend/src/scripts/main.ts
@@ -125,3 +125,26 @@ export function sendApiPost(path: string, data: Record): Promise): Promise {
+ 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")}`;
+}
diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css
index 870b716..3606cad 100644
--- a/frontend/src/styles/main.css
+++ b/frontend/src/styles/main.css
@@ -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) {