Merge pull request #12 from avalon-vanguard/feat/hud-about
Say what is written about a world, when asked
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
/** Every request the article service makes, whichever language it is asking in. */
|
||||||
|
const isWikipedia = (url: URL): boolean => url.hostname.endsWith('wikipedia.org');
|
||||||
|
|
||||||
|
/** The article Wikipedia would send, stubbed: this suite tests the panel, not the encyclopedia. */
|
||||||
|
const SUMMARY = {
|
||||||
|
type: 'standard',
|
||||||
|
titles: { normalized: 'Titan (moon)' },
|
||||||
|
extract: 'Titan is the largest moon of Saturn.',
|
||||||
|
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } }
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('About', () => {
|
||||||
|
test('fetches an article only when asked, and says where it came from', async ({ page }) => {
|
||||||
|
let requests = 0;
|
||||||
|
await page.route(isWikipedia, async (route) => {
|
||||||
|
requests++;
|
||||||
|
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) });
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/body/titan');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
|
// Nothing has been fetched yet: the panel is measurements until a reader asks for prose.
|
||||||
|
expect(requests).toBe(0);
|
||||||
|
await expect(page.getByText('Titan is the largest moon')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
|
||||||
|
const credit = page.getByRole('link', { name: /Wikipedia/ });
|
||||||
|
await expect(credit).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Titan_(moon)');
|
||||||
|
expect(requests).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('says nothing is written rather than leaving the press unanswered', async ({ page }) => {
|
||||||
|
await page.route(isWikipedia, (route) => route.fulfill({ status: 404, body: '{}' }));
|
||||||
|
|
||||||
|
await page.goto('/body/titan');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText(/Wikipedia has no article on Titan/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tells being unable to ask apart from there being no answer, and offers to try again', async ({ page }) => {
|
||||||
|
await page.route(isWikipedia, (route) => route.abort('failed'));
|
||||||
|
|
||||||
|
await page.goto('/body/titan');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Titan' })).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.getByRole('button', { name: 'About', exact: true }).click();
|
||||||
|
await expect(page.getByText(/could not be reached/)).toBeVisible();
|
||||||
|
|
||||||
|
// And the retry actually retries, rather than reading back a remembered failure.
|
||||||
|
await page.unroute(isWikipedia);
|
||||||
|
await page.route(isWikipedia, (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(SUMMARY) }));
|
||||||
|
await page.getByRole('button', { name: 'Try again' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Titan is the largest moon of Saturn.')).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ArticleService } from './article.service';
|
||||||
|
|
||||||
|
function summary(overrides: Record<string, unknown> = {}): Response {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'standard',
|
||||||
|
titles: { normalized: 'Titan (moon)' },
|
||||||
|
extract: 'Titan is the largest moon of Saturn.',
|
||||||
|
content_urls: { desktop: { page: 'https://en.wikipedia.org/wiki/Titan_(moon)' } },
|
||||||
|
...overrides
|
||||||
|
}),
|
||||||
|
{ status: 200 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function missing(): Response {
|
||||||
|
return new Response('{}', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What Wikipedia sends anyone who asks too quickly. */
|
||||||
|
function rateLimited(): Response {
|
||||||
|
return new Response('You are making too many requests to the API.', { status: 429 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function service(): ArticleService {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
return TestBed.inject(ArticleService);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The language each call was made in, in order, so the fallback chain can be asserted. */
|
||||||
|
function languagesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => new URL(String(call[0])).hostname.split('.')[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function titlesAsked(fetchMock: ReturnType<typeof vi.fn>): string[] {
|
||||||
|
return fetchMock.mock.calls.map((call) => decodeURIComponent(String(call[0]).split('/summary/')[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ArticleService', () => {
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('navigator', { language: 'en-GB' });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hands back what Wikipedia wrote, and where to go and check it', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
|
||||||
|
const result = await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
status: 'found',
|
||||||
|
article: {
|
||||||
|
title: 'Titan (moon)',
|
||||||
|
extract: 'Titan is the largest moon of Saturn.',
|
||||||
|
url: 'https://en.wikipedia.org/wiki/Titan_(moon)',
|
||||||
|
language: 'en'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks in the browser’s language first and English second', async () => {
|
||||||
|
vi.stubGlobal('navigator', { language: 'fr-FR' });
|
||||||
|
fetchMock.mockResolvedValueOnce(missing()).mockResolvedValueOnce(summary());
|
||||||
|
|
||||||
|
const result = await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(languagesAsked(fetchMock)).toEqual(['fr', 'en']);
|
||||||
|
expect(result.status).toBe('found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks English only once, when English is what the browser is set to', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
|
||||||
|
await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(languagesAsked(fetchMock)).toEqual(['en']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the kind when the plain name lands on a list of other things', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(summary({ type: 'disambiguation' })).mockResolvedValueOnce(summary());
|
||||||
|
|
||||||
|
const result = await service().lookup('Titan', 'moon');
|
||||||
|
|
||||||
|
expect(titlesAsked(fetchMock)).toEqual(['Titan', 'Titan (moon)']);
|
||||||
|
expect(result.status).toBe('found');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says there is nothing written rather than pretending, when nothing is', async () => {
|
||||||
|
fetchMock.mockResolvedValue(missing());
|
||||||
|
|
||||||
|
expect(await service().lookup('HD 224700', 'planet')).toEqual({ status: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells being unable to ask apart from there being no answer', async () => {
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||||
|
|
||||||
|
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the rate limit as being unable to ask, not as an empty answer', async () => {
|
||||||
|
fetchMock.mockResolvedValue(rateLimited());
|
||||||
|
|
||||||
|
expect(await service().lookup('Titan')).toEqual({ status: 'unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks once per body, however many times it is asked for', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
const articles = service();
|
||||||
|
|
||||||
|
await articles.lookup('Titan');
|
||||||
|
await articles.lookup('Titan');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remembers that there is nothing, but never that it could not ask', async () => {
|
||||||
|
const articles = service();
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(missing());
|
||||||
|
await articles.lookup('Nowhere');
|
||||||
|
await articles.lookup('Nowhere');
|
||||||
|
const afterMissing = fetchMock.mock.calls.length;
|
||||||
|
|
||||||
|
fetchMock.mockRejectedValue(new TypeError('offline'));
|
||||||
|
await articles.lookup('Elsewhere');
|
||||||
|
const afterFirstFailure = fetchMock.mock.calls.length;
|
||||||
|
await articles.lookup('Elsewhere');
|
||||||
|
|
||||||
|
// One round of requests for the missing page, then nothing more; but a failure to reach
|
||||||
|
// Wikipedia is a fact about this minute, so pressing again is allowed to try again.
|
||||||
|
expect(afterMissing).toBe(1);
|
||||||
|
expect(fetchMock.mock.calls.length).toBeGreaterThan(afterFirstFailure);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an article with nothing in it as no article', async () => {
|
||||||
|
fetchMock.mockResolvedValue(summary({ extract: ' ' }));
|
||||||
|
|
||||||
|
expect(await service().lookup('Titan')).toEqual({ status: 'none' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not send a browser language that is not one', async () => {
|
||||||
|
vi.stubGlobal('navigator', { language: 'not a language tag' });
|
||||||
|
fetchMock.mockResolvedValue(summary());
|
||||||
|
|
||||||
|
await service().lookup('Titan');
|
||||||
|
|
||||||
|
expect(languagesAsked(fetchMock)).toEqual(['en']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
/** A Wikipedia summary, as much of it as this app shows. */
|
||||||
|
export interface Article {
|
||||||
|
readonly title: string;
|
||||||
|
readonly extract: string;
|
||||||
|
/** The article itself, so a reader can go and check. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Which Wikipedia it came from — `en`, `fr`. Shown, because it is not always the one asked for. */
|
||||||
|
readonly language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Found it, there is no article, or Wikipedia could not be reached. The last two are different
|
||||||
|
* facts and the panel says so: "nothing written about this" and "could not ask" are not the
|
||||||
|
* same, and only one of them is about the world.
|
||||||
|
*/
|
||||||
|
export type ArticleLookup = { readonly status: 'found'; readonly article: Article } | { readonly status: 'none' } | { readonly status: 'unavailable' };
|
||||||
|
|
||||||
|
const FALLBACK_LANGUAGE = 'en';
|
||||||
|
|
||||||
|
/** Wikipedia's own summary endpoint, which follows redirects — "Proxima Cen b" lands on
|
||||||
|
* "Proxima Centauri b" without this app having to know that. */
|
||||||
|
function summaryUrl(language: string, title: string): string {
|
||||||
|
return `https://${language}.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The primary subtag of whatever the browser is set to, or English where there is no browser. */
|
||||||
|
function browserLanguage(): string {
|
||||||
|
// `navigator.language` is optional in the DOM lib and absent in some embedded engines.
|
||||||
|
const tag = (typeof navigator === 'undefined' ? '' : navigator.language) ?? '';
|
||||||
|
const primary = tag.split('-')[0]?.toLowerCase();
|
||||||
|
return primary && /^[a-z]{2,3}$/.test(primary) ? primary : FALLBACK_LANGUAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wikipedia summaries, fetched only when asked for.
|
||||||
|
*
|
||||||
|
* The map's own figures are measurements; this is prose someone wrote, from somewhere else. It
|
||||||
|
* is never loaded with a body, only when a reader asks for it, and it is always labelled with
|
||||||
|
* where it came from and linked back to the article — the same rule the derived surfaces follow,
|
||||||
|
* for the same reason.
|
||||||
|
*
|
||||||
|
* Asked for in the browser's language first and English second, because the catalogue's names
|
||||||
|
* are English and the disambiguation this has to work around is an English-Wikipedia habit.
|
||||||
|
*/
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ArticleService {
|
||||||
|
private readonly cache = new Map<string, ArticleLookup>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The article for `name`, or why there is none.
|
||||||
|
*
|
||||||
|
* `qualifier` is what tells "Titan the moon" from "Titan the disambiguation page": Wikipedia
|
||||||
|
* parenthesises the kind, and this app happens to know it. Only English gets that treatment —
|
||||||
|
* every wiki words its own qualifiers — so a disambiguation anywhere else falls back to
|
||||||
|
* English rather than guessing at a translation.
|
||||||
|
*/
|
||||||
|
async lookup(name: string, qualifier?: string): Promise<ArticleLookup> {
|
||||||
|
// Separated by an escape rather than a space: a name may contain one, so `("Kepler-22 b",
|
||||||
|
// undefined)` and `("Kepler-22", "b")` would otherwise be the same question.
|
||||||
|
const key = `${name}\0${qualifier ?? ''}`;
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const language = browserLanguage();
|
||||||
|
const attempts: { language: string; title: string }[] = [
|
||||||
|
...(language === FALLBACK_LANGUAGE ? [] : [{ language, title: name }]),
|
||||||
|
{ language: FALLBACK_LANGUAGE, title: name },
|
||||||
|
...(qualifier ? [{ language: FALLBACK_LANGUAGE, title: `${name} (${qualifier})` }] : [])
|
||||||
|
];
|
||||||
|
|
||||||
|
let reachedWikipedia = false;
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
const result = await this.fetchSummary(attempt.language, attempt.title);
|
||||||
|
if (result === 'unreachable') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
reachedWikipedia = true;
|
||||||
|
if (result) {
|
||||||
|
const found: ArticleLookup = { status: 'found', article: result };
|
||||||
|
this.cache.set(key, found);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not cached when Wikipedia could not be reached: that is a fact about the network this
|
||||||
|
// minute, and the next press should be allowed to ask again.
|
||||||
|
const outcome: ArticleLookup = reachedWikipedia ? { status: 'none' } : { status: 'unavailable' };
|
||||||
|
if (reachedWikipedia) {
|
||||||
|
this.cache.set(key, outcome);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One request. `null` where Wikipedia answered but has nothing usable — a missing page, or a
|
||||||
|
* disambiguation, which is a list of things this is not. `'unreachable'` where it did not
|
||||||
|
* answer at all, including the rate limit it returns to anyone who asks too fast.
|
||||||
|
*/
|
||||||
|
private async fetchSummary(language: string, title: string): Promise<Article | null | 'unreachable'> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(summaryUrl(language, title), { headers: { Accept: 'application/json' } });
|
||||||
|
} catch {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as {
|
||||||
|
type?: string;
|
||||||
|
extract?: string;
|
||||||
|
titles?: { normalized?: string };
|
||||||
|
content_urls?: { desktop?: { page?: string } };
|
||||||
|
};
|
||||||
|
const extract = body.extract?.trim();
|
||||||
|
if (!extract || body.type === 'disambiguation') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: body.titles?.normalized ?? title,
|
||||||
|
extract,
|
||||||
|
url: body.content_urls?.desktop?.page ?? `https://${language}.wikipedia.org/wiki/${encodeURIComponent(title)}`,
|
||||||
|
language
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return 'unreachable';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, computed, inject, input } from '@angular/core';
|
import { Component, computed, effect, inject, input, signal } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { Article, ArticleService } from '../../core/data/article.service';
|
||||||
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
import { BookmarksStore } from '../../shared/state/bookmarks.store';
|
||||||
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
import { BookmarkIconComponent } from '../../shared/ui/bookmark-icon.component';
|
||||||
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
import { ChevronIconComponent } from '../../shared/ui/chevron-icon.component';
|
||||||
@@ -50,6 +51,47 @@ import { ReadoutSectionsComponent } from './readout-sections.component';
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<app-readout-sections [readouts]="readouts()" />
|
<app-readout-sections [readouts]="readouts()" />
|
||||||
|
|
||||||
|
<!-- Prose from elsewhere, and only when asked for. Everything above this line is a
|
||||||
|
measurement or something derived from one; this is a person's paragraph on another
|
||||||
|
site, so it says whose and links back to it. -->
|
||||||
|
@if (article(); as found) {
|
||||||
|
<!-- Capped and scrollable: a Wikipedia lead can run to a dozen lines, and this panel is
|
||||||
|
anchored to the top of a viewport that may be a good deal shorter than the prose. -->
|
||||||
|
<div class="max-h-56 overflow-y-auto border-t border-border/40 px-4 py-3">
|
||||||
|
<p class="text-[11px] leading-relaxed text-muted">{{ found.extract }}</p>
|
||||||
|
<a
|
||||||
|
[href]="found.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="type-label mt-2 inline-block text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent"
|
||||||
|
>Wikipedia · {{ found.language }}</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
} @else if (aboutState() !== 'idle') {
|
||||||
|
<p class="border-t border-border/40 px-4 py-3 text-[11px] leading-relaxed text-muted">
|
||||||
|
@switch (aboutState()) {
|
||||||
|
@case ('loading') {
|
||||||
|
Asking Wikipedia…
|
||||||
|
}
|
||||||
|
@case ('none') {
|
||||||
|
Wikipedia has no article on {{ body().name }}.
|
||||||
|
}
|
||||||
|
@case ('unavailable') {
|
||||||
|
Wikipedia could not be reached.
|
||||||
|
<button type="button" (click)="loadArticle()" class="text-accent underline decoration-accent/40 underline-offset-2 hover:decoration-accent focus-visible:outline-1 focus-visible:outline-accent">Try again</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
(click)="loadArticle()"
|
||||||
|
class="type-label w-full border-t border-border/40 px-4 py-2 text-left text-muted transition-colors hover:bg-accent/8 hover:text-accent focus-visible:bg-accent/12 focus-visible:text-accent focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-accent"
|
||||||
|
>
|
||||||
|
About
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
@@ -58,9 +100,38 @@ export class InfoPanelComponent {
|
|||||||
|
|
||||||
readonly bookmarks = inject(BookmarksStore);
|
readonly bookmarks = inject(BookmarksStore);
|
||||||
|
|
||||||
readonly readouts = computed(() => bodyReadouts(this.body()));
|
private readonly articles = inject(ArticleService);
|
||||||
|
|
||||||
constructor(private readonly router: Router) {}
|
readonly article = signal<Article | null>(null);
|
||||||
|
readonly aboutState = signal<'idle' | 'loading' | 'none' | 'unavailable'>('idle');
|
||||||
|
|
||||||
|
constructor(private readonly router: Router) {
|
||||||
|
// The panel is reused as the route's parameter changes, so what was asked about one body
|
||||||
|
// must not still be showing under the next one's name.
|
||||||
|
effect(() => {
|
||||||
|
this.body();
|
||||||
|
this.article.set(null);
|
||||||
|
this.aboutState.set('idle');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the article, on the press and not before. The kind goes with the name because
|
||||||
|
* Wikipedia disambiguates by it — "Titan" alone is a list of everything called Titan.
|
||||||
|
*/
|
||||||
|
async loadArticle(): Promise<void> {
|
||||||
|
this.aboutState.set('loading');
|
||||||
|
const result = await this.articles.lookup(this.body().name, this.readouts().kindLabel.toLowerCase());
|
||||||
|
if (result.status === 'found') {
|
||||||
|
this.article.set(result.article);
|
||||||
|
this.aboutState.set('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.article.set(null);
|
||||||
|
this.aboutState.set(result.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly readouts = computed(() => bodyReadouts(this.body()));
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
void this.router.navigate(['/']);
|
void this.router.navigate(['/']);
|
||||||
|
|||||||
Reference in New Issue
Block a user