diff --git a/frontend/src/components/badge.njk b/frontend/src/components/badge.njk
index 6cb0f47..0e35adc 100644
--- a/frontend/src/components/badge.njk
+++ b/frontend/src/components/badge.njk
@@ -7,7 +7,7 @@
}
:host > div {
- background-color: var(--gold);
+ background-color: var(--light-gray);
border-radius: 0.4rem;
color: var(--gray);
font-weight: 600;
diff --git a/frontend/src/components/blog-article-list.njk b/frontend/src/components/blog-article-list.njk
new file mode 100644
index 0000000..da0f1c4
--- /dev/null
+++ b/frontend/src/components/blog-article-list.njk
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+ Title
+
+
+
+
+
+
+
+
+{% includeOnce 'components/button.njk' %}
+{% includeOnce 'components/loader-section.njk' %}
+{% includeOnce 'components/pagination.njk' %}
diff --git a/frontend/src/components/blog-article-list.ts b/frontend/src/components/blog-article-list.ts
new file mode 100644
index 0000000..dd91040
--- /dev/null
+++ b/frontend/src/components/blog-article-list.ts
@@ -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("tesoft-loader-section")!;
+ this.listDiv = shadowRoot.querySelector(".list")!;
+ this.tagsDiv = shadowRoot.querySelector(".tags")!;
+ this.pagination = shadowRoot.querySelector("tesoft-pagination")!;
+ this.error = shadowRoot.querySelector("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 = {
+ 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("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(".dates")!.textContent = formatArticleDate(new Date(article.date), article['mod-date'] ? new Date(article['mod-date']) : null);
+
+ const tags = anchor.querySelector(".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 {
+ 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);
diff --git a/frontend/src/components/blog-article.njk b/frontend/src/components/blog-article.njk
index 6082465..a43ec92 100644
--- a/frontend/src/components/blog-article.njk
+++ b/frontend/src/components/blog-article.njk
@@ -6,8 +6,8 @@
display: block;
}
- tesoft-loader-section {
- min-height: calc(100vh - 8rem);
+ tesoft-loader-section, tesoft-error {
+ min-height: var(--full-content-height);
}
.article {
diff --git a/frontend/src/components/blog-article.ts b/frontend/src/components/blog-article.ts
index c741e8a..016236b 100644
--- a/frontend/src/components/blog-article.ts
+++ b/frontend/src/components/blog-article.ts
@@ -41,42 +41,30 @@ export class TesoftBlogArticle extends TesoftComponent {
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;
- }
+ const badgeText = getBadgeText(body.status);
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)}`}`;
+ this.datesDiv.textContent = formatArticleDate(new Date(body.date), body['mod-date'] ? new Date(body['mod-date']) : null);
for (const tagName of body.tags) {
const tag = document.createElement("tesoft-tag") as TesoftTag;
tag.innerText = tagName;
tag.href = constructUrl(tagName);
+ tag.selected = "";
this.tagsDiv.appendChild(tag);
}
this.contentDiv.innerHTML = body.content;
} else {
const body = await response.json();
- this.error.textContent = capitalize(body.message);
+ this.error.textContent = `${capitalize(body.message)}.`;
this.error.classList.remove("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);
diff --git a/frontend/src/components/button.njk b/frontend/src/components/button.njk
index 26141d7..c50d580 100644
--- a/frontend/src/components/button.njk
+++ b/frontend/src/components/button.njk
@@ -29,7 +29,6 @@
:host([selected]) button, :host([selected]) a {
background-color: var(--light-gray);
- border-style: none;
color: var(--gray);
font-weight: 800;
}
diff --git a/frontend/src/components/button.ts b/frontend/src/components/button.ts
index cc12654..e4db0a1 100644
--- a/frontend/src/components/button.ts
+++ b/frontend/src/components/button.ts
@@ -64,6 +64,14 @@ export class TesoftButton extends TesoftComponent {
this.setAttribute("selected", "");
}
}
+
+ set transparent(value: string | null) {
+ if (value === null) {
+ this.removeAttribute("transparent");
+ } else {
+ this.setAttribute("transparent", "");
+ }
+ }
}
customElements.define("tesoft-button", TesoftButton);
diff --git a/frontend/src/components/error.njk b/frontend/src/components/error.njk
index 672cb3b..bf8d820 100644
--- a/frontend/src/components/error.njk
+++ b/frontend/src/components/error.njk
@@ -3,7 +3,7 @@
@import "/src/styles/default.css";
:host {
- display: block;
+ display: grid;
}
:host > div {
diff --git a/frontend/src/components/nav.ts b/frontend/src/components/nav.ts
index 2deecee..44fb307 100644
--- a/frontend/src/components/nav.ts
+++ b/frontend/src/components/nav.ts
@@ -72,6 +72,7 @@ export class TesoftNav extends TesoftComponent {
for (const child of this.children) {
const button = document.createElement("tesoft-button") as TesoftButton;
button.innerText = child.textContent.trim();
+ button.transparent = "";
this.smallMenuDiv.appendChild(button);
button.href = (child as HTMLAnchorElement).href;
diff --git a/frontend/src/pages/blog.njk b/frontend/src/pages/blog.njk
index 388a59d..63c6925 100644
--- a/frontend/src/pages/blog.njk
+++ b/frontend/src/pages/blog.njk
@@ -11,7 +11,14 @@
{% endblock %}
{% block components %}
+ {% includeOnce 'components/blog-article.njk' %}
+ {% includeOnce 'components/blog-article-list.njk' %}
{% endblock %}
{% block content %}
+
+
+
+
+
{% endblock %}
diff --git a/frontend/src/scripts/blog.ts b/frontend/src/scripts/blog.ts
index e69de29..6b08437 100644
--- a/frontend/src/scripts/blog.ts
+++ b/frontend/src/scripts/blog.ts
@@ -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("tesoft-blog-article")!,
+ document.querySelector("tesoft-blog-article-list")!,
+ Parameters.parse(new URLSearchParams(window.location.search)),
+ );
+
+ blogSite.load();
+});
diff --git a/frontend/src/styles/blog.css b/frontend/src/styles/blog.css
index e69de29..bb6776b 100644
--- a/frontend/src/styles/blog.css
+++ b/frontend/src/styles/blog.css
@@ -0,0 +1,3 @@
+/*tesoft-blog-article {
+ min-height: calc(100vh - 8rem);
+}*/
diff --git a/frontend/src/styles/default.css b/frontend/src/styles/default.css
index 4d50495..04901fd 100644
--- a/frontend/src/styles/default.css
+++ b/frontend/src/styles/default.css
@@ -8,6 +8,8 @@
--medium-padding: 0.8rem;
--large-padding: 1.2rem;
--button-border-radius: 0.5rem;
+
+ --full-content-height: calc(100vh - 8rem);
}
html {
@@ -41,6 +43,10 @@ html {
}
a {
+ color: var(--light-gray);
+}
+
+a:hover {
color: var(--gold);
}
diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css
index 3606cad..5421feb 100644
--- a/frontend/src/styles/main.css
+++ b/frontend/src/styles/main.css
@@ -35,7 +35,7 @@ tesoft-nav {
}
main {
- min-height: calc(100vh - 8rem);
+ min-height: var(--full-content-height);
padding: 0 0.5rem;
}