feat: add Appearance panel to Settings modal

This features two knobs: theming and indent.
I was curious whether I could actually hand the theme switching to the
parent, so I created an event dispatcher for it.

It works! But I had to hack the Svelte component a little.
I also need to sync it two the theme switcher in the popup and go over
every component that is focused on light mode only at the moment. To
limit the work I will constraint myself to the Profile page here. If the
theme is meant to apply to other pages as well, we will need to persist
the setting. That means a database in Anvil, I guess. I'm not willing to
take that step right now. It must come at some point (e.g. when talking
to Vervis to actually inform about changes made in Anvil).

Signed-off-by: André Jaenisch <andre.jaenisch@posteo.de>
This commit is contained in:
André Jaenisch 2024-08-08 10:29:31 +02:00
parent b5fed52bc5
commit 5af877bcaa
No known key found for this signature in database
GPG key ID: 5A668E771F1ED854
11 changed files with 514 additions and 262 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "anvil", "name": "anvil",
"version": "0.0.12", "version": "0.0.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "anvil", "name": "anvil",
"version": "0.0.12", "version": "0.0.13",
"dependencies": { "dependencies": {
"@floating-ui/dom": "1.6.8", "@floating-ui/dom": "1.6.8",
"@fontsource/spline-sans-mono": "5.0.20", "@fontsource/spline-sans-mono": "5.0.20",

View file

@ -1,6 +1,6 @@
{ {
"name": "anvil", "name": "anvil",
"version": "0.0.12", "version": "0.0.13",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View file

@ -13,15 +13,34 @@ You should have received a copy of the GNU Affero General Public License along w
<script> <script>
import SettingsAccount from './SettingsAccount.svelte'; import SettingsAccount from './SettingsAccount.svelte';
import SettingsAppearance from './SettingsAppearance.svelte';
import SettingsKeys from './SettingsKeys.svelte'; import SettingsKeys from './SettingsKeys.svelte';
import SettingsSidebar from '../atoms/SettingsSidebar.svelte'; import SettingsSidebar from '../atoms/SettingsSidebar.svelte';
import SettingsProfile from './SettingsProfile.svelte'; import SettingsProfile from './SettingsProfile.svelte';
let activeSetting = 'profile'; let activeSetting = 'profile';
let activeTheme = 'auto';
function onTileChanged(ev) { function onTileChanged(ev) {
activeSetting = ev.detail; activeSetting = ev.detail;
} }
function onThemeSwitched(ev) {
activeTheme = ev.detail;
// This is a hack.
// Skeleton relies on Cookies and a hook:
// https://github.com/skeletonlabs/skeleton/blob/%40skeletonlabs/skeleton%402.10.2/sites/skeleton.dev/src/hooks.server.ts#L16
// Svelte itself features a svelte:document special element that must
// only be used at the top level - except SvelteKit has app.html
// as a static file :[]
// Ideas welcome!
if (window?.document?.documentElement) {
window.document.documentElement.classList.remove('auto');
window.document.documentElement.classList.remove('dark');
window.document.documentElement.classList.remove('light');
window.document.documentElement.classList.add(activeTheme);
}
}
</script> </script>
<div class="flex bg-surface-50 min-h-[900px]"> <div class="flex bg-surface-50 min-h-[900px]">
@ -33,5 +52,7 @@ You should have received a copy of the GNU Affero General Public License along w
<SettingsAccount /> <SettingsAccount />
{:else if activeSetting === 'ssh_gpg_keys'} {:else if activeSetting === 'ssh_gpg_keys'}
<SettingsKeys /> <SettingsKeys />
{:else if activeSetting === 'appearance'}
<SettingsAppearance on:switch-theme={onThemeSwitched} />
{/if} {/if}
</div> </div>

View file

@ -0,0 +1,68 @@
<!--
SettingsAppearance molecule.
Copyright (C) 2024 André Jaenisch
SPDX-FileCopyrightText: 2024 André Jaenisch
SPDX-License-Identifier: AGPL-3.0-or-later
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<script>
import { createEventDispatcher } from 'svelte';
import { _ } from 'svelte-i18n';
const dispatcher = createEventDispatcher();
function onThemeSwitched(ev) {
dispatcher('switch-theme', ev.target.value);
}
</script>
<div class="flex flex-col flex-1 gap-6 pe-8 pb-8 ps-8 pt-20 bg-surface-100-800-token">
<div class="text-surface-500-300-token text-xl font-semibold">
{$_('settings.appearance.headline')}
</div>
<div class="flex flex-col gap-2">
<div class="text-surface-500-300-token font-semibold py-1">
{$_('settings.appearance.theme.headline')}
</div>
<div class="flex gap-8">
<label class="label flex items-center space-x-2">
<input type="radio" class="me-2" name="theme" value="light" on:change={onThemeSwitched} />
{$_('settings.appearance.theme.light')}
</label>
<label class="label flex items-center space-x-2">
<input type="radio" class="me-2" name="theme" value="dark" on:change={onThemeSwitched} />
{$_('settings.appearance.theme.dark')}
</label>
<label class="label flex items-center space-x-2">
<input
type="radio"
class="me-2"
name="theme"
value="auto"
checked
on:change={onThemeSwitched}
/>
{$_('settings.appearance.theme.auto')}
</label>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-surface-500-300-token font-semibold py-1">
{$_('settings.appearance.tab_indent.headline')}
</div>
<p class="text-sm">{$_('settings.appearance.tab_indent.intro')}</p>
<input
type="text"
class="input bg-white text-center text-surface-500-300-token max-w-16"
name="tab_indent"
value="4"
/>
</div>
</div>

View file

@ -192,7 +192,18 @@
} }
}, },
"appearance": { "appearance": {
"label": "" "headline": "",
"label": "",
"tab_indent": {
"headline": "",
"intro": ""
},
"theme": {
"auto": "",
"dark": "",
"headline": "",
"light": ""
}
}, },
"notifications": { "notifications": {
"label": "" "label": ""

View file

@ -1,257 +1,268 @@
{ {
"overlay": { "overlay": {
"avatar": { "avatar": {
"about_anvil": "Über Anvil", "about_anvil": "Über Anvil",
"dark_mode": "", "dark_mode": "",
"profile": "", "profile": "",
"settings": "", "settings": "",
"sign_out": "" "sign_out": ""
} }
}, },
"page": { "page": {
"import_project": { "import_project": {
"form": { "form": {
"avatar": "Avatar", "avatar": "Avatar",
"components": "Komponenten", "components": "Komponenten",
"fields": { "fields": {
"avatar": { "avatar": {
"label": "Bild hochladen" "label": "Bild hochladen"
}, },
"description": { "description": {
"label": "Beschreibung", "label": "Beschreibung",
"placeholder": "Beschreibe das Projekt in unter 100 Zeichen..." "placeholder": "Beschreibe das Projekt in unter 100 Zeichen..."
}, },
"issues": { "issues": {
"label": "Issues" "label": "Issues"
}, },
"name": { "name": {
"error": "Dieses Feld ist erforderlich", "error": "Dieses Feld ist erforderlich",
"label": "Name", "label": "Name",
"placeholder": "Name des Projekts..." "placeholder": "Name des Projekts..."
}, },
"pr": { "pr": {
"label": "Pullrequest" "label": "Pullrequest"
}, },
"repository": { "repository": {
"hint": "optional: von Git-Repository importieren", "hint": "optional: von Git-Repository importieren",
"label": "Repository", "label": "Repository",
"placeholder": "URL des Git-Repositorys" "placeholder": "URL des Git-Repositorys"
} }
}, },
"submit": "Projekt erstellen" "submit": "Projekt erstellen"
}, },
"heading": "Projekt erstellen", "heading": "Projekt erstellen",
"intro": "Neues F2-Projekt zu Anvil hinzufügen." "intro": "Neues F2-Projekt zu Anvil hinzufügen."
}, },
"login": { "login": {
"form": { "form": {
"fields": { "fields": {
"account": { "account": {
"label": "Accountname" "label": "Accountname"
}, },
"passphrase": { "passphrase": {
"label": "Passwort", "label": "Passwort",
"placeholder": "Passwort" "placeholder": "Passwort"
}, },
"server": { "server": {
"label": "F2-Server" "label": "F2-Server"
} }
}, },
"reset": "Passwort zurücksetzen", "reset": "Passwort zurücksetzen",
"submit": "Einloggen", "submit": "Einloggen",
"validation": { "validation": {
"incorrect": "Accountname oder Kennwort sind falsch.", "incorrect": "Accountname oder Kennwort sind falsch.",
"missing": "Das Account-Feld ist verbindlich." "missing": "Das Account-Feld ist verbindlich."
} }
}, },
"heading": "Einloggen", "heading": "Einloggen",
"intro": "Die Einwahldaten ausfüllen um Anvil mit deinem F2-Account zu benutzen." "intro": "Die Einwahldaten ausfüllen um Anvil mit deinem F2-Account zu benutzen."
}, },
"profile": { "profile": {
"activities": { "activities": {
"block_or_report": "{blockElementOpen}blockieren{blockElementClose} oder {reportElementOpen}melden{reportElementClose}", "block_or_report": "{blockElementOpen}blockieren{blockElementClose} oder {reportElementOpen}melden{reportElementClose}",
"like": "Gefällt mir" "like": "Gefällt mir"
}, },
"heading": "Profil für", "heading": "Profil für",
"history": { "history": {
"activities": { "activities": {
"commits": { "commits": {
"actions": { "actions": {
"browse": "", "browse": "",
"copy": "" "copy": ""
}, },
"number": "", "number": "",
"relative_time": "" "relative_time": ""
}, },
"setup": { "setup": {
"description": "Das F2-Konto @{username}@{instance} wurde erfolgreich innerhalb von {created_with} erstellt", "description": "Das F2-Konto @{username}@{instance} wurde erfolgreich innerhalb von {created_with} erstellt",
"summary": "Kontoeinstellungen" "summary": "Kontoeinstellungen"
} }
}, },
"heading": "Aktivitäten" "heading": "Aktivitäten"
}, },
"menu": { "menu": {
"actions": { "actions": {
"fork": "", "fork": "",
"star": "", "star": "",
"watch": "" "watch": ""
}, },
"buttons": { "buttons": {
"avatar": "", "avatar": "",
"issues": "", "issues": "",
"notifications": "", "notifications": "",
"prs": "" "prs": ""
}, },
"details": { "details": {
"branches": "", "branches": "",
"commits": "", "commits": "",
"files": "", "files": "",
"issues": "", "issues": "",
"merge_requests": "", "merge_requests": "",
"moderation": "", "moderation": "",
"overview": "", "overview": "",
"people": "", "people": "",
"repository": "", "repository": "",
"roles": "", "roles": "",
"tags": "" "tags": ""
} }
}, },
"projects": { "projects": {
"actions": { "actions": {
"fork": "Fork", "fork": "Fork",
"star": "Favorisieren", "star": "Favorisieren",
"watch": "Beobachten" "watch": "Beobachten"
}, },
"add_or_import": "{addElementOpen}Ein Projekt hinzufügen{addElementClose} oder {importElementOpen}Ein Projekt importieren{importElementClose}.", "add_or_import": "{addElementOpen}Ein Projekt hinzufügen{addElementClose} oder {importElementOpen}Ein Projekt importieren{importElementClose}.",
"empty": "Bisher keine Projekte hinzugefügt.", "empty": "Bisher keine Projekte hinzugefügt.",
"heading": "Projekte" "heading": "Projekte"
}, },
"repositories": { "repositories": {
"heading": "" "heading": ""
} }
}, },
"projects": { "projects": {
"file_table": { "file_table": {
"updated": "Aktualisiert: {relativeTime}" "updated": "Aktualisiert: {relativeTime}"
}, },
"form": { "form": {
"fields": { "fields": {
"more_filters": { "more_filters": {
"submit": "Weitere Filter" "submit": "Weitere Filter"
}, },
"projects": { "projects": {
"submit": "Meine Projekte" "submit": "Meine Projekte"
}, },
"search": { "search": {
"placeholder": "Suchen oder filtern", "placeholder": "Suchen oder filtern",
"submit": "Absenden" "submit": "Absenden"
}, },
"starred": { "starred": {
"submit": "Favorisiert" "submit": "Favorisiert"
} }
} }
}, },
"nav": { "nav": {
"next": "Weiter", "next": "Weiter",
"previous": "Zurück" "previous": "Zurück"
}, },
"table": { "table": {
"heading": { "heading": {
"last_updated": "Letzte Aktualisierung", "last_updated": "Letzte Aktualisierung",
"name": "Name" "name": "Name"
} }
} }
}, },
"welcome": { "welcome": {
"create": "", "create": "",
"intro": "", "intro": "",
"headline": "Willkommen bei Anvil", "headline": "Willkommen bei Anvil",
"login": "", "login": "",
"logo": { "logo": {
"alt": "" "alt": ""
}, },
"reset": "" "reset": ""
} }
}, },
"settings": { "settings": {
"headline": "", "headline": "",
"account": { "account": {
"delete": "", "delete": "",
"f2": { "f2": {
"label": "", "label": "",
"placeholder": "" "placeholder": ""
}, },
"headline": "", "headline": "",
"label": "", "label": "",
"password": { "password": {
"label": "", "label": "",
"reset": "" "reset": ""
}, },
"verification": { "verification": {
"label": "" "label": ""
} }
}, },
"appearance": { "appearance": {
"label": "" "headline": "",
}, "label": "",
"notifications": { "tab_indent": {
"label": "" "headline": "",
}, "intro": ""
"profile": { },
"avatar": { "theme": {
"headline": "", "auto": "",
"remove": "", "dark": "",
"upload": "" "headline": "",
}, "light": ""
"bio": { }
"label": "", },
"placeholder": "" "notifications": {
}, "label": ""
"extra": { },
"content": { "profile": {
"placeholder": "" "avatar": {
}, "headline": "",
"headline": "", "remove": "",
"hint": "", "upload": ""
"label": { },
"placeholder": "" "bio": {
} "label": "",
}, "placeholder": ""
"headline": "", },
"label": "", "extra": {
"name": { "content": {
"label": "", "placeholder": ""
"placeholder": "" },
}, "headline": "",
"pronouns": { "hint": "",
"label": "" "label": {
} "placeholder": ""
}, }
"ssh_gpg_keys": { },
"gpg": { "headline": "",
"add": "", "label": "",
"headline": "", "name": {
"key": { "label": "",
"placeholder": "" "placeholder": ""
}, },
"remove": "", "pronouns": {
"title": { "label": ""
"placeholder": "" }
} },
}, "ssh_gpg_keys": {
"headline": "", "gpg": {
"label": "", "add": "",
"ssh": { "headline": "",
"add": "", "key": {
"headline": "", "placeholder": ""
"key": { },
"placeholder": "" "remove": "",
}, "title": {
"remove": "", "placeholder": ""
"title": { }
"placeholder": "" },
} "headline": "",
} "label": "",
} "ssh": {
} "add": "",
"headline": "",
"key": {
"placeholder": ""
},
"remove": "",
"title": {
"placeholder": ""
}
}
}
}
} }

View file

@ -192,7 +192,18 @@
} }
}, },
"appearance": { "appearance": {
"label": "Appearance" "headline": "Appearance",
"label": "Appearance",
"tab_indent": {
"headline": "Tab indenting",
"intro": "Number of spaces per tab in code view."
},
"theme": {
"auto": "Auto",
"dark": "Dark",
"headline": "Theme",
"light": "Light"
}
}, },
"notifications": { "notifications": {
"label": "Notifications" "label": "Notifications"

View file

@ -192,7 +192,18 @@
} }
}, },
"appearance": { "appearance": {
"label": "" "headline": "",
"label": "",
"tab_indent": {
"headline": "",
"intro": ""
},
"theme": {
"auto": "",
"dark": "",
"headline": "",
"light": ""
}
}, },
"notifications": { "notifications": {
"label": "" "label": ""

View file

@ -192,7 +192,18 @@
} }
}, },
"appearance": { "appearance": {
"label": "" "headline": "",
"label": "",
"tab_indent": {
"headline": "",
"intro": ""
},
"theme": {
"auto": "",
"dark": "",
"headline": "",
"light": ""
}
}, },
"notifications": { "notifications": {
"label": "" "label": ""

View file

@ -0,0 +1,26 @@
/* Stories for SettingsAppearance molecule.
* Copyright (C) 2024 André Jaenisch
* SPDX-FileCopyrightText: 2024 André Jaenisch
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import type { Meta, StoryObj } from '@storybook/svelte';
import SettingsAppearance from '$lib/components/molecules/SettingsAppearance.svelte';
const meta = {
title: 'Molecules/SettingsAppearance',
component: SettingsAppearance,
tags: ['autodocs']
} satisfies Meta<SettingsAppearance>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Plain: Story = {};

View file

@ -0,0 +1,82 @@
/* Component test for SettingsAppearance molecule.
* Copyright (C) 2024 André Jaenisch
* SPDX-FileCopyrightText: 2024 André Jaenisch
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/svelte';
import { init, locale, register } from 'svelte-i18n';
import SettingsAppearance from '../../../src/lib/components/molecules/SettingsAppearance.svelte';
import enMessages from '../../../src/lib/i18n/locales/en.json';
describe('SettingsAppearance.svelte', () => {
beforeEach(() => {
register('en', () => import('../../../src/lib/i18n/locales/en.json'));
init({ fallbackLocale: 'en', initialLocale: 'en' });
locale.set('en');
});
it('should mount', () => {
// Arrange
// Nothing to prepare
// Act
const { container } = render(SettingsAppearance);
// Assert
expect(container).toBeTruthy();
});
it('should have a radio group for themes', () => {
// Arrange
// Nothing to prepare
// Act
render(SettingsAppearance);
// Assert
expect(screen.getByLabelText(enMessages.settings.appearance.theme.light)).toBeInTheDocument();
expect(screen.getByLabelText(enMessages.settings.appearance.theme.dark)).toBeInTheDocument();
expect(screen.getByLabelText(enMessages.settings.appearance.theme.auto)).toBeInTheDocument();
});
describe('when clicking a theme', () => {
it('should dispatch a switch-theme event', () => {
// Arrange
const listener = vi.fn();
// Act
const { component } = render(SettingsAppearance);
component.$on('switch-theme', listener);
const darkTheme = screen.getByLabelText(enMessages.settings.appearance.theme.dark);
darkTheme.click();
const ev = new CustomEvent({ detail: 'dark' });
// Assert
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(ev);
});
});
// TODO: Mark up proper label and input
it.skip('should have a tab indent input', () => {
// Arrange
// Nothing to prepare
// Act
render(SettingsAppearance);
// Assert
expect(
screen.getByPlaceholderText(enMessages.settings.appearance.tab_indent.label)
).toBeInTheDocument();
});
});