diff --git a/src/components/card/GroupCard.svelte b/src/components/card/GroupCard.svelte
new file mode 100644
index 00000000..eea1c872
--- /dev/null
+++ b/src/components/card/GroupCard.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
diff --git a/src/components/card/CardGrid.svelte b/src/components/card/GroupCardGrid.svelte
similarity index 76%
rename from src/components/card/CardGrid.svelte
rename to src/components/card/GroupCardGrid.svelte
index 8e8ff396..a47f9f47 100644
--- a/src/components/card/CardGrid.svelte
+++ b/src/components/card/GroupCardGrid.svelte
@@ -1,23 +1,22 @@
- {#each entries as entrySlug (entrySlug)}
+ {#each globals.config.listedGroups as groupId (groupId)}
-
diff --git a/src/components/card/IconCard.svelte b/src/components/card/IconCard.svelte
index 964b57c4..ff9281f9 100644
--- a/src/components/card/IconCard.svelte
+++ b/src/components/card/IconCard.svelte
@@ -1,5 +1,5 @@
+
+
+
+ {#if item.info.icon}
+
+
+
+
{item.info.name}
+
{item.info.description}
+
+
+ {:else}
+
+
{item.info.name}
+
{item.info.description}
+
+ {/if}
+
+
+
+
diff --git a/src/components/card/ItemCardGrid.svelte b/src/components/card/ItemCardGrid.svelte
new file mode 100644
index 00000000..001bf2e2
--- /dev/null
+++ b/src/components/card/ItemCardGrid.svelte
@@ -0,0 +1,56 @@
+
+
+
+ {#each globals.groups[groupId].info.listedItems as itemId (itemId)}
+
+
+
+ {/each}
+
+
+
diff --git a/src/components/card/LabelCard.svelte b/src/components/card/LabelCard.svelte
deleted file mode 100644
index de19d3fd..00000000
--- a/src/components/card/LabelCard.svelte
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
- {#if label.info.icon}
-
-
-
-
{label.info.name}
-
{label.info.description}
-
-
- {:else}
-
-
{label.info.name}
-
{label.info.description}
-
- {/if}
-
-
-
-
diff --git a/src/components/card/PackageCard.svelte b/src/components/card/PackageCard.svelte
index fee3d5b7..0f4528aa 100644
--- a/src/components/card/PackageCard.svelte
+++ b/src/components/card/PackageCard.svelte
@@ -1,8 +1,9 @@
-
- {name}
-
+
+ {name}
+
diff --git a/src/components/chip/ItemChip.svelte b/src/components/chip/ItemChip.svelte
new file mode 100644
index 00000000..7e280547
--- /dev/null
+++ b/src/components/chip/ItemChip.svelte
@@ -0,0 +1,21 @@
+
+
+
diff --git a/src/components/chip/ChipList.svelte b/src/components/chip/ItemChipList.svelte
similarity index 78%
rename from src/components/chip/ChipList.svelte
rename to src/components/chip/ItemChipList.svelte
index 3b8d2531..039b7994 100644
--- a/src/components/chip/ChipList.svelte
+++ b/src/components/chip/ItemChipList.svelte
@@ -1,18 +1,19 @@
-{#if labels.keys().length}
+{#if items.length}
- {#each labels.values().slice(0, -1) as labelGroup}
- {#each labelGroup.values() as { label, selected }}
- bubbleClick(label.classifier, label.slug, e)}
+ on:click={e => bubbleClick(groupId, itemId, e)}
/>
{/each}
{/each}
- {#each labels.values().slice(-1)[0].values() as { label, selected }}
- bubbleClick(label.classifier, label.slug, e)}
+ on:click={e => bubbleClick(itemId, groupId, e)}
/>
{/each}
diff --git a/src/components/chip/LabelChip.svelte b/src/components/chip/LabelChip.svelte
deleted file mode 100644
index f869dcdb..00000000
--- a/src/components/chip/LabelChip.svelte
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
diff --git a/src/components/chip/index.ts b/src/components/chip/index.ts
index 6d8cd664..61617736 100644
--- a/src/components/chip/index.ts
+++ b/src/components/chip/index.ts
@@ -1,3 +1,3 @@
export { default as Chip } from './Chip.svelte';
-export { default as ChipList } from './ChipList.svelte';
-export { default as LabelChip } from './LabelChip.svelte';
+export { default as ItemChipList } from './ItemChipList.svelte';
+export { default as ItemChip } from './ItemChip.svelte';
diff --git a/src/components/index.ts b/src/components/index.ts
index 4723ded2..399e3be2 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -1,5 +1,5 @@
-export { default as Markdown } from './Markdown.svelte';
-export { default as Navbar } from './Navbar.svelte';
+export { Markdown } from './markdown';
+export { default as Navbar } from './navbar/Navbar.svelte';
export { default as Separator } from './Separator.svelte';
// export { default as AsciinemaPlayer } from './AsciinemaPlayer.svelte';
diff --git a/src/components/markdown/EditableMarkdown.svelte b/src/components/markdown/EditableMarkdown.svelte
new file mode 100644
index 00000000..5c365889
--- /dev/null
+++ b/src/components/markdown/EditableMarkdown.svelte
@@ -0,0 +1,65 @@
+
+
+{#if !editable}
+
+{:else}
+ {#if isEditing}
+
+
+
+
+
+ {:else}
+
+
+
+
+ {/if}
+{/if}
+
+
diff --git a/src/components/Markdown.svelte b/src/components/markdown/Markdown.svelte
similarity index 98%
rename from src/components/Markdown.svelte
rename to src/components/markdown/Markdown.svelte
index 31da5fc5..454815dd 100644
--- a/src/components/Markdown.svelte
+++ b/src/components/markdown/Markdown.svelte
@@ -2,7 +2,6 @@
import { marked } from 'marked';
import hljs from 'highlight.js';
import 'highlight.js/styles/stackoverflow-light.css';
- import { onMount } from 'svelte';
export let source: string;
diff --git a/src/components/MarkdownEditor.svelte b/src/components/markdown/MarkdownEditor.svelte
similarity index 60%
rename from src/components/MarkdownEditor.svelte
rename to src/components/markdown/MarkdownEditor.svelte
index e36a7b02..323a2cb1 100644
--- a/src/components/MarkdownEditor.svelte
+++ b/src/components/markdown/MarkdownEditor.svelte
@@ -1,15 +1,21 @@
-
+
diff --git a/src/components/markdown/index.ts b/src/components/markdown/index.ts
new file mode 100644
index 00000000..dc4ac17e
--- /dev/null
+++ b/src/components/markdown/index.ts
@@ -0,0 +1,6 @@
+export { default as Markdown } from './Markdown.svelte';
+export { default as MarkdownEditor } from './Markdown.svelte';
+
+import EditableMarkdown from './EditableMarkdown.svelte';
+
+export default EditableMarkdown;
diff --git a/src/components/modals/Error.svelte b/src/components/modals/Error.svelte
new file mode 100644
index 00000000..bcf991df
--- /dev/null
+++ b/src/components/modals/Error.svelte
@@ -0,0 +1,19 @@
+
+
+
+ {header}
+
+ {text}
+
+
+
diff --git a/src/components/modals/Modal.svelte b/src/components/modals/Modal.svelte
new file mode 100644
index 00000000..bfec9178
--- /dev/null
+++ b/src/components/modals/Modal.svelte
@@ -0,0 +1,93 @@
+
+
+
+
+
+
diff --git a/src/components/modals/NewGroupModal.svelte b/src/components/modals/NewGroupModal.svelte
new file mode 100644
index 00000000..c8b5528f
--- /dev/null
+++ b/src/components/modals/NewGroupModal.svelte
@@ -0,0 +1,92 @@
+
+
+
+ New group
+
+
+
+
diff --git a/src/components/modals/NewItemModal.svelte b/src/components/modals/NewItemModal.svelte
new file mode 100644
index 00000000..01b7a538
--- /dev/null
+++ b/src/components/modals/NewItemModal.svelte
@@ -0,0 +1,95 @@
+
+
+
+ New item
+ Creating an item within the group '{groupId}'.
+
+
+
+
diff --git a/src/components/modals/Spinner.svelte b/src/components/modals/Spinner.svelte
new file mode 100644
index 00000000..a440459b
--- /dev/null
+++ b/src/components/modals/Spinner.svelte
@@ -0,0 +1,28 @@
+
+
+
+ {header}
+
+ {text}
+
+
+
diff --git a/src/components/navbar/Navbar.svelte b/src/components/navbar/Navbar.svelte
new file mode 100644
index 00000000..f28c9999
--- /dev/null
+++ b/src/components/navbar/Navbar.svelte
@@ -0,0 +1,134 @@
+
+
+
+
+
diff --git a/src/components/navbar/index.ts b/src/components/navbar/index.ts
new file mode 100644
index 00000000..29b38dee
--- /dev/null
+++ b/src/components/navbar/index.ts
@@ -0,0 +1,3 @@
+import Navbar from './Navbar.svelte';
+
+export default Navbar;
diff --git a/src/endpoints/ApiError.ts b/src/endpoints/ApiError.ts
new file mode 100644
index 00000000..95358d8a
--- /dev/null
+++ b/src/endpoints/ApiError.ts
@@ -0,0 +1,21 @@
+/**
+ * Custom error class for errors that happen when fetching data
+ */
+class ApiError extends Error {
+ error: string;
+ code: number | null;
+
+ /**
+ * Custom error class for errors that happen when fetching data
+ */
+ constructor (code: number | null, error: string | object) {
+ if (typeof error === 'object') {
+ error = JSON.stringify(error);
+ }
+ super(`ApiError: [${code}] ${error}`);
+ this.error = error;
+ this.code = code;
+ }
+}
+
+export default ApiError;
diff --git a/src/endpoints/README.md b/src/endpoints/README.md
new file mode 100644
index 00000000..7b5b2e80
--- /dev/null
+++ b/src/endpoints/README.md
@@ -0,0 +1,7 @@
+# Tests / API
+
+This directory contains functions to access the portfolio API. This should act
+as a reference for how the API works.
+
+It is used in testing to send web requests using the `sync-request-curl`
+library, as for some reason the `fetch` API doesn't play nicely.
diff --git a/src/endpoints/admin/auth.ts b/src/endpoints/admin/auth.ts
new file mode 100644
index 00000000..5d3b9e1d
--- /dev/null
+++ b/src/endpoints/admin/auth.ts
@@ -0,0 +1,85 @@
+/** Authentication endpoints */
+import { apiFetch, json } from '../fetch';
+
+export default function auth(token: string | undefined) {
+ /**
+ * Log in as an administrator for the site
+ *
+ * @param username The username of the admin account
+ * @param password The password of the admin account
+ */
+ const login = async (username: string, password: string) => {
+ return json(apiFetch(
+ 'POST',
+ '/api/admin/auth/login',
+ undefined,
+ { username, password }
+ )) as Promise<{ token: string }>;
+ };
+
+ /**
+ * Log out, invalidating the token
+ *
+ * @param token The token to invalidate
+ */
+ const logout = async () => {
+ return json(apiFetch(
+ 'POST',
+ '/api/admin/auth/logout',
+ token,
+ )) as Promise<{ token: string }>;
+ };
+
+ /**
+ * Change the authentication of the admin account
+ *
+ * @param token The auth token
+ * @param oldPassword The currently-active password
+ * @param newPassword The new replacement password
+ */
+ const change = async (newUsername: string, oldPassword: string, newPassword: string) => {
+ return json(apiFetch(
+ 'POST',
+ '/api/admin/auth/change',
+ token,
+ { newUsername, oldPassword, newPassword }
+ )) as Promise
>;
+ };
+
+ /**
+ * Revoke all current API tokens
+ *
+ * @param token The auth token
+ */
+ const revoke = async () => {
+ return json(apiFetch(
+ 'POST',
+ '/api/admin/auth/revoke',
+ token
+ )) as Promise>;
+ };
+
+ /**
+ * Disable authentication, meaning that users can no-longer log into the
+ * system.
+ *
+ * @param token The auth token
+ * @param password The password to the admin account
+ */
+ const disable = async (password: string) => {
+ return json(apiFetch(
+ 'POST',
+ '/api/admin/auth/disable',
+ token,
+ { password }
+ )) as Promise>;
+ };
+
+ return {
+ login,
+ logout,
+ change,
+ disable,
+ revoke,
+ };
+}
diff --git a/src/endpoints/admin/config.ts b/src/endpoints/admin/config.ts
new file mode 100644
index 00000000..ca517c8d
--- /dev/null
+++ b/src/endpoints/admin/config.ts
@@ -0,0 +1,38 @@
+/** Configuration endpoints */
+import { apiFetch, json } from '../fetch';
+import type { ConfigJson } from '$lib/server/data/config';
+
+export default function config(token: string | undefined) {
+ const get = async () => {
+ return json(apiFetch(
+ 'GET',
+ '/api/admin/config',
+ token,
+ )) as Promise;
+ };
+
+ const put = async (config: ConfigJson) => {
+ return json(apiFetch(
+ 'PUT',
+ '/api/admin/config',
+ token,
+ config,
+ )) as Promise>;
+ };
+
+ return {
+ /**
+ * Retrieve the site configuration.
+ *
+ * @param token The authentication token
+ */
+ get,
+ /**
+ * Update the site configuration.
+ *
+ * @param token The authentication token
+ * @param config The updated configuration
+ */
+ put,
+ };
+}
diff --git a/src/endpoints/admin/firstrun.ts b/src/endpoints/admin/firstrun.ts
new file mode 100644
index 00000000..f7ffd7d9
--- /dev/null
+++ b/src/endpoints/admin/firstrun.ts
@@ -0,0 +1,21 @@
+/** Git repository endpoints */
+import type { FirstRunCredentials } from '$lib/server/auth';
+import { apiFetch, json } from '../fetch';
+
+/**
+ * Set up the site's data repository.
+ *
+ * @param repoUrl The clone URL of the git repo
+ * @param branch The branch to check-out
+ */
+export default async function (
+ repoUrl: string | null,
+ branch: string | null,
+) {
+ return json(apiFetch(
+ 'POST',
+ '/api/admin/firstrun',
+ undefined,
+ { repoUrl, branch },
+ )) as Promise<{ credentials: FirstRunCredentials, firstTime: boolean }>;
+}
diff --git a/src/endpoints/admin/index.ts b/src/endpoints/admin/index.ts
new file mode 100644
index 00000000..0d509546
--- /dev/null
+++ b/src/endpoints/admin/index.ts
@@ -0,0 +1,14 @@
+/** Admin endpoints */
+import auth from './auth';
+import config from './config';
+import repo from './repo';
+import firstrun from './firstrun';
+
+export default function admin(token: string | undefined) {
+ return {
+ auth: auth(token),
+ config: config(token),
+ repo: repo(token),
+ firstrun,
+ };
+}
diff --git a/src/endpoints/admin/repo.ts b/src/endpoints/admin/repo.ts
new file mode 100644
index 00000000..e39b46ea
--- /dev/null
+++ b/src/endpoints/admin/repo.ts
@@ -0,0 +1,36 @@
+/** Git repository endpoints */
+import { apiFetch, json } from '../fetch';
+
+/** Information about the repo */
+export type RepoInfo = {
+ /** Object if repo exists, or null otherwise */
+ repo: {
+ /** The repo URL */
+ url: string
+ /** The current branch */
+ branch: string
+ /** The current commit hash */
+ commit: string
+ /** Whether the repository has any uncommitted changes */
+ clean: boolean
+ } | null,
+};
+
+export default function repo(token: string | undefined) {
+ const get = async () => {
+ return json(apiFetch(
+ 'GET',
+ '/api/admin/repo',
+ token,
+ )) as Promise;
+ };
+
+ return {
+ /**
+ * Retrieve information about the data repository.
+ *
+ * @param token The authentication token
+ */
+ get,
+ };
+}
diff --git a/src/endpoints/debug.ts b/src/endpoints/debug.ts
new file mode 100644
index 00000000..6997c042
--- /dev/null
+++ b/src/endpoints/debug.ts
@@ -0,0 +1,32 @@
+/** Debug endpoints */
+import { apiFetch, json } from './fetch';
+
+export default function debug(token: string | undefined) {
+ const clear = async () => {
+ return json(apiFetch(
+ 'DELETE',
+ '/api/debug/clear',
+ token,
+ )) as Promise>;
+ };
+
+ const echo = async (text: string) => {
+ return json(apiFetch(
+ 'POST',
+ '/api/debug/echo',
+ token,
+ { text }
+ )) as Promise>;
+ };
+
+ return {
+ /**
+ * Reset the app to its default state.
+ */
+ clear,
+ /**
+ * Echo text to the server's console
+ */
+ echo,
+ };
+}
diff --git a/src/endpoints/fetch.ts b/src/endpoints/fetch.ts
new file mode 100644
index 00000000..809029a2
--- /dev/null
+++ b/src/endpoints/fetch.ts
@@ -0,0 +1,146 @@
+import dotenv from 'dotenv';
+import ApiError from './ApiError';
+// import fetch from 'cross-fetch';
+import { browser } from '$app/environment';
+
+export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'DELETE';
+
+function getUrl() {
+ if (browser) {
+ // Running in browser (request to whatever origin we are running in)
+ return '';
+ } else {
+ // Running in node
+ dotenv.config();
+
+ const PORT = process.env.PORT as string;
+ const HOST = process.env.HOST as string;
+ return `http://${HOST}:${PORT}`;
+ }
+}
+
+/**
+ * Fetch some data from the backend
+ *
+ * @param method Type of request
+ * @param route route to request to
+ * @param token auth token (note this is only needed if the token wasn't set in
+ * the cookies)
+ * @param bodyParams request body or params
+ *
+ * @returns promise of the resolved data.
+ */
+export async function apiFetch(
+ method: HttpVerb,
+ route: string,
+ token?: string,
+ bodyParams?: object
+): Promise {
+ const URL = getUrl();
+ if (bodyParams === undefined) {
+ bodyParams = {};
+ }
+
+ const tokenHeader = token ? { Authorization: `Bearer ${token}` } : {};
+ const contentType = ['POST', 'PUT'].includes(method) ? { 'Content-Type': 'application/json' } : {};
+
+ const headers = new Headers({
+ ...tokenHeader,
+ ...contentType,
+ } as Record);
+
+ let url: string;
+ let body: string | null; // JSON string
+
+ if (['POST', 'PUT'].includes(method)) {
+ // POST and PUT use a body
+ url = `${URL}${route}`;
+ body = JSON.stringify(bodyParams);
+ } else {
+ // GET and DELETE use params
+ url
+ = `${URL}${route}?`
+ + new URLSearchParams(bodyParams as Record);
+ body = null;
+ }
+
+ // Now send the request
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method,
+ body,
+ headers,
+ // Include credentials so that the token cookie is sent with the request
+ // https://stackoverflow.com/a/76562495/6335363
+ credentials: 'same-origin',
+ });
+ } catch (err) {
+ // Likely a network issue
+ if (err instanceof Error) {
+ throw new ApiError(null, err.message);
+ } else {
+ throw new ApiError(null, `Unknown request error ${err}`);
+ }
+ }
+
+ return res;
+}
+
+/** Process a text response, returning the text as a string */
+export async function text(response: Promise): Promise {
+ const res = await response;
+ if ([404, 405].includes(res.status)) {
+ throw new ApiError(404, `Error ${res.status} at ${res.url}`);
+ }
+ if (![200, 304].includes(res.status)) {
+ // Unknown error
+ throw new ApiError(res.status, `Request got status code ${res.status}`);
+ }
+
+ const text = await res.text();
+
+ if ([400, 401, 403].includes(res.status)) {
+ throw new ApiError(res.status, text);
+ }
+
+ return text;
+}
+
+/** Process a JSON response, returning the data as a JS object */
+export async function json(response: Promise): Promise