aboutsummaryrefslogtreecommitdiff
path: root/src/lib
diff options
context:
space:
mode:
authorRuben Beltran del Rio <ruben@unlimited.pizza>2022-05-01 00:56:06 +0200
committerRuben Beltran del Rio <ruben@unlimited.pizza>2022-05-01 00:56:06 +0200
commita7cf03c192470cbab13edeb1aec99e0c66dede10 (patch)
tree581b4430d1de958dcb666bae80a7678332134602 /src/lib
parent010f307346e525ac2e4239a0549d2c1a4d6d102b (diff)
Update / use typescript
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/animations/blink.test.ts107
-rw-r--r--src/lib/animations/blink.ts25
-rw-r--r--src/lib/components/actions/topic.svelte25
-rw-r--r--src/lib/components/actions/topic.test.ts29
-rw-r--r--src/lib/components/author/author.svelte0
-rw-r--r--src/lib/components/error_block/error_block.svelte40
-rw-r--r--src/lib/components/error_block/error_block.test.ts32
-rw-r--r--src/lib/components/footer/footer.svelte30
-rw-r--r--src/lib/components/forum/forum.svelte13
-rw-r--r--src/lib/components/forum/forum.test.ts73
-rw-r--r--src/lib/components/forum_list/forum_list.svelte42
-rw-r--r--src/lib/components/forum_list/forum_list.test.ts94
-rw-r--r--src/lib/components/glyph/glyph.svelte41
-rw-r--r--src/lib/components/glyph/glyph.test.ts36
-rw-r--r--src/lib/components/header/header.svelte63
-rw-r--r--src/lib/components/header/header.test.ts33
-rw-r--r--src/lib/components/home/home.svelte6
-rw-r--r--src/lib/components/invalid_route/invalid_route.svelte6
-rw-r--r--src/lib/components/language_selector/language_selector.svelte26
-rw-r--r--src/lib/components/language_selector/language_selector.test.ts48
-rw-r--r--src/lib/components/loader/loader.svelte5
-rw-r--r--src/lib/components/post/post.svelte50
-rw-r--r--src/lib/components/post/post.test.ts128
-rw-r--r--src/lib/components/tag/tag.svelte13
-rw-r--r--src/lib/components/tag/tag.test.ts62
-rw-r--r--src/lib/components/topic/topic.svelte44
-rw-r--r--src/lib/components/topic/topic.test.ts168
-rw-r--r--src/lib/components/topic_summary/topic_summary.svelte23
-rw-r--r--src/lib/components/topic_summary/topic_summary.test.ts47
-rw-r--r--src/lib/config/__mocks__/config.ts15
-rw-r--r--src/lib/config/apollo.ts14
-rw-r--r--src/lib/config/config.ts17
-rw-r--r--src/lib/config/env.dist1
-rw-r--r--src/lib/data/queries.ts90
-rw-r--r--src/lib/i18n.ts12
-rw-r--r--src/lib/stores/actions.test.ts32
-rw-r--r--src/lib/stores/actions.ts41
-rw-r--r--src/lib/stores/apollo.ts64
-rw-r--r--src/lib/stores/forums.test.ts570
-rw-r--r--src/lib/stores/forums.ts5
-rw-r--r--src/lib/stores/posts.test.ts339
-rw-r--r--src/lib/stores/posts.ts4
-rw-r--r--src/lib/stores/tags.test.ts311
-rw-r--r--src/lib/stores/tags.ts4
-rw-r--r--src/lib/stores/topics.test.ts413
-rw-r--r--src/lib/stores/topics.ts4
-rw-r--r--src/lib/translations/en.json112
-rw-r--r--src/lib/translations/es.json112
-rw-r--r--src/lib/utils/glyph_hash.test.ts68
-rw-r--r--src/lib/utils/glyph_hash.ts55
-rw-r--r--src/lib/utils/readable_time.test.ts83
-rw-r--r--src/lib/utils/readable_time.ts46
-rw-r--r--src/lib/utils/resolve_after.test.ts37
-rw-r--r--src/lib/utils/resolve_after.ts29
54 files changed, 3787 insertions, 0 deletions
diff --git a/src/lib/animations/blink.test.ts b/src/lib/animations/blink.test.ts
new file mode 100644
index 0000000..32dc872
--- /dev/null
+++ b/src/lib/animations/blink.test.ts
@@ -0,0 +1,107 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import { blink } from './blink';
+import { sineOut } from 'svelte/easing';
+
+const internals = {
+ response: null
+};
+
+describe('blink', () => {
+
+ test('it has a default delay of 0ms', () => {
+
+ const response = blink(document.createElement('div'), {});
+
+ expect(response.delay).toBe(0);
+ });
+
+ test('it allows delay to be overridden', () => {
+
+ const response = blink(document.createElement('div'), {
+ delay: 300
+ });
+
+ expect(response.delay).toBe(300);
+ });
+
+ test('it has a default duration of 400ms', () => {
+
+ const response = blink(document.createElement('div'), {});
+
+ expect(response.duration).toBe(400);
+ });
+
+ test('it allows delay to be overridden', () => {
+
+ const response = blink(document.createElement('div'), {
+ duration: 999
+ });
+
+ expect(response.duration).toBe(999);
+ });
+
+ test('it uses sineOut as the default easing function', () => {
+
+ const response = blink(document.createElement('div'), {});
+
+ expect(response.easing).toBe(sineOut);
+ });
+
+ test('it allows easing function to be overridden', () => {
+
+ const response = blink(document.createElement('div'), {
+ easing: () => 666
+ });
+
+ expect(response.easing(0)).toBe(666);
+ });
+
+ describe('css animation function', () => {
+
+ beforeEach(() => {
+
+ const div = document.createElement('div');
+ div.style.width = '100px';
+ div.style.height = '200px';
+ internals.response = blink(div, {});
+ });
+
+ test('It starts with with zeroed width and height', () => {
+
+ const css = internals.response.css(0, 1);
+ expect(css).toContain('width: 0px');
+ expect(css).toContain('height: 0px');
+ });
+
+ test('It grows to full height and 0 width in first 20%', () => {
+
+ const css = internals.response.css(0.2, 0.8);
+ expect(css).toContain('width: 0px');
+ expect(css).toContain('height: 200px');
+ });
+
+ test('It expands to full height by the end', () => {
+
+ const css = internals.response.css(1, 0);
+ expect(css).toContain('width: 100px');
+ expect(css).toContain('height: 200px');
+ });
+
+ test('It keeps element vertically centered by adjusting the margin', () => {
+
+ const css = internals.response.css(0.1, 0.9);
+ expect(css).toContain('margin: 50px 50px');
+ expect(css).toContain('height: 100px');
+ });
+
+ test('It keeps element horizontally centered by adjusting the margin', () => {
+
+ const css = internals.response.css(0.6, 0.4);
+ expect(css).toContain('margin: 0px 25px');
+ expect(css).toContain('width: 50px');
+ });
+ });
+});
diff --git a/src/lib/animations/blink.ts b/src/lib/animations/blink.ts
new file mode 100644
index 0000000..c291b06
--- /dev/null
+++ b/src/lib/animations/blink.ts
@@ -0,0 +1,25 @@
+import { sineOut } from 'svelte/easing';
+import type { AnimationConfig } from 'svelte/animate';
+
+export const blink = function blink(node: HTMLElement, params: AnimationConfig): AnimationConfig{
+
+ const originalWidth = parseFloat(getComputedStyle(node).width);
+ const originalHeight = parseFloat(getComputedStyle(node).height);
+
+ return {
+ delay: params.delay || 0,
+ duration: params.duration || 400,
+ easing: params.easing || sineOut,
+ css: (t: number): string => {
+
+ const halfWidth = originalWidth / 2;
+ const halfHeight = originalHeight / 2;
+ const height = Math.round(t <= 0.2 ? (originalHeight * t) / 0.2 : originalHeight);
+ const marginY = Math.round(t <= 0.2 ? halfHeight * (1 - t / 0.2) : 0);
+ const width = Math.round(t > 0.2 ? ((t - 0.2) / 0.8) * originalWidth : 0);
+ const marginX = Math.round(t > 0.2 ? (1 - (t - 0.2) / 0.8) * halfWidth : halfWidth);
+
+ return `width: ${width}px; height: ${height}px; margin: ${marginY}px ${marginX}px`;
+ }
+ };
+};
diff --git a/src/lib/components/actions/topic.svelte b/src/lib/components/actions/topic.svelte
new file mode 100644
index 0000000..1311d06
--- /dev/null
+++ b/src/lib/components/actions/topic.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import type { TopicAction } from '$lib/stores/action';
+ export let actions: TopicAction;
+
+ import { _ } from 'svelte-i18n';
+</script>
+
+<li>
+ <a href="/reply/{actions.id}" title={$_('header.action.reply.title')}>
+ {@html $_('header.action.reply.display')}
+ </a>
+</li>
+
+<style>
+ li {
+ display: inline;
+ margin: 5px;
+ }
+
+ a {
+ text-decoration: none;
+ line-height: 3em;
+ display: inline-block;
+ }
+</style>
diff --git a/src/lib/components/actions/topic.test.ts b/src/lib/components/actions/topic.test.ts
new file mode 100644
index 0000000..0037a7f
--- /dev/null
+++ b/src/lib/components/actions/topic.test.ts
@@ -0,0 +1,29 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import Topic from './topic.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Topic Actions component', () => {
+
+ test('Should link to reply page', () => {
+
+ const results = render(Topic, { props: {
+ actions: {
+ id: '8ebaa211-fd9b-423a-8b4f-b57622007fde'
+ }
+ } });
+
+ expect(results.getByTitle('Reply').closest('a'))
+ .toHaveAttribute('href', '/reply/8ebaa211-fd9b-423a-8b4f-b57622007fde');
+ });
+});
diff --git a/src/lib/components/author/author.svelte b/src/lib/components/author/author.svelte
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/src/lib/components/author/author.svelte
diff --git a/src/lib/components/error_block/error_block.svelte b/src/lib/components/error_block/error_block.svelte
new file mode 100644
index 0000000..d25f507
--- /dev/null
+++ b/src/lib/components/error_block/error_block.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+ export let message = null;
+
+ import { blink } from '$lib/animations/blink';
+</script>
+
+<div transition:blink>
+ <h2>{$_('error.generic.title')}</h2>
+ <p>{message || $_('error.generic.message')}</p>
+</div>
+
+<style>
+ div {
+ background: repeating-linear-gradient(-45deg, red, red 5px, black 5px, black 10px);
+ border: 5px solid red;
+ color: yellow;
+ font-family: 'ヒラギノ角ゴ ProN', 'Hiragino Kaku Gothic ProN', '游ゴシック', '游ゴシック体',
+ YuGothic, 'Yu Gothic', 'メイリオ', Meiryo, 'MS ゴシック', 'MS Gothic', HiraKakuProN-W3,
+ 'TakaoExゴシック', TakaoExGothic, 'MotoyaLCedar', 'Droid Sans Japanese', sans-serif;
+ margin: 0 10px 0 0;
+ text-align: center;
+ overflow: hidden;
+ }
+
+ h2,
+ p {
+ background-color: black;
+ font-size: 1em;
+ }
+
+ h2 {
+ text-transform: uppercase;
+ margin: 100px 5px 10px;
+ }
+
+ p {
+ margin: 10px 5px 100px;
+ }
+</style>
diff --git a/src/lib/components/error_block/error_block.test.ts b/src/lib/components/error_block/error_block.test.ts
new file mode 100644
index 0000000..d2ac458
--- /dev/null
+++ b/src/lib/components/error_block/error_block.test.ts
@@ -0,0 +1,32 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import ErrorBlock from './error_block.svelte';
+
+describe('Error Block component', () => {
+
+ test('Should display error message sent', () => {
+
+ const results = render(ErrorBlock, { props: {
+ message: 'An error has, sadly, destroyed everything.'
+ } });
+
+ expect(results.getByText('An error has, sadly, destroyed everything.'))
+ .toBeVisible();
+ });
+
+ test('Should display default error message', () => {
+
+ const results = render(ErrorBlock);
+
+ expect(results.getByText('Unknown error has occurred. Panic!'))
+ .toBeVisible();
+ });
+});
+
diff --git a/src/lib/components/footer/footer.svelte b/src/lib/components/footer/footer.svelte
new file mode 100644
index 0000000..5c171d2
--- /dev/null
+++ b/src/lib/components/footer/footer.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+
+ import LanguageSelector from '$lib/components/language_selector/language_selector.svelte';
+
+ const licenseUrl = 'https://gitlab.com/rbdr/forum/';
+</script>
+
+<footer title={$_('footer.title')}>
+ <ul>
+ <li>{@html $_('footer.license', { values: { licenseUrl } })}</li>
+ <li>{$_('footer.choose_language')}: <LanguageSelector /></li>
+ </ul>
+</footer>
+
+<style>
+ footer {
+ grid-column: col-start 1 / span 12;
+ border-top: 1px solid black;
+ }
+
+ ul {
+ padding: 0;
+ }
+
+ li {
+ display: inline;
+ margin: 5px;
+ }
+</style>
diff --git a/src/lib/components/forum/forum.svelte b/src/lib/components/forum/forum.svelte
new file mode 100644
index 0000000..b886428
--- /dev/null
+++ b/src/lib/components/forum/forum.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ export let forum;
+
+ import { _ } from 'svelte-i18n';
+ import TopicSummary from '$lib/components/topic_summary/topic_summary.svelte';
+</script>
+
+<h1>{forum.glyph} {$_(forum.label)}</h1>
+<ul>
+ {#each forum.topics as topic}
+ <TopicSummary {topic} />
+ {/each}
+</ul>
diff --git a/src/lib/components/forum/forum.test.ts b/src/lib/components/forum/forum.test.ts
new file mode 100644
index 0000000..b3ce167
--- /dev/null
+++ b/src/lib/components/forum/forum.test.ts
@@ -0,0 +1,73 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { addMessages } from 'svelte-i18n';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import Forum from './forum.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Forum component', () => {
+
+ beforeAll(() => {
+
+ addMessages('en', {
+ 'test_forums.oleo': 'Oleo'
+ });
+ });
+
+ beforeEach(() => {
+
+ internals.results = render(Forum, { props: {
+ forum: {
+ id: 'oleo',
+ glyph: '☽',
+ label: 'test_forums.oleo',
+ topics: [
+ {
+ id: '0575d375-5bea-44df-a597-bee3adda624d',
+ title: 'Very forumy topic',
+ ttl: 160 * 1000,
+ updated_at: Date.now()
+ },
+ {
+ id: 'aeeb56e4-751d-4400-8aa7-d0f3a20d4e25',
+ title: 'Only mildly forum-like',
+ ttl: 160 * 1000,
+ updated_at: Date.now()
+ }
+ ]
+ }
+ } });
+ });
+
+ test('It should display the forum glyph and label', () => {
+
+ expect(internals.results.getByText(/^\s*☽\s*Oleo\s*$/))
+ .toBeVisible();
+ });
+
+ test('It should display the topics', () => {
+
+ expect(internals.results.getByText('Very forumy topic'))
+ .toBeVisible();
+ expect(internals.results.getByText('Only mildly forum-like'))
+ .toBeVisible();
+ });
+
+ test('It should link to the topics', () => {
+
+ expect(internals.results.getByText('Very forumy topic').closest('a'))
+ .toHaveAttribute('href', '/t/0575d375-5bea-44df-a597-bee3adda624d');
+ expect(internals.results.getByText('Only mildly forum-like').closest('a'))
+ .toHaveAttribute('href', '/t/aeeb56e4-751d-4400-8aa7-d0f3a20d4e25');
+ });
+});
diff --git a/src/lib/components/forum_list/forum_list.svelte b/src/lib/components/forum_list/forum_list.svelte
new file mode 100644
index 0000000..3529de8
--- /dev/null
+++ b/src/lib/components/forum_list/forum_list.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+ export let forums;
+
+ $: sortedForums = forums.slice().sort((a, b) => a.position - b.position);
+</script>
+
+<ul>
+ {#each sortedForums as forum}
+ <li>
+ <a href="/f/{forum.id}">
+ <span aria-hidden="true" class="navigation-glyph {forum.glyph}">{forum.glyph}</span>
+ <span class="navigation-label">{$_(forum.label)}</span>
+ </a>
+ </li>
+ {/each}
+</ul>
+
+<style>
+ ul {
+ padding: 0;
+ }
+
+ li {
+ display: block;
+ text-align: left;
+ margin-bottom: 20px;
+ }
+
+ .navigation-glyph {
+ font-size: 1.5rem;
+ display: block;
+ }
+
+ .☽ {
+ font-size: 2rem;
+ }
+
+ a {
+ text-decoration: none;
+ }
+</style>
diff --git a/src/lib/components/forum_list/forum_list.test.ts b/src/lib/components/forum_list/forum_list.test.ts
new file mode 100644
index 0000000..be7d449
--- /dev/null
+++ b/src/lib/components/forum_list/forum_list.test.ts
@@ -0,0 +1,94 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import { addMessages } from 'svelte-i18n';
+
+import ForumList from './forum_list.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Forum List component', () => {
+
+ beforeAll(() => {
+
+ addMessages('en', {
+ 'test_forums.yes': 'Absolutely yes',
+ 'test_forums.no': 'No, not at all',
+ 'test_forums.maybe': 'OK, maybe...'
+ });
+ });
+
+ beforeEach(() => {
+
+ internals.results = render(ForumList, { props: {
+ forums: [
+ {
+ id: 'yes',
+ glyph: '☆',
+ label: 'test_forums.yes',
+ position: 2
+ },
+ {
+ id: 'no',
+ glyph: '◯',
+ label: 'test_forums.no',
+ position: 0
+ },
+ {
+ id: 'maybe',
+ glyph: '⏀',
+ label: 'test_forums.maybe',
+ position: 1
+ }
+ ]
+ } });
+ });
+
+ test('It should display each forum according to their position', () => {
+
+ expect(internals.results.container)
+ .toHaveTextContent(/^◯.+⏀.+☆.+$/);
+ });
+
+ test('It should translate forum labels', () => {
+
+ expect(internals.results.getByText('Absolutely yes')).toBeVisible();
+ expect(internals.results.getByText('No, not at all')).toBeVisible();
+ expect(internals.results.getByText('OK, maybe...')).toBeVisible();
+ });
+
+ test('It should display forum glyphs', () => {
+
+ expect(internals.results.getByText('☆')).toBeVisible();
+ expect(internals.results.getByText('◯')).toBeVisible();
+ expect(internals.results.getByText('⏀')).toBeVisible();
+ });
+
+ test('Label should be a permalink to the forum', () => {
+
+ expect(internals.results.getByText('Absolutely yes').closest('a'))
+ .toHaveAttribute('href', '/f/yes');
+ expect(internals.results.getByText('No, not at all').closest('a'))
+ .toHaveAttribute('href', '/f/no');
+ expect(internals.results.getByText('OK, maybe...').closest('a'))
+ .toHaveAttribute('href', '/f/maybe');
+ });
+
+ test('Glyph should be a permalink to the forum', () => {
+
+ expect(internals.results.getByText('☆').closest('a'))
+ .toHaveAttribute('href', '/f/yes');
+ expect(internals.results.getByText('◯').closest('a'))
+ .toHaveAttribute('href', '/f/no');
+ expect(internals.results.getByText('⏀').closest('a'))
+ .toHaveAttribute('href', '/f/maybe');
+ });
+});
diff --git a/src/lib/components/glyph/glyph.svelte b/src/lib/components/glyph/glyph.svelte
new file mode 100644
index 0000000..0559b98
--- /dev/null
+++ b/src/lib/components/glyph/glyph.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+ import { getGlyphHash } from '$lib/utils/glyph_hash';
+
+ export let uuid: string;
+</script>
+
+<div class="glyphicon" role="img" aria-label={$_('glyph.title')} title={$_('glyph.title')}>
+ {#each getGlyphHash(uuid) as fragment}
+ <span class={fragment.glyph} style="color: {fragment.color} ">
+ {fragment.glyph}
+ </span>
+ {/each}
+</div>
+
+<style>
+ .glyphicon {
+ border: 1px solid black;
+ display: inline-block;
+ font-size: 1.4rem;
+ height: 48px;
+ margin-top: 5px;
+ width: 48px;
+ background-color: white;
+ padding: 2px;
+ }
+
+ span {
+ display: block;
+ float: left;
+ width: 24px;
+ height: 24px;
+ text-align: center;
+ line-height: 24px;
+ }
+
+ .☽ {
+ font-size: 2rem;
+ line-height: 19px;
+ }
+</style>
diff --git a/src/lib/components/glyph/glyph.test.ts b/src/lib/components/glyph/glyph.test.ts
new file mode 100644
index 0000000..778353f
--- /dev/null
+++ b/src/lib/components/glyph/glyph.test.ts
@@ -0,0 +1,36 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import Glyph from './glyph.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Glyph component', () => {
+
+ beforeEach(() => {
+
+ internals.results = render(Glyph, { props: {
+ uuid: '9fb14ebc-bc64-400b-915f-d429ec44b8fe'
+ } });
+ });
+
+ test('Should act as an image', () => {
+
+ expect(internals.results.getByRole('img'))
+ .toBeVisible();
+ });
+
+ test('Should render 4 glyphs', () => {
+
+ expect(internals.results.getByRole('img'))
+ .toHaveTextContent(/^. . . .$/);
+ });
+});
diff --git a/src/lib/components/header/header.svelte b/src/lib/components/header/header.svelte
new file mode 100644
index 0000000..e6c9348
--- /dev/null
+++ b/src/lib/components/header/header.svelte
@@ -0,0 +1,63 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+ import { version } from '$lib/config/config';
+ import { topicActions } from '$lib/stores/actions';
+ import TopicActions from '$lib/components/actions/topic.svelte';
+</script>
+
+<header title={$_('header.title')}>
+ <ul>
+ <li>
+ <strong
+ ><a href="/" title={$_('header.long_version', { values: { version } })}
+ >{$_('header.short_version', { values: { version } })}</a
+ ></strong
+ >
+ </li>
+ <li>
+ <a href="/new" title={$_('header.action.new.title')}
+ >{@html $_('header.action.new.display')}</a
+ >
+ </li>
+ {#if $topicActions}
+ <TopicActions actions={$topicActions} />
+ {/if}
+ <li>
+ <a href="/search" title={$_('header.action.search.title')}
+ >{@html $_('header.action.search.display')}</a
+ >
+ </li>
+ <li>
+ <a href="/logout" title={$_('header.action.log_out.title')}
+ >{@html $_('header.action.log_out.display')}</a
+ >
+ </li>
+ </ul>
+</header>
+
+<style>
+ header {
+ grid-column: col-start 1 / span 12;
+ border-bottom: 1px solid black;
+ }
+
+ ul {
+ padding: 0;
+ }
+
+ li {
+ display: inline;
+ margin: 5px;
+ }
+
+ a {
+ text-decoration: none;
+ line-height: 3em;
+ display: inline-block;
+ }
+
+ strong a {
+ color: blue;
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/lib/components/header/header.test.ts b/src/lib/components/header/header.test.ts
new file mode 100644
index 0000000..4107b2d
--- /dev/null
+++ b/src/lib/components/header/header.test.ts
@@ -0,0 +1,33 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+import { enableTopicActions } from '$lib/stores/actions';
+
+jest.mock('$lib/config/config.ts');
+
+import Header from './header.svelte';
+
+describe('Header component', () => {
+
+ test('Should not display topic if action is not set', () => {
+
+ const results = render(Header);
+
+ expect(results.queryByTitle('Reply'))
+ .toBe(null);
+ });
+
+ test('Should display topic if action is set', () => {
+
+ enableTopicActions('d138d6d8-e669-42e7-995d-20a7fcc176f5');
+ const results = render(Header);
+
+ expect(results.getByTitle('Reply'))
+ .toBeVisible();
+ });
+});
diff --git a/src/lib/components/home/home.svelte b/src/lib/components/home/home.svelte
new file mode 100644
index 0000000..9473bbb
--- /dev/null
+++ b/src/lib/components/home/home.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+</script>
+
+<h1>{$_('home.title')}</h1>
+<p>{$_('home.content')}</p>
diff --git a/src/lib/components/invalid_route/invalid_route.svelte b/src/lib/components/invalid_route/invalid_route.svelte
new file mode 100644
index 0000000..a75316d
--- /dev/null
+++ b/src/lib/components/invalid_route/invalid_route.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+</script>
+
+<h1>{$_('error.invalid_url.title')}</h1>
+<p>{$_('error.invalid_url.message')}</p>
diff --git a/src/lib/components/language_selector/language_selector.svelte b/src/lib/components/language_selector/language_selector.svelte
new file mode 100644
index 0000000..64a4ef9
--- /dev/null
+++ b/src/lib/components/language_selector/language_selector.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+ import { locale, locales } from 'svelte-i18n';
+ import { getLangNameFromCode } from 'language-name-map';
+
+ $: namedLocales = $locales
+ .map((code) => ({
+ code,
+ ...getLangNameFromCode(code)
+ }))
+ .sort((a, b) => a.native - b.native);
+
+ let selected = $locale
+
+ $: {
+ locale.set(selected);
+ }
+</script>
+
+<select bind:value={selected}>
+ {#each namedLocales as namedLocale}
+ <option value={namedLocale.code}>{namedLocale.native}</option>
+ {/each}
+</select>
+
+<style>
+</style>
diff --git a/src/lib/components/language_selector/language_selector.test.ts b/src/lib/components/language_selector/language_selector.test.ts
new file mode 100644
index 0000000..74156bc
--- /dev/null
+++ b/src/lib/components/language_selector/language_selector.test.ts
@@ -0,0 +1,48 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { locale } from 'svelte-i18n';
+import { act, render } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import '$lib/i18n';
+
+import LanguageSelector from './language_selector.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Language Selector component', () => {
+
+ beforeEach(() => {
+
+ internals.results = render(LanguageSelector);
+ });
+
+ test('Should display languages in their own language', () => {
+
+ expect(internals.results.getByText('English'))
+ .toBeVisible();
+ expect(internals.results.getByText('Español'))
+ .toBeVisible();
+ });
+
+ test('Should change locale when a language is selected', async () => {
+
+ locale.subscribe((localeValue) => {
+
+ expect(localeValue).toBe('en');
+ })();
+ const spanish = internals.results.getByText('Español');
+ await userEvent.selectOptions(spanish.closest('select'), spanish);
+ await act();
+ locale.subscribe((localeValue) => {
+
+ expect(localeValue).toBe('es');
+ })();
+ });
+});
+
diff --git a/src/lib/components/loader/loader.svelte b/src/lib/components/loader/loader.svelte
new file mode 100644
index 0000000..dad0957
--- /dev/null
+++ b/src/lib/components/loader/loader.svelte
@@ -0,0 +1,5 @@
+<script lang="ts">
+ import { _ } from 'svelte-i18n';
+</script>
+
+<p>{$_('loader.message')}</p>
diff --git a/src/lib/components/post/post.svelte b/src/lib/components/post/post.svelte
new file mode 100644
index 0000000..4e6c28f
--- /dev/null
+++ b/src/lib/components/post/post.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ export let post;
+ export let index = 0;
+ export let count = 1;
+
+ import { _ } from 'svelte-i18n';
+ import Glyph from '$lib/components/glyph/glyph.svelte';
+
+ const timestampToISO = (timestamp) => new Date(timestamp).toISOString();
+</script>
+
+<aside
+ class="post-meta"
+ title={$_('post.metadata_title', { values: { count: index + 1, total: count } })}
+>
+ <Glyph uuid={post.author.id} />
+ <span class="h-card">
+ {$_('post.author_credit')}
+ <a href="/a/{post.author.handle}" class="p-nickname u-url">{post.author.handle}</a>.
+ </span>
+ <time role="presentation" class="dt-published" datetime={timestampToISO(post.created_at)}>
+ <a title={$_('post.permalink_title')} href="/p/{post.id}">
+ {timestampToISO(post.created_at)}
+ </a>
+ </time>
+ {#if post.topic}
+ <span>
+ ({$_('post.topic_location')} <a href="/t/{post.topic.id}">{post.topic.title}</a>.)
+ </span>
+ {/if}
+</aside>
+<article
+ class="e-content"
+ title={$_('post.title', {
+ values: { count: index + 1, total: count, author: post.author.handle }
+ })}
+>
+ {post.text}
+</article>
+<hr />
+
+<style>
+ .post-meta * {
+ vertical-align: top;
+ }
+
+ article {
+ white-space: pre;
+ }
+</style>
diff --git a/src/lib/components/post/post.test.ts b/src/lib/components/post/post.test.ts
new file mode 100644
index 0000000..d43583e
--- /dev/null
+++ b/src/lib/components/post/post.test.ts
@@ -0,0 +1,128 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { cleanup, render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import Post from './post.svelte';
+
+const internals = {
+ basicPost: {
+ id: 'e5a19d53-4c9a-4be8-afa5-00942ea3afa4',
+ text: 'This is an example post qwerty',
+ created_at: Date.UTC(2021, 3, 19, 6, 6, 6, 666).valueOf(),
+ author: {
+ handle: 'very_cool_user',
+ id: 'b01bdb48-4b5e-46a4-97f3-6db789bcd33b'
+ },
+ topic: {
+ id: '35d3c3eb-e486-42ef-994c-d8ab1f1e167a',
+ title: 'Parent topic, yes'
+ }
+ },
+ postWithoutTopic: {
+ id: '9e52e38e-9007-4a20-bbf1-cea4e2f950f3',
+ text: 'This is a post without a topic',
+ created_at: Date.UTC(2022, 8, 21, 4, 3, 1, 340).valueOf(),
+ author: {
+ handle: 'my_normal_user',
+ id: '121f8f97-de02-4102-b25d-f34fd619009b'
+ }
+ },
+
+ results: null
+};
+
+describe('Post component', () => {
+
+ beforeEach(() => {
+
+ internals.results = render(Post, { props: {
+ post: internals.basicPost
+ } });
+ });
+
+ test('Should display the text of the post', () => {
+
+ expect(internals.results.getByText('This is an example post qwerty')).toBeVisible();
+ });
+
+ test('Should display date of the post', () => {
+
+ expect(internals.results.getByText('2021-04-19T06:06:06.666Z'))
+ .toBeVisible();
+ });
+
+ test('Date of post should be a permalink to the post', () => {
+
+ expect(internals.results.getByText('2021-04-19T06:06:06.666Z').closest('a'))
+ .toHaveAttribute('href', '/p/e5a19d53-4c9a-4be8-afa5-00942ea3afa4');
+ });
+
+ test('Should display the glyph of the post author', () => {
+
+ const glyphicon = internals.results.getByRole('img');
+
+ expect(glyphicon)
+ .toBeVisible();
+ expect(glyphicon)
+ .toHaveTextContent(/^. . . .$/);
+ });
+
+ test('Should display author handle', () => {
+
+ expect(internals.results.getByText('very_cool_user'))
+ .toBeVisible();
+ });
+
+ test('Author handle should have a permalink to topic', () => {
+
+ expect(internals.results.getByText('very_cool_user').closest('a'))
+ .toHaveAttribute('href', '/a/very_cool_user');
+ });
+
+ test('Should display parent topic title', () => {
+
+ expect(internals.results.getByText('Parent topic, yes'))
+ .toBeVisible();
+ });
+
+ test('Parent topic title should have a permalink to topic', () => {
+
+ expect(internals.results.getByText('Parent topic, yes').closest('a'))
+ .toHaveAttribute('href', '/t/35d3c3eb-e486-42ef-994c-d8ab1f1e167a');
+ });
+
+ test('Parent topic title should have a permalink to topic', () => {
+
+ cleanup();
+ internals.results = render(Post, { props: {
+ post: internals.postWithoutTopic
+ } });
+
+ expect(internals.results.queryByText('Parent topic, yes'))
+ .toBe(null);
+ });
+
+ test('It should default to 1/1 when no index or count is passed', () => {
+
+ expect(internals.results.getByTitle('Post 1 of 1 by very_cool_user'))
+ .toBeVisible();
+ });
+
+ test('Parent topic title should have a permalink to topic', () => {
+
+ cleanup();
+ internals.results = render(Post, { props: {
+ index: 2,
+ count: 5,
+ post: internals.postWithoutTopic
+ } });
+
+ expect(internals.results.getByTitle('Post 3 of 5 by my_normal_user'))
+ .toBeVisible();
+ });
+});
diff --git a/src/lib/components/tag/tag.svelte b/src/lib/components/tag/tag.svelte
new file mode 100644
index 0000000..86d0b82
--- /dev/null
+++ b/src/lib/components/tag/tag.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ export let tag;
+
+ import { _ } from 'svelte-i18n';
+ import TopicSummary from '$lib/components/topic_summary/topic_summary.svelte';
+</script>
+
+<h1>{$_('tag.title')}: {tag.id}</h1>
+<ul>
+ {#each tag.topics as topic}
+ <TopicSummary {topic} />
+ {/each}
+</ul>
diff --git a/src/lib/components/tag/tag.test.ts b/src/lib/components/tag/tag.test.ts
new file mode 100644
index 0000000..76304e6
--- /dev/null
+++ b/src/lib/components/tag/tag.test.ts
@@ -0,0 +1,62 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import Tag from './tag.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Tag component', () => {
+
+ beforeEach(() => {
+
+ internals.results = render(Tag, { props: {
+ tag: {
+ id: 'avocado',
+ topics: [
+ {
+ id: 'eb751e7a-5777-46c3-b81b-cc66546d5157',
+ title: 'A single topic',
+ ttl: 160 * 1000,
+ updated_at: Date.now()
+ },
+ {
+ id: 'b4a5613c-237b-4147-a867-9c105d51e365',
+ title: 'And its companion',
+ ttl: 160 * 1000,
+ updated_at: Date.now()
+ }
+ ]
+ }
+ } });
+ });
+
+ test('It should display the tag title', () => {
+
+ expect(internals.results.getByText('Tag: avocado'))
+ .toBeVisible();
+ });
+
+ test('It should display the topics', () => {
+
+ expect(internals.results.getByText('A single topic'))
+ .toBeVisible();
+ expect(internals.results.getByText('And its companion'))
+ .toBeVisible();
+ });
+
+ test('It should link to the topics', () => {
+
+ expect(internals.results.getByText('A single topic').closest('a'))
+ .toHaveAttribute('href', '/t/eb751e7a-5777-46c3-b81b-cc66546d5157');
+ expect(internals.results.getByText('And its companion').closest('a'))
+ .toHaveAttribute('href', '/t/b4a5613c-237b-4147-a867-9c105d51e365');
+ });
+});
diff --git a/src/lib/components/topic/topic.svelte b/src/lib/components/topic/topic.svelte
new file mode 100644
index 0000000..06f2aeb
--- /dev/null
+++ b/src/lib/components/topic/topic.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ export let topic;
+
+ import { _ } from 'svelte-i18n';
+ import Post from '$lib/components/post/post.svelte';
+ import { readableTime } from '$lib/utils/readable_time';
+
+ $: remainingTime = topic.updated_at + topic.ttl - Date.now();
+ $: remaining = readableTime(remainingTime);
+</script>
+
+<div class="h-entry" title={$_('topic.title')}>
+ <h1 class="p-name">{topic.title}</h1>
+ <aside class="topic-meta" title={$_('topic.metadata_title')}>
+ {#if topic.forum}
+ <span class="topic-location">
+ {$_('topic.category_location')}
+ <a href="/f/{topic.forum.id}" class="p-category">
+ {topic.forum.glyph} {$_(topic.forum.label)}
+ </a>.
+ </span>
+ {/if}
+ <span class="topic-ttl">
+ <a class="u-url u-uid" title={$_('topic.permalink_title')} href="/t/{topic.id}">
+ ({$_('topic.remaining_time', {
+ values: { remaining: $_(remaining.label, { values: { count: remaining.count } }) }
+ })})
+ </a>.
+ </span>
+ </aside>
+ {#if topic.tags.length > 0}
+ <aside class="topic-tags" title={$_('topic.tags_title')}>
+ {$_('topic.tags_location')}
+ {#each topic.tags as tag}
+ <a href="/g/{tag.id}" class="p-category">
+ {tag.id}<span class="tag-weight">({tag.weight})</span>
+ </a>{' '}
+ {/each}
+ </aside>
+ {/if}
+ {#each topic.posts as post, index}
+ <Post {post} {index} count={topic.posts.length} />
+ {/each}
+</div>
diff --git a/src/lib/components/topic/topic.test.ts b/src/lib/components/topic/topic.test.ts
new file mode 100644
index 0000000..985ca72
--- /dev/null
+++ b/src/lib/components/topic/topic.test.ts
@@ -0,0 +1,168 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { addMessages } from 'svelte-i18n';
+
+import { cleanup, render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import Topic from './topic.svelte';
+
+const internals = {
+ results: null,
+ basicTopic: {
+ id: 'b1a4f8d1-4d16-4872-b391-fda6a0e9012d',
+ title: 'I sure am a test topic',
+ ttl: 160 * 1000,
+ updated_at: Date.now(),
+ forum: {
+ id: 'diversion',
+ glyph: '⏃',
+ label: 'test_forums.diversion'
+ },
+ tags: [
+ {
+ id: 'fish',
+ weight: 40
+ },
+ {
+ id: 'statue',
+ weight: 5
+ }
+ ],
+ posts: [
+ {
+ id: '413a74db-9473-4bac-8698-da9452c05854',
+ text: 'This is the first post',
+ created_at: Date.UTC(1999, 7, 1, 8, 8, 2, 111).valueOf(),
+ author: {
+ handle: 'past_user',
+ id: 'c76d3e51-76ac-4e84-a1b2-2eee9abd68b3'
+ }
+ },
+ {
+ id: '821ff177-5250-406f-9431-1a8097b35430',
+ text: 'This response came later',
+ created_at: Date.UTC(2038, 1, 2, 3, 4, 6, 789).valueOf(),
+ author: {
+ handle: 'future_user',
+ id: 'cb9307cb-77e9-4c55-bbe7-dbbf88737358'
+ }
+ }
+ ]
+ },
+ topicWithoutForum: {
+ id: '9715e9ee-0d63-4b50-b613-826ef2791728',
+ title: 'This topic, no forums',
+ ttl: 160 * 1000,
+ updated_at: Date.now(),
+ tags: [
+ {
+ id: 'cauliflower',
+ weight: 33
+ }
+ ],
+ posts: []
+ }
+};
+
+describe('Topic component', () => {
+
+ beforeAll(() => {
+
+ addMessages('en', {
+ 'test_forums.diversion': 'Diversion'
+ });
+ });
+
+ beforeEach(() => {
+
+ internals.results = render(Topic, { props: {
+ topic: internals.basicTopic
+ } });
+ });
+
+ test('Should show the topic title', () => {
+
+ expect(internals.results.getByText('I sure am a test topic'))
+ .toBeVisible();
+ });
+ test('Should display remaining time in readable format', () => {
+
+ expect(internals.results.getByText(/2 minutes remaining/))
+ .toBeVisible();
+ });
+ test('Remaining time should be a permalink to the topic', () => {
+
+ expect(internals.results.getByText(/2 minutes remaining/).closest('a'))
+ .toHaveAttribute('href', '/t/b1a4f8d1-4d16-4872-b391-fda6a0e9012d');
+ });
+
+ test('Should show text for all posts', () => {
+
+ expect(internals.results.getByText('This is the first post'))
+ .toBeVisible();
+ expect(internals.results.getByText('This response came later'))
+ .toBeVisible();
+ });
+
+ test('Should send index and count to posts', () => {
+
+ expect(internals.results.getByTitle('Post 1 of 2 by past_user'))
+ .toBeVisible();
+ expect(internals.results.getByTitle('Post 2 of 2 by future_user'))
+ .toBeVisible();
+ });
+
+ describe('Forum link', () => {
+
+ test('Should show forum if the post has one', () => {
+
+ expect(internals.results.getByText(/^\s*⏃\s*Diversion\s*$/))
+ .toBeVisible();
+ });
+
+ test('Forum text should be a permalink to the forum', () => {
+
+ expect(internals.results.getByText(/^\s*⏃\s*Diversion\s*$/).closest('a'))
+ .toHaveAttribute('href', '/f/diversion');
+ });
+
+ test('Should not show forum if the post doesn\'t have one', () => {
+
+ cleanup();
+ internals.results = render(Topic, { props: {
+ topic: internals.topicWithoutForum
+ } });
+
+ expect(internals.results.queryByText(/^\s*⏃\s*Diversion\s*$/))
+ .toBe(null);
+ });
+ });
+
+ describe('Tag listing', () => {
+
+ test('Should show topic tags', () => {
+
+ expect(internals.results.getByText('fish'))
+ .toBeVisible();
+ expect(internals.results.getByText('fish'))
+ .toHaveTextContent('fish(40)');
+ expect(internals.results.getByText('statue'))
+ .toBeVisible();
+ expect(internals.results.getByText('statue'))
+ .toHaveTextContent('statue(5)');
+ });
+
+ test('Tag text should be a permalink to the tag', () => {
+
+ expect(internals.results.getByText('fish').closest('a'))
+ .toHaveAttribute('href', '/g/fish');
+ expect(internals.results.getByText('statue').closest('a'))
+ .toHaveAttribute('href', '/g/statue');
+ });
+ });
+});
diff --git a/src/lib/components/topic_summary/topic_summary.svelte b/src/lib/components/topic_summary/topic_summary.svelte
new file mode 100644
index 0000000..bfcf8ab
--- /dev/null
+++ b/src/lib/components/topic_summary/topic_summary.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ export let topic;
+
+ import { _ } from 'svelte-i18n';
+ import { readableTime } from '$lib/utils/readable_time';
+
+ $: remainingTime = topic.updated_at + topic.ttl - Date.now();
+ $: remaining = readableTime(remainingTime);
+</script>
+
+<li class="h-entry" title={$_('topic.title')}>
+ <span class="p-name">
+ <a class="u-url u-uid" title={$_('topic.permalink_title')} href="/t/{topic.id}">
+ {topic.title}
+ </a></span>
+ <span class="topic-ttl">({$_('topic.remaining_time', {
+ values: { remaining: $_(remaining.label, { values: { count: remaining.count } }) }
+ })})
+ </span>
+</li>
+
+<style>
+</style>
diff --git a/src/lib/components/topic_summary/topic_summary.test.ts b/src/lib/components/topic_summary/topic_summary.test.ts
new file mode 100644
index 0000000..a4c46bf
--- /dev/null
+++ b/src/lib/components/topic_summary/topic_summary.test.ts
@@ -0,0 +1,47 @@
+/**
+ * @jest-environment jsdom
+ */
+
+import '@testing-library/jest-dom/extend-expect';
+
+import { render } from '@testing-library/svelte';
+import '$lib/i18n';
+
+import TopicSummary from './topic_summary.svelte';
+
+const internals = {
+ results: null
+};
+
+describe('Topic Summary component', () => {
+
+ beforeEach(() => {
+
+ internals.results = render(TopicSummary, { props: {
+ topic: {
+ id: 'ea2431c8-5c1c-4ed0-907a-45e012696ab8',
+ title: 'I sure am a test topic',
+ ttl: 160 * 1000,
+ updated_at: Date.now()
+ }
+ } });
+ });
+
+ test('It should display the title', () => {
+
+ expect(internals.results.getByText('I sure am a test topic'))
+ .toBeVisible();
+ });
+
+ test('Topic title should be a permalink', () => {
+
+ expect(internals.results.getByText('I sure am a test topic').closest('a'))
+ .toHaveAttribute('href', '/t/ea2431c8-5c1c-4ed0-907a-45e012696ab8');
+ });
+
+ test('It should display remaining time in readable format', () => {
+
+ expect(internals.results.getByText(/2 minutes remaining/))
+ .toBeVisible();
+ });
+});
diff --git a/src/lib/config/__mocks__/config.ts b/src/lib/config/__mocks__/config.ts
new file mode 100644
index 0000000..448e1db
--- /dev/null
+++ b/src/lib/config/__mocks__/config.ts
@@ -0,0 +1,15 @@
+export const apollo = {
+ uri: 'http://127.0.0.1:1234/graphql',
+ name: 'COOL_APP',
+ version: '9.9.9',
+ defaultOptions: {
+ watchQuery: {
+ fetchPolicy: 'no-cache'
+ },
+ query: {
+ fetchPolicy: 'no-cache'
+ }
+ }
+};
+
+export const version = '9.9.9';
diff --git a/src/lib/config/apollo.ts b/src/lib/config/apollo.ts
new file mode 100644
index 0000000..a3820ed
--- /dev/null
+++ b/src/lib/config/apollo.ts
@@ -0,0 +1,14 @@
+import fetch from 'cross-fetch';
+import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client/core';
+import { apollo as apolloConfig } from './config';
+
+const cache = new InMemoryCache();
+
+export const client = new ApolloClient({
+ cache,
+ link: new HttpLink({
+ uri: apolloConfig.uri,
+ fetch
+ }),
+ ...apolloConfig
+});
diff --git a/src/lib/config/config.ts b/src/lib/config/config.ts
new file mode 100644
index 0000000..1ec5ab2
--- /dev/null
+++ b/src/lib/config/config.ts
@@ -0,0 +1,17 @@
+const internals = {
+ version: '1.0.0'
+};
+
+/*
+ * The main configuration object for the Forum frontend. These values
+ * are calculated during compile time and need to be set in a .env
+ * file, otherwise it won't work.
+ */
+
+export const apollo = {
+ uri: import.meta.env.VITE_APOLLO_SERVER,
+ name: 'forum',
+ version: internals.version
+};
+
+export const version = internals.version;
diff --git a/src/lib/config/env.dist b/src/lib/config/env.dist
new file mode 100644
index 0000000..1f3ff0c
--- /dev/null
+++ b/src/lib/config/env.dist
@@ -0,0 +1 @@
+VITE_APOLLO_SERVER=http://location_of_apollo_server
diff --git a/src/lib/data/queries.ts b/src/lib/data/queries.ts
new file mode 100644
index 0000000..7364c0f
--- /dev/null
+++ b/src/lib/data/queries.ts
@@ -0,0 +1,90 @@
+import { gql } from '@apollo/client/core';
+
+export const GET_FORUMS = gql`
+ query GetForums {
+ forums {
+ id
+ glyph
+ label
+ position
+ }
+ }
+`;
+
+export const GET_FORUM = gql`
+ query GetForum($id: ID!) {
+ forum(id: $id) {
+ id
+ glyph
+ label
+ position
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ }
+ }
+ }
+`;
+
+export const GET_TAG = gql`
+ query GetTag($id: ID!) {
+ tag(id: $id) {
+ id
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ }
+ }
+ }
+`;
+
+export const GET_TOPIC = gql`
+ query GetTopic($id: ID!) {
+ topic(id: $id) {
+ id
+ title
+ updated_at
+ ttl
+ forum {
+ id
+ glyph
+ label
+ }
+ tags {
+ id
+ weight
+ }
+ posts {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ }
+ }
+ }
+ }
+`;
+
+export const GET_POST = gql`
+ query GetPost($id: ID!) {
+ post(id: $id) {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ }
+ topic {
+ id
+ title
+ }
+ }
+ }
+`;
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts
new file mode 100644
index 0000000..5dd533a
--- /dev/null
+++ b/src/lib/i18n.ts
@@ -0,0 +1,12 @@
+import { addMessages, getLocaleFromNavigator, init } from 'svelte-i18n';
+
+import en from './translations/en.json';
+import es from './translations/es.json';
+
+addMessages('en', en);
+addMessages('es', es);
+
+init({
+ fallbackLocale: 'en',
+ initialLocale: getLocaleFromNavigator().replace(/-[A-Z]{2}$/, '')
+});
diff --git a/src/lib/stores/actions.test.ts b/src/lib/stores/actions.test.ts
new file mode 100644
index 0000000..c650536
--- /dev/null
+++ b/src/lib/stores/actions.test.ts
@@ -0,0 +1,32 @@
+import { enableTopicActions, disableTopicActions, topicActions } from './actions';
+
+describe('Topic actions and state', () => {
+
+ test('There should be no topic actions by default', () => {
+
+ topicActions.subscribe((actions) => {
+
+ expect(actions).toBe(undefined);
+ })();
+ });
+
+ test('enableTopicActions should set the topic id', () => {
+
+ enableTopicActions('free_hat');
+ topicActions.subscribe((actions) => {
+
+ expect(actions).toEqual({
+ id: 'free_hat'
+ });
+ })();
+ });
+
+ test('disableTopicActions should unset the topic id', () => {
+
+ disableTopicActions();
+ topicActions.subscribe((actions) => {
+
+ expect(actions).toEqual(undefined);
+ })();
+ });
+});
diff --git a/src/lib/stores/actions.ts b/src/lib/stores/actions.ts
new file mode 100644
index 0000000..95702dc
--- /dev/null
+++ b/src/lib/stores/actions.ts
@@ -0,0 +1,41 @@
+import { derived, writable } from 'svelte/store';
+import type { Readable, Writable } from 'svelte/store';
+
+export type Actions = {
+ topic?: TopicAction
+};
+
+export type TopicAction = {
+ id: string
+};
+
+/*
+ * This is a store to set the actions in the top header.
+ */
+
+const actions: Writable<Actions> = writable({});
+
+export const enableTopicActions = (id: string) => {
+
+ actions.update((actionsValue: Actions): Actions => {
+
+ actionsValue.topic = {
+ id
+ };
+ return actionsValue;
+ });
+};
+
+export const disableTopicActions = () => {
+
+ actions.update((actionsValue): Actions => {
+
+ delete actionsValue.topic;
+ return actionsValue;
+ });
+};
+
+export const topicActions: Readable<TopicAction> = derived(
+ actions,
+ ($actions) => $actions.topic
+);
diff --git a/src/lib/stores/apollo.ts b/src/lib/stores/apollo.ts
new file mode 100644
index 0000000..12463c3
--- /dev/null
+++ b/src/lib/stores/apollo.ts
@@ -0,0 +1,64 @@
+import { ApolloError } from '@apollo/client/core';
+import { readable } from 'svelte/store';
+import { client } from '$lib/config/apollo';
+import type { DocumentNode, ApolloQueryResult } from '@apollo/client/core';
+
+import type { Readable } from 'svelte/store';
+
+/*
+ * This is a generic store for use with apollo
+ */
+
+type ApolloStoreConfiguration<Type> = {
+ key: string,
+ query: DocumentNode,
+ initialValue?: Type | void
+ variables?: object
+};
+
+type ApolloStoreState<Type> = {
+ loading: boolean,
+ data: Type | void,
+ error: Error | void
+};
+
+export const store = function store<Type>({ key, query, initialValue = null, variables = {} }: ApolloStoreConfiguration<Type>): Readable<ApolloStoreState<Type>> {
+
+ const initialState: ApolloStoreState<Type> = {
+ loading: true,
+ data: initialValue,
+ error: undefined
+ };
+
+ return readable(
+ initialState,
+ (set) => {
+
+ const handleError = function (error: Error) {
+
+ return set({
+ loading: false,
+ data: initialValue,
+ error
+ });
+ };
+
+ client.watchQuery({ query, variables }).subscribe(
+ (result: ApolloQueryResult<Type>) => {
+
+ if (result.errors) {
+ const error = new ApolloError({ graphQLErrors: result.errors });
+ return handleError(error);
+ }
+
+ set({
+ loading: false,
+ data: result.data[key],
+ error: undefined
+ });
+ },
+ (error: Error) => handleError(error)
+ );
+ }
+ );
+};
diff --git a/src/lib/stores/forums.test.ts b/src/lib/stores/forums.test.ts
new file mode 100644
index 0000000..5a8c723
--- /dev/null
+++ b/src/lib/stores/forums.test.ts
@@ -0,0 +1,570 @@
+import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact';
+import { resolve } from 'path';
+
+import { resolveAfter } from '$lib/utils/resolve_after';
+
+const { eachLike, like } = Matchers;
+
+jest.mock('$lib/config/config.ts');
+
+import { getForum, getForums } from './forums';
+
+const internals = {
+ provider: null
+};
+
+describe('Forums store pact', () => {
+
+ beforeAll(async () => {
+
+ internals.provider = new Pact({
+ port: 1234,
+ dir: resolve(process.cwd(), 'pacts'),
+ consumer: 'ForumClient',
+ provider: 'ForumServer',
+ pactfileWriteMode: 'update'
+ });
+
+ await internals.provider.setup();
+ });
+
+ afterEach(() => internals.provider.verify());
+ afterAll(() => internals.provider.finalize());
+
+ describe('When there\'s data', () => {
+
+ describe('GetForums', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s data')
+ .uponReceiving('a request to list the forums')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForums')
+ .withQuery(
+ `query GetForums {
+ forums {
+ id
+ glyph
+ label
+ position
+ __typename
+ }
+ }`
+ )
+ .withVariables({})
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ forums: eachLike({
+ id: like('butter'),
+ glyph: like('⌘'),
+ label: like('test_forums.butter'),
+ position: like(1)
+ })
+ }
+ }
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the forums', async () => {
+
+ const forums = getForums();
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forums.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toEqual(expect.arrayContaining([{
+ id: 'butter',
+ glyph: '⌘',
+ label: 'test_forums.butter',
+ position: 1
+ }]));
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+
+ describe('GetForum', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s data')
+ .uponReceiving('a request to get a single forum')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForum')
+ .withQuery(
+ `query GetForum($id: ID!) {
+ forum(id: $id) {
+ id
+ glyph
+ label
+ position
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'freezer'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ forum: like({
+ id: 'freezer',
+ glyph: like('✭'),
+ label: like('test_forums.freezer'),
+ position: like(3),
+ topics: eachLike({
+ id: like('629de02c-151a-4db7-bb86-43b2add8a15a'),
+ title: like('Very pacty topic'),
+ updated_at: like(1619954611616),
+ ttl: like(3601)
+ })
+ })
+ }
+ }
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the forum', async () => {
+
+ const forum = getForum('freezer');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forum.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data.id).toBe('freezer');
+ expect(response.data.glyph).toBe('✭');
+ expect(response.data.label).toBe('test_forums.freezer');
+ expect(response.data.position).toBe(3);
+ expect(response.data.topics).toEqual(expect.arrayContaining([{
+ id: '629de02c-151a-4db7-bb86-43b2add8a15a',
+ title: 'Very pacty topic',
+ updated_at: 1619954611616,
+ ttl: 3601
+ }]));
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s no data', () => {
+
+ describe('GetForums', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s no data')
+ .uponReceiving('a request to list the forums')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForums')
+ .withQuery(
+ `query GetForums {
+ forums {
+ id
+ glyph
+ label
+ position
+ __typename
+ }
+ }`
+ )
+ .withVariables({})
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ forums: []
+ }
+ }
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the forums', async () => {
+
+ const forums = getForums();
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forums.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+
+ describe('GetForum', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s no data')
+ .uponReceiving('a request to get a single forum')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForum')
+ .withQuery(
+ `query GetForum($id: ID!) {
+ forum(id: $id) {
+ id
+ glyph
+ label
+ position
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'freezer'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ forum: null
+ }
+ }
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the forum', async () => {
+
+ const forum = getForum('freezer');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forum.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s a server error', () => {
+
+ describe('GetForums', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s a server error')
+ .uponReceiving('a request to list the forums')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForums')
+ .withQuery(
+ `query GetForums {
+ forums {
+ id
+ glyph
+ label
+ position
+ __typename
+ }
+ }`
+ )
+ .withVariables({})
+ .willRespondWith({
+ status: 500
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const forums = getForums();
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forums.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBeInstanceOf(Error);
+ });
+ });
+
+ describe('GetForum', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s a server error')
+ .uponReceiving('a request to get a single forum')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForum')
+ .withQuery(
+ `query GetForum($id: ID!) {
+ forum(id: $id) {
+ id
+ glyph
+ label
+ position
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'freezer'
+ })
+ .willRespondWith({
+ status: 500
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const forum = getForum('freezer');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forum.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBeInstanceOf(Error);
+ });
+ });
+ });
+
+ describe('When there\'s an error in the response', () => {
+
+ describe('GetForums', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s an error in the response')
+ .uponReceiving('a request to list the forums')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForums')
+ .withQuery(
+ `query GetForums {
+ forums {
+ id
+ glyph
+ label
+ position
+ __typename
+ }
+ }`
+ )
+ .withVariables({})
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ errors: eachLike({
+ message: like('An error occurred when fetching forums')
+ })
+ }
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const forums = getForums();
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forums.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBeInstanceOf(Array);
+ expect(response.data.length).toBe(0);
+ expect(response.loading).toBe(false);
+ expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{
+ message: 'An error occurred when fetching forums'
+ }]));
+ });
+ });
+
+ describe('GetForum', () => {
+
+ beforeAll(async () => {
+
+ const forumQuery = new GraphQLInteraction()
+ .given('there\'s an error in the response')
+ .uponReceiving('a request to get a single forum')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetForum')
+ .withQuery(
+ `query GetForum($id: ID!) {
+ forum(id: $id) {
+ id
+ glyph
+ label
+ position
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'freezer'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ errors: eachLike({
+ message: like('An error occurred when fetching the forum')
+ })
+ }
+ });
+ return await internals.provider.addInteraction(forumQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const forum = getForum('freezer');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ forum.subscribe((forumsValue) => {
+
+ response = forumsValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{
+ message: 'An error occurred when fetching the forum'
+ }]));
+ });
+ });
+ });
+});
diff --git a/src/lib/stores/forums.ts b/src/lib/stores/forums.ts
new file mode 100644
index 0000000..cb62b5a
--- /dev/null
+++ b/src/lib/stores/forums.ts
@@ -0,0 +1,5 @@
+import { store } from './apollo';
+import { GET_FORUM, GET_FORUMS } from '$lib/data/queries';
+
+export const getForum = (id: string) => store({ key: 'forum', query: GET_FORUM, variables: { id } });
+export const getForums = () => store({ key: 'forums', query: GET_FORUMS, initialValue: [] });
diff --git a/src/lib/stores/posts.test.ts b/src/lib/stores/posts.test.ts
new file mode 100644
index 0000000..afc22f3
--- /dev/null
+++ b/src/lib/stores/posts.test.ts
@@ -0,0 +1,339 @@
+import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact';
+import { resolve } from 'path';
+
+import { resolveAfter } from '$lib/utils/resolve_after';
+
+const { eachLike, like } = Matchers;
+
+jest.mock('$lib/config/config.ts');
+
+import { getPost } from './posts';
+
+const internals = {
+ provider: null
+};
+
+describe('Posts store pact', () => {
+
+ beforeAll(async () => {
+
+ internals.provider = new Pact({
+ port: 1234,
+ dir: resolve(process.cwd(), 'pacts'),
+ consumer: 'ForumClient',
+ provider: 'ForumServer',
+ pactfileWriteMode: 'update'
+ });
+
+ await internals.provider.setup();
+ });
+
+ afterEach(() => internals.provider.verify());
+ afterAll(() => internals.provider.finalize());
+
+ describe('When there\'s data', () => {
+
+ describe('GetPost', () => {
+
+ beforeAll(async () => {
+
+ const postQuery = new GraphQLInteraction()
+ .given('there\'s data')
+ .uponReceiving('a request to get a single post')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetPost')
+ .withQuery(
+ `query GetPost($id: ID!) {
+ post(id: $id) {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ topic {
+ id
+ title
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '8f75eba5-6989-4dd3-b466-e464546ce374'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ post: like({
+ id: like('8f75eba5-6989-4dd3-b466-e464546ce374'),
+ text: like('This is a very pacty post'),
+ created_at: like(1619976194937),
+ author: like({
+ id: like('a805b3de-cac4-451c-a1e6-f078869c9db9'),
+ handle: like('pacts_person')
+ }),
+ topic: like({
+ id: like('5c283ce1-0470-4b98-86f5-1fec9a22c9ac'),
+ title: like('The parent pacts topic')
+ })
+ })
+ }
+ }
+ });
+ return await internals.provider.addInteraction(postQuery);
+ });
+
+ test('it returns the post', async () => {
+
+ const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ post.subscribe((postValue) => {
+
+ response = postValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toEqual({
+ id: '8f75eba5-6989-4dd3-b466-e464546ce374',
+ text: 'This is a very pacty post',
+ created_at: 1619976194937,
+ author: {
+ id: 'a805b3de-cac4-451c-a1e6-f078869c9db9',
+ handle: 'pacts_person'
+ },
+ topic: {
+ id: '5c283ce1-0470-4b98-86f5-1fec9a22c9ac',
+ title: 'The parent pacts topic'
+ }
+ });
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s no data', () => {
+
+ describe('GetPost', () => {
+
+ beforeAll(async () => {
+
+ const postQuery = new GraphQLInteraction()
+ .given('there\'s no data')
+ .uponReceiving('a request to get a single post')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetPost')
+ .withQuery(
+ `query GetPost($id: ID!) {
+ post(id: $id) {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ topic {
+ id
+ title
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '8f75eba5-6989-4dd3-b466-e464546ce374'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ post: null
+ }
+ }
+ });
+ return await internals.provider.addInteraction(postQuery);
+ });
+
+ test('it returns the post', async () => {
+
+ const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ post.subscribe((postValue) => {
+
+ response = postValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s a server error', () => {
+
+ describe('GetPost', () => {
+
+ beforeAll(async () => {
+
+ const postQuery = new GraphQLInteraction()
+ .given('there\'s a server error')
+ .uponReceiving('a request to get a single post')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetPost')
+ .withQuery(
+ `query GetPost($id: ID!) {
+ post(id: $id) {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ topic {
+ id
+ title
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '8f75eba5-6989-4dd3-b466-e464546ce374'
+ })
+ .willRespondWith({
+ status: 500
+ });
+ return await internals.provider.addInteraction(postQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ post.subscribe((postValue) => {
+
+ response = postValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBeInstanceOf(Error);
+ });
+ });
+ });
+
+ describe('When there\'s an error in the response', () => {
+
+ describe('GetPost', () => {
+
+ beforeAll(async () => {
+
+ const postQuery = new GraphQLInteraction()
+ .given('there\'s an error in the response')
+ .uponReceiving('a request to get a single post')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetPost')
+ .withQuery(
+ `query GetPost($id: ID!) {
+ post(id: $id) {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ topic {
+ id
+ title
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '8f75eba5-6989-4dd3-b466-e464546ce374'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ errors: eachLike({
+ message: like('An error occurred when fetching the post')
+ })
+ }
+ });
+ return await internals.provider.addInteraction(postQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const post = getPost('8f75eba5-6989-4dd3-b466-e464546ce374');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ post.subscribe((postValue) => {
+
+ response = postValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{
+ message: 'An error occurred when fetching the post'
+ }]));
+ });
+ });
+ });
+});
diff --git a/src/lib/stores/posts.ts b/src/lib/stores/posts.ts
new file mode 100644
index 0000000..0d3dec3
--- /dev/null
+++ b/src/lib/stores/posts.ts
@@ -0,0 +1,4 @@
+import { store } from './apollo';
+import { GET_POST } from '$lib/data/queries';
+
+export const getPost = (id: string) => store({ key: 'post', query: GET_POST, variables: { id } });
diff --git a/src/lib/stores/tags.test.ts b/src/lib/stores/tags.test.ts
new file mode 100644
index 0000000..9d3eb50
--- /dev/null
+++ b/src/lib/stores/tags.test.ts
@@ -0,0 +1,311 @@
+import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact';
+import { resolve } from 'path';
+
+import { resolveAfter } from '$lib/utils/resolve_after';
+
+const { eachLike, like } = Matchers;
+
+jest.mock('$lib/config/config.ts');
+
+import { getTag } from './tags';
+
+const internals = {
+ provider: null
+};
+
+describe('Tags store pact', () => {
+
+ beforeAll(async () => {
+
+ internals.provider = new Pact({
+ port: 1234,
+ dir: resolve(process.cwd(), 'pacts'),
+ consumer: 'ForumClient',
+ provider: 'ForumServer',
+ pactfileWriteMode: 'update'
+ });
+
+ await internals.provider.setup();
+ });
+
+ afterEach(() => internals.provider.verify());
+ afterAll(() => internals.provider.finalize());
+
+ describe('When there\'s data', () => {
+
+ describe('GetTag', () => {
+
+ beforeAll(async () => {
+
+ const tagQuery = new GraphQLInteraction()
+ .given('there\'s data')
+ .uponReceiving('a request to get a single tag')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTag')
+ .withQuery(
+ `query GetTag($id: ID!) {
+ tag(id: $id) {
+ id
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'pineapple'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ tag: {
+ id: like('pineapple'),
+ topics: eachLike({
+ id: like('cd038ae7-e8b4-4e38-9543-3d697e69ac34'),
+ title: like('This topic is about pineapples'),
+ updated_at: like(1619978944077),
+ ttl: like(3555)
+ })
+ }
+ }
+ }
+ });
+ return await internals.provider.addInteraction(tagQuery);
+ });
+
+ test('it returns the tag', async () => {
+
+ const tag = getTag('pineapple');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ tag.subscribe((tagValue) => {
+
+ response = tagValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toEqual({
+ id: 'pineapple',
+ topics: [{
+ id: 'cd038ae7-e8b4-4e38-9543-3d697e69ac34',
+ title: 'This topic is about pineapples',
+ updated_at: 1619978944077,
+ ttl: 3555
+ }]
+ });
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s no data', () => {
+
+ describe('GetTag', () => {
+
+ beforeAll(async () => {
+
+ const tagQuery = new GraphQLInteraction()
+ .given('there\'s no data')
+ .uponReceiving('a request to get a single tag')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTag')
+ .withQuery(
+ `query GetTag($id: ID!) {
+ tag(id: $id) {
+ id
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'pineapple'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ tag: null
+ }
+ }
+ });
+ return await internals.provider.addInteraction(tagQuery);
+ });
+
+ test('it returns the tag', async () => {
+
+ const tag = getTag('pineapple');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ tag.subscribe((tagValue) => {
+
+ response = tagValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s a server error', () => {
+
+ describe('GetTag', () => {
+
+ beforeAll(async () => {
+
+ const tagQuery = new GraphQLInteraction()
+ .given('there\'s a server error')
+ .uponReceiving('a request to get a single tag')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTag')
+ .withQuery(
+ `query GetTag($id: ID!) {
+ tag(id: $id) {
+ id
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'pineapple'
+ })
+ .willRespondWith({
+ status: 500
+ });
+ return await internals.provider.addInteraction(tagQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const tag = getTag('pineapple');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ tag.subscribe((tagValue) => {
+
+ response = tagValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBeInstanceOf(Error);
+ });
+ });
+ });
+
+ describe('When there\'s an error in the response', () => {
+
+ describe('GetTag', () => {
+
+ beforeAll(async () => {
+
+ const tagQuery = new GraphQLInteraction()
+ .given('there\'s an error in the response')
+ .uponReceiving('a request to get a single tag')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTag')
+ .withQuery(
+ `query GetTag($id: ID!) {
+ tag(id: $id) {
+ id
+ topics {
+ id
+ title
+ updated_at
+ ttl
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: 'pineapple'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ errors: eachLike({
+ message: like('An error occurred when fetching the tag')
+ })
+ }
+ });
+ return await internals.provider.addInteraction(tagQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const tag = getTag('pineapple');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ tag.subscribe((tagValue) => {
+
+ response = tagValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{
+ message: 'An error occurred when fetching the tag'
+ }]));
+ });
+ });
+ });
+});
diff --git a/src/lib/stores/tags.ts b/src/lib/stores/tags.ts
new file mode 100644
index 0000000..79f017e
--- /dev/null
+++ b/src/lib/stores/tags.ts
@@ -0,0 +1,4 @@
+import { store } from './apollo';
+import { GET_TAG } from '$lib/data/queries';
+
+export const getTag = (id: string) => store({ key: 'tag', query: GET_TAG, variables: { id } });
diff --git a/src/lib/stores/topics.test.ts b/src/lib/stores/topics.test.ts
new file mode 100644
index 0000000..2d2a2e7
--- /dev/null
+++ b/src/lib/stores/topics.test.ts
@@ -0,0 +1,413 @@
+import { GraphQLInteraction, Pact, Matchers } from '@pact-foundation/pact';
+import { resolve } from 'path';
+
+import { resolveAfter } from '$lib/utils/resolve_after';
+
+const { eachLike, like } = Matchers;
+
+jest.mock('$lib/config/config.ts');
+
+import { getTopic } from './topics';
+
+const internals = {
+ provider: null
+};
+
+describe('Topics store pact', () => {
+
+ beforeAll(async () => {
+
+ internals.provider = new Pact({
+ port: 1234,
+ dir: resolve(process.cwd(), 'pacts'),
+ consumer: 'ForumClient',
+ provider: 'ForumServer',
+ pactfileWriteMode: 'update'
+ });
+
+ await internals.provider.setup();
+ });
+
+ afterEach(() => internals.provider.verify());
+ afterAll(() => internals.provider.finalize());
+
+ describe('When there\'s data', () => {
+
+ describe('GetTopic', () => {
+
+ beforeAll(async () => {
+
+ const topicQuery = new GraphQLInteraction()
+ .given('there\'s data')
+ .uponReceiving('a request to get a single topic')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTopic')
+ .withQuery(
+ `query GetTopic($id: ID!) {
+ topic(id: $id) {
+ id
+ title
+ updated_at
+ ttl
+ forum {
+ id
+ glyph
+ label
+ __typename
+ }
+ tags {
+ id
+ weight
+ __typename
+ }
+ posts {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ topic: {
+ id: like('0b58959d-d448-4a4e-84b6-35e5ac0028d1'),
+ title: like('The pacty topic of the day'),
+ updated_at: like(1619979888906),
+ ttl: like(3399),
+ forum: {
+ id: like('cucumber'),
+ glyph: like('✽'),
+ label: like('test_forums.cucumber')
+ },
+ tags: eachLike({
+ id: like('skunk'),
+ weight: like(44)
+ }),
+ posts: eachLike({
+ id: like('ed93530e-6f9c-4701-91ef-14f9e0ed3e26'),
+ text: like('The content of this post is very relevant'),
+ created_at: like(1619979889798),
+ author: like({
+ id: like('07fb2ba0-0945-464a-b215-873296710c8c'),
+ handle: like('cucumber_fan92')
+ })
+ })
+ }
+ }
+ }
+ });
+ return await internals.provider.addInteraction(topicQuery);
+ });
+
+ test('it returns the topic', async () => {
+
+ const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ topic.subscribe((topicValue) => {
+
+ response = topicValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toEqual({
+ id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1',
+ title: 'The pacty topic of the day',
+ updated_at: 1619979888906,
+ ttl: 3399,
+ forum: {
+ id: 'cucumber',
+ glyph: '✽',
+ label: 'test_forums.cucumber'
+ },
+ tags: [{
+ id: 'skunk',
+ weight: 44
+ }],
+ posts: [{
+ id: 'ed93530e-6f9c-4701-91ef-14f9e0ed3e26',
+ text: 'The content of this post is very relevant',
+ created_at: 1619979889798,
+ author: {
+ id: '07fb2ba0-0945-464a-b215-873296710c8c',
+ handle: 'cucumber_fan92'
+ }
+ }]
+ });
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s no data', () => {
+
+ describe('GetTopic', () => {
+
+ beforeAll(async () => {
+
+ const topicQuery = new GraphQLInteraction()
+ .given('there\'s no data')
+ .uponReceiving('a request to get a single topic')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTopic')
+ .withQuery(
+ `query GetTopic($id: ID!) {
+ topic(id: $id) {
+ id
+ title
+ updated_at
+ ttl
+ forum {
+ id
+ glyph
+ label
+ __typename
+ }
+ tags {
+ id
+ weight
+ __typename
+ }
+ posts {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ data: {
+ topic: null
+ }
+ }
+ });
+ return await internals.provider.addInteraction(topicQuery);
+ });
+
+ test('it returns the topic', async () => {
+
+ const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ topic.subscribe((topicValue) => {
+
+ response = topicValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBe(undefined);
+ });
+ });
+ });
+
+ describe('When there\'s a server error', () => {
+
+ describe('GetTopic', () => {
+
+ beforeAll(async () => {
+
+ const topicQuery = new GraphQLInteraction()
+ .given('there\'s a server error')
+ .uponReceiving('a request to get a single topic')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTopic')
+ .withQuery(
+ `query GetTopic($id: ID!) {
+ topic(id: $id) {
+ id
+ title
+ updated_at
+ ttl
+ forum {
+ id
+ glyph
+ label
+ __typename
+ }
+ tags {
+ id
+ weight
+ __typename
+ }
+ posts {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1'
+ })
+ .willRespondWith({
+ status: 500
+ });
+ return await internals.provider.addInteraction(topicQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ topic.subscribe((topicValue) => {
+
+ response = topicValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error).toBeInstanceOf(Error);
+ });
+ });
+ });
+
+ describe('When there\'s an error in the response', () => {
+
+ describe('GetTopic', () => {
+
+ beforeAll(async () => {
+
+ const topicQuery = new GraphQLInteraction()
+ .given('there\'s an error in the response')
+ .uponReceiving('a request to get a single topic')
+ .withRequest({
+ path: '/graphql',
+ method: 'POST'
+ })
+ .withOperation('GetTopic')
+ .withQuery(
+ `query GetTopic($id: ID!) {
+ topic(id: $id) {
+ id
+ title
+ updated_at
+ ttl
+ forum {
+ id
+ glyph
+ label
+ __typename
+ }
+ tags {
+ id
+ weight
+ __typename
+ }
+ posts {
+ id
+ text
+ created_at
+ author {
+ id
+ handle
+ __typename
+ }
+ __typename
+ }
+ __typename
+ }
+ }`
+ )
+ .withVariables({
+ id: '0b58959d-d448-4a4e-84b6-35e5ac0028d1'
+ })
+ .willRespondWith({
+ status: 200,
+ headers: {
+ 'Content-Type': 'application/json; charset=utf-8'
+ },
+ body: {
+ errors: eachLike({
+ message: like('An error occurred when fetching the topic')
+ })
+ }
+ });
+ return await internals.provider.addInteraction(topicQuery);
+ });
+
+ test('it returns the error', async () => {
+
+ const topic = getTopic('0b58959d-d448-4a4e-84b6-35e5ac0028d1');
+ const { counter, promise: resolveAfterTwo } = resolveAfter(2);
+ let response = null;
+ topic.subscribe((topicValue) => {
+
+ response = topicValue;
+ counter();
+ });
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(true);
+ expect(response.error).toBe(undefined);
+ await resolveAfterTwo;
+ expect(response.data).toBe(null);
+ expect(response.loading).toBe(false);
+ expect(response.error.graphQLErrors).toEqual(expect.arrayContaining([{
+ message: 'An error occurred when fetching the topic'
+ }]));
+ });
+ });
+ });
+});
diff --git a/src/lib/stores/topics.ts b/src/lib/stores/topics.ts
new file mode 100644
index 0000000..84228cd
--- /dev/null
+++ b/src/lib/stores/topics.ts
@@ -0,0 +1,4 @@
+import { store } from './apollo';
+import { GET_TOPIC } from '$lib/data/queries';
+
+export const getTopic = (id: string) => store({ key: 'topic', query: GET_TOPIC, variables: { id } });
diff --git a/src/lib/translations/en.json b/src/lib/translations/en.json
new file mode 100644
index 0000000..14e89e4
--- /dev/null
+++ b/src/lib/translations/en.json
@@ -0,0 +1,112 @@
+{
+ "error": {
+ "generic": {
+ "title": "Error!",
+ "message": "Unknown error has occurred. Panic!"
+ },
+ "invalid_url": {
+ "title": "This is not right.",
+ "message": "This URL is not valid. Try another one maybe?"
+ }
+ },
+ "footer": {
+ "choose_language": "Choose your language",
+ "license": "Forum is <a href=\"{licenseUrl}\">open source.</a>",
+ "title": "Footer"
+ },
+ "forum": {
+ "forum": "forum",
+ "name": {
+ "everything": "Everything",
+ "us": "Us",
+ "words": "Words",
+ "sound": "Sounds",
+ "structure": "Structure",
+ "interaction": "Interaction",
+ "emotion": "Emotion",
+ "movement": "Movement",
+ "belief": "Belief",
+ "experience": "Experience",
+ "online": "Online",
+ "the_world": "The World",
+ "life": "Life"
+ },
+ "error": {
+ "unavailable": "Forum topics unavailable."
+ }
+ },
+ "forum_list": {
+ "title": "List of forums",
+ "error": {
+ "unavailable": "Forum list unavailable."
+ }
+ },
+ "glyph": {
+ "title": "User avatar"
+ },
+ "header": {
+ "action": {
+ "new": {
+ "title": "New",
+ "display": "<u>N</u>ew"
+ },
+ "reply": {
+ "title": "Reply",
+ "display": "<u>R</u>eply"
+ },
+ "search": {
+ "title": "Search",
+ "display": "<u>S</u>earch"
+ },
+ "log_out": {
+ "title": "Log Out",
+ "display": "Log <u>O</u>ut"
+ }
+ },
+ "long_version": "Forum version {version}",
+ "title": "Toolbar",
+ "short_version": "Forum v{version}"
+ },
+ "home": {
+ "title": "Hello.",
+ "content": "You are now in a forum. Select a category from the left or input a valid URL."
+ },
+ "loader": {
+ "message": "Loading."
+ },
+ "post": {
+ "author_credit": "By:",
+ "metadata_title": "Post {count} of {total} metadata",
+ "permalink_title": "Permalink to post",
+ "post": "Post",
+ "title": "Post {count} of {total} by {author}",
+ "topic_location": "In",
+ "error": {
+ "unavailable": "Post unavailable."
+ }
+ },
+ "tag": {
+ "title": "Tag",
+ "error": {
+ "unavailable": "Tag topics unavailable."
+ }
+ },
+ "topic": {
+ "category_location": "Posted on",
+ "metadata_title": "Topic metadata",
+ "permalink_title": "Permalink to topic",
+ "remaining_time": "{remaining} remaining",
+ "tags_location": "Tags:",
+ "tags_title": "Topic tags",
+ "title": "Topic",
+ "error": {
+ "unavailable": "Topic unavailable."
+ }
+ },
+ "time": {
+ "days": "{count, plural, =1 {# day} other {# days}}",
+ "hours": "{count, plural, =1 {# hour} other {# hours}}",
+ "minutes": "{count, plural, =1 {# minute} other {# minutes}}",
+ "seconds": "{count, plural, =1 {# second} other {# seconds}}"
+ }
+}
diff --git a/src/lib/translations/es.json b/src/lib/translations/es.json
new file mode 100644
index 0000000..4f6b0c1
--- /dev/null
+++ b/src/lib/translations/es.json
@@ -0,0 +1,112 @@
+{
+ "error": {
+ "generic": {
+ "title": "Error!",
+ "message": "Hubo un error desconocido. ¡Entra en pánico!"
+ },
+ "invalid_url": {
+ "title": "Esto no está bien.",
+ "message": "Este URL no es válido. Intenta otro, ¿Tal vez?"
+ }
+ },
+ "footer": {
+ "choose_language": "Escoge un lenguaje",
+ "license": "Forum es <a href=\"{licenseUrl}\">software libre.</a>",
+ "title": "Pie de página"
+ },
+ "forum": {
+ "forum": "foro",
+ "name": {
+ "everything": "Todo",
+ "us": "Nosotros",
+ "words": "Palabras",
+ "sound": "Sonidos",
+ "structure": "Estructura",
+ "interaction": "Interacción",
+ "emotion": "Emoción",
+ "movement": "Movimiento",
+ "belief": "Creencia",
+ "experience": "Experiencia",
+ "online": "En Línea",
+ "the_world": "El Mundo",
+ "life": "Vida"
+ },
+ "error": {
+ "unavailable": "Temas del foro no disponibles."
+ }
+ },
+ "forum_list": {
+ "title": "Lista de foros",
+ "error": {
+ "unavailable": "Lista de foros no disponible."
+ }
+ },
+ "glyph": {
+ "title": "Avatar del usuario"
+ },
+ "header": {
+ "action": {
+ "new": {
+ "title": "Nuevo",
+ "display": "<u>N</u>uevo"
+ },
+ "reply": {
+ "title": "Responder",
+ "display": "<u>R</u>esponder"
+ },
+ "search": {
+ "title": "Buscar",
+ "display": "Bu<u>s</u>car"
+ },
+ "log_out": {
+ "title": "Cerrar Sesión",
+ "display": "Cerrar Sesi<u>ó</u>n"
+ }
+ },
+ "long_version": "Forum versión {version}",
+ "title": "Barra de herramientas",
+ "short_version": "Forum v{version}"
+ },
+ "home": {
+ "title": "Hola.",
+ "content": "Ahora estás en un foro. Elige una categoría de la izquierda o escribe un URL válido."
+ },
+ "loader": {
+ "message": "Cargando."
+ },
+ "post": {
+ "author_credit": "Por:",
+ "metadata_title": "Metadatos de entrada {count} de {total}",
+ "permalink_title": "Permalink a entrada",
+ "post": "Entrada",
+ "title": "Entrada {count} de {total}, por {author}",
+ "topic_location": "En",
+ "error": {
+ "unavailable": "Entrada no disponible."
+ }
+ },
+ "tag": {
+ "title": "Etiqueta",
+ "error": {
+ "unavailable": "Temas de la etiqueta no disponibles."
+ }
+ },
+ "topic": {
+ "category_location": "Agregado a",
+ "metadata_title": "Metadatos del tema",
+ "permalink_title": "Permalink al tema",
+ "remaining_time": "Quedan {remaining}",
+ "tags_location": "Etiquetas:",
+ "tags_title": "Etiquetas del tema",
+ "title": "Tema",
+ "error": {
+ "unavailable": "Tema no disponible."
+ }
+ },
+ "time": {
+ "days": "{count, plural, =1 {# día} other {# días}}",
+ "hours": "{count, plural, =1 {# hora} other {# horas}}",
+ "minutes": "{count, plural, =1 {# minuto} other {# minutos}}",
+ "seconds": "{count, plural, =1 {# segundo} other {# segundos}}"
+ }
+}
diff --git a/src/lib/utils/glyph_hash.test.ts b/src/lib/utils/glyph_hash.test.ts
new file mode 100644
index 0000000..5f57a2a
--- /dev/null
+++ b/src/lib/utils/glyph_hash.test.ts
@@ -0,0 +1,68 @@
+import { getGlyphHash } from './glyph_hash';
+import type { GlyphHash } from './glyph_hash';
+
+type TestState = {
+ glyphHash?: GlyphHash
+};
+
+
+describe('Glyph Hash utility', () => {
+
+ test('it throws an exception if the string is too short', () => {
+
+ expect(() => {
+
+ getGlyphHash('short');
+ }).toThrow();
+
+ expect(() => {
+
+ getGlyphHash('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
+ }).toThrow();
+
+ expect(() => {
+
+ getGlyphHash('abcdefghijklmnopqrstuvwxyzABCDEF');
+ }).not.toThrow();
+ });
+
+ test('it treats UUIDs with hyphens the same as those without', () => {
+
+ const uuidWithHyphens = 'f7722355-2285-46c0-a55f-3483a826f3a6';
+ const uuidWithoutHyphens = 'f7722355228546c0a55f3483a826f3a6';
+
+ expect(getGlyphHash(uuidWithHyphens)).toEqual(getGlyphHash(uuidWithoutHyphens));
+ });
+
+ describe('it generates four sets of glyphs and colors', () => {
+
+ const state: TestState = {};
+
+ beforeEach(() => {
+
+ state.glyphHash = getGlyphHash('f7722355-2285-46c0-a55f-3483a826f3a6');
+ });
+
+ test('there should be four glyph fragments', () => {
+
+ expect(state.glyphHash.length).toBe(4);
+ });
+
+ test('each fragment should have a single character glyph', () => {
+
+ for (const glyphHashFragment of state.glyphHash) {
+ expect(typeof glyphHashFragment.glyph).toBe('string');
+ expect(glyphHashFragment.glyph.length).toBe(1);
+ }
+ });
+
+ test('each fragment should have a hexadecimal color', () => {
+
+ for (const glyphHashFragment of state.glyphHash) {
+ expect(typeof glyphHashFragment.color).toBe('string');
+ expect(glyphHashFragment.color.length).toBe(7);
+ expect(glyphHashFragment.color).toEqual(expect.stringMatching(/#[0-9a-f]{6}/));
+ }
+ });
+ });
+});
diff --git a/src/lib/utils/glyph_hash.ts b/src/lib/utils/glyph_hash.ts
new file mode 100644
index 0000000..b704569
--- /dev/null
+++ b/src/lib/utils/glyph_hash.ts
@@ -0,0 +1,55 @@
+export type GlyphHash = GlyphHashFragment[];
+type GlyphHashFragment = {
+ glyph: string,
+ color: string
+};
+
+const internals = {
+ kDehyphenRegex: /[-]/g,
+ kSplitterRegex: /.{1,8}/g,
+ kGlyphs: [
+ '☽',
+ '☆',
+ '♢',
+ '♡',
+ '╱',
+ '╲',
+ '╳',
+ '〰',
+ '▷',
+ '⏊',
+ '〒',
+ '▢',
+ '◯',
+ '⏃',
+ '⏀',
+ '⏆'
+ ],
+ unexpectedUUIDLength: class UnexpectedUUIDLength extends Error {
+ name = 'UnexpectedUUIDLength';
+ message = 'The provided string was not a valid UUIDv4, please provide a 32 character long string'
+ }
+};
+
+// Return a glyph with color based on a 4 byte fragment of a UUIDv4
+const getGlyphHashFragment = function (uuidFragment: string): GlyphHashFragment {
+
+ const glyphIndex = parseInt(uuidFragment.substring(0, 2), 16) % 16;
+ return {
+ glyph: internals.kGlyphs[glyphIndex],
+ color: `#${uuidFragment.substring(2, 8)}`
+ };
+};
+
+// Return an array of glyphs based on a UUIDv4
+export const getGlyphHash = function (uuid: string): GlyphHash {
+
+ const dehyphenedUuid = uuid.replace(/[-]/g, '');
+
+ if (dehyphenedUuid.length !== 32) {
+ throw new internals.unexpectedUUIDLength();
+ }
+
+ const hashFragments = dehyphenedUuid.match(internals.kSplitterRegex);
+ return hashFragments.map(getGlyphHashFragment);
+};
diff --git a/src/lib/utils/readable_time.test.ts b/src/lib/utils/readable_time.test.ts
new file mode 100644
index 0000000..5d8ba27
--- /dev/null
+++ b/src/lib/utils/readable_time.test.ts
@@ -0,0 +1,83 @@
+import { readableTime } from './readable_time';
+
+describe('readableTime', () => {
+
+ test('it shows negative time as 0', () => {
+
+ const response = readableTime(-1000);
+
+ expect(response.count).toBe(0);
+ expect(response.label).toContain('seconds');
+ });
+
+ test('uses seconds as the smallest unit', () => {
+
+ const response = readableTime(10);
+
+ expect(response.count).toBe(0);
+ expect(response.label).toContain('seconds');
+ });
+
+ test('correctly divides miliseconds into seconds', () => {
+
+ const response = readableTime(4000);
+
+ expect(response.count).toBe(4);
+ });
+
+ test('uses seconds if the time is < 1 minute', () => {
+
+ const response = readableTime(59 * 1000);
+
+ expect(response.label).toContain('seconds');
+ });
+
+ test('correctly divides miliseconds into minutes', () => {
+
+ const response = readableTime(2 * 60 * 1000);
+
+ expect(response.count).toBe(2);
+ });
+
+ test('uses minutes if the time is < 1 hour', () => {
+
+ const response = readableTime(59 * 60 * 1000);
+
+ expect(response.label).toContain('minutes');
+ });
+
+ test('correctly divides miliseconds into hours', () => {
+
+ const response = readableTime(2 * 60 * 60 * 1000);
+
+ expect(response.count).toBe(2);
+ });
+
+ test('uses hours if the time is < 1 days', () => {
+
+ const response = readableTime(23 * 60 * 60 * 1000);
+
+ expect(response.label).toContain('hours');
+ });
+
+ test('correctly divides miliseconds into days', () => {
+
+ const response = readableTime(2 * 24 * 60 * 60 * 1000);
+
+ expect(response.count).toBe(2);
+ });
+
+ test('uses days if the time is >= 1 day', () => {
+
+ const response = readableTime(364 * 24 * 60 * 60 * 1000);
+
+ expect(response.label).toContain('days');
+ });
+
+ test('uses days as the maximum unit', () => {
+
+ const response = readableTime(Number.MAX_VALUE);
+
+ expect(response.label).toContain('days');
+ });
+});
diff --git a/src/lib/utils/readable_time.ts b/src/lib/utils/readable_time.ts
new file mode 100644
index 0000000..86ba044
--- /dev/null
+++ b/src/lib/utils/readable_time.ts
@@ -0,0 +1,46 @@
+type DateMagnitude = 'day' | 'hour' | 'minute' | 'second';
+
+type ReadableTime = {
+ count: number,
+ label: string
+};
+
+
+const internals = {
+ magnitudes: {
+ day: 86400000,
+ hour: 3600000,
+ minute: 60000,
+ second: 1000
+ },
+ labels: {
+ day: 'time.days',
+ hour: 'time.hours',
+ minute: 'time.minutes',
+ second: 'time.seconds'
+ },
+
+ makeTimeReadable(time: number, magnitude: DateMagnitude): ReadableTime {
+
+ return {
+ count: Math.floor(time / internals.magnitudes[magnitude]),
+ label: internals.labels[magnitude]
+ };
+ }
+};
+
+export const readableTime = function readableTime(time: number): ReadableTime {
+
+ switch (true) {
+ case time >= internals.magnitudes.day:
+ return internals.makeTimeReadable(time, 'day');
+ case time >= internals.magnitudes.hour:
+ return internals.makeTimeReadable(time, 'hour');
+ case time >= internals.magnitudes.minute:
+ return internals.makeTimeReadable(time, 'minute');
+ case time < 0:
+ return internals.makeTimeReadable(0, 'second');
+ default:
+ return internals.makeTimeReadable(time, 'second');
+ }
+};
diff --git a/src/lib/utils/resolve_after.test.ts b/src/lib/utils/resolve_after.test.ts
new file mode 100644
index 0000000..7e0cc3c
--- /dev/null
+++ b/src/lib/utils/resolve_after.test.ts
@@ -0,0 +1,37 @@
+import { resolveAfter } from './resolve_after';
+
+describe('Resolve After', () => {
+
+ test('it should throw if given 0', () => {
+
+ expect(() => {
+
+ resolveAfter(0);
+ }).toThrow();
+ });
+
+ test('it should throw if given a negative number', () => {
+
+ expect(() => {
+
+ resolveAfter(-1);
+ }).toThrow();
+ });
+
+ test('it should resolve after the specified number of times', () => {
+
+ expect(() => {
+
+ const { counter, promise: resolveAfterThree } = resolveAfter(3);
+ let resolved = false;
+
+ resolveAfterThree.then(() => (resolved = true));
+ counter();
+ expect(resolved).toBe(false);
+ counter();
+ expect(resolved).toBe(false);
+ counter();
+ expect(resolved).toBe(true);
+ }).toThrow();
+ });
+});
diff --git a/src/lib/utils/resolve_after.ts b/src/lib/utils/resolve_after.ts
new file mode 100644
index 0000000..95a477e
--- /dev/null
+++ b/src/lib/utils/resolve_after.ts
@@ -0,0 +1,29 @@
+export type ResolveAfterPromise = {
+ counter: () => void,
+ promise: Promise<void>
+};
+
+export const resolveAfter = function (timesUntilResolve: number): ResolveAfterPromise {
+
+ let counter = null;
+ let currentValue = 0;
+
+ if (timesUntilResolve <= 0) {
+ throw new Error('Resolve after requires a positive integer');
+ }
+
+ const promise: Promise<void> = new Promise((resolvePromise) => {
+
+ counter = () => {
+
+ if (++currentValue === timesUntilResolve) {
+ resolvePromise();
+ }
+ };
+ });
+
+ return {
+ counter,
+ promise
+ };
+};