feat(KAN-52): add server selection and isolated native sessions

This commit is contained in:
2026-09-16 18:58:49 +02:00
parent 0f62445c3e
commit 1be2d06ff4
13 changed files with 988 additions and 183 deletions
+13 -2
View File
@@ -14,8 +14,19 @@
<p class="eyebrow" data-i18n="appName"></p>
<h1 data-i18n="title"></h1>
<p class="description" data-i18n="description"></p>
<a class="open-workspace" href="https://kanbangit.de/" data-i18n="openWorkspace"></a>
<p class="server">kanbangit.de</p>
<form id="server-form" novalidate>
<label for="server-url" data-i18n="serverLabel"></label>
<input id="server-url" type="url" inputmode="url" autocomplete="url" spellcheck="false" aria-describedby="server-help connection-status" value="https://kanbangit.de/" required />
<p id="server-help" class="description" data-i18n="serverHelp"></p>
<button class="open-workspace" type="submit" data-i18n="openWorkspace"></button>
</form>
<p id="connection-status" class="description" role="status" aria-live="polite"></p>
<div id="switch-confirmation" hidden>
<p class="description" data-i18n="switchWarning"></p>
<button id="confirm-switch" type="button" data-i18n="confirmSwitch"></button>
<button id="cancel-switch" type="button" data-i18n="cancel"></button>
</div>
<button id="resume" type="button" data-i18n="resume" hidden></button>
<label class="appearance">
<span data-i18n="appearance"></span>
<select id="appearance">
+21 -2
View File
@@ -1,8 +1,27 @@
export const en = {
appName: 'KanbanGit',
title: 'Your workspace, one click away.',
description: 'Open your projects and keep work moving.',
title: 'Choose your workspace.',
description: 'Connect to your KanbanGit installation over HTTPS.',
serverLabel: 'Server address',
serverHelp:
'Use the installation address without login details, a query or a fragment.',
openWorkspace: 'Open workspace',
retry: 'Try again',
resume: 'Return to workspace',
switchWarning:
'Switching servers closes the current workspace and discards unsaved inputs. You will sign in separately on the new server.',
confirmSwitch: 'Switch server',
cancel: 'Cancel',
idle: '',
connecting: 'Connecting to your server…',
connected:
'Your workspace is open. Use KanbanGit → Server settings to change servers.',
connectionError:
'The server did not finish loading. Check your connection, server address and HTTPS certificate, then try again. An already open workspace and its inputs are kept.',
invalidServer:
'Enter a valid HTTPS installation address without login details, a query or a fragment.',
storageError:
'The server setting could not be read or saved. Check your local storage permissions and try again.',
appearance: 'Appearance',
system: 'System',
light: 'Light',
+78
View File
@@ -1,9 +1,87 @@
import { en } from './locales/en.js';
import { normalizeServer, needsConfirmation } from './server.js';
for (const element of document.querySelectorAll('[data-i18n]')) {
element.textContent = en[element.dataset.i18n];
}
const form = document.querySelector('#server-form');
const input = document.querySelector('#server-url');
const status = document.querySelector('#connection-status');
const open = document.querySelector('.open-workspace');
const resume = document.querySelector('#resume');
const confirmation = document.querySelector('#switch-confirmation');
let activeServer = null;
let pendingServer = null;
let initialized = false;
function action(name, server) {
const url = new URL(`/action/${name}`, location.href);
if (server) {
url.searchParams.set('server', server);
}
location.href = url.href;
}
window.updateLauncher = (query) => {
const state = new URLSearchParams(query);
const phase = state.get('phase');
const server = state.get('server');
if (!initialized) {
input.value = server;
initialized = true;
}
if (phase === 'connecting' || phase === 'connected') {
activeServer = server;
}
status.textContent = en[phase] ?? '';
status.dataset.state = phase;
open.disabled = phase === 'connecting';
open.textContent =
en[phase === 'connectionError' ? 'retry' : 'openWorkspace'];
resume.hidden = state.get('ready') !== 'true';
form.setAttribute('aria-busy', String(phase === 'connecting'));
};
function connect(server) {
confirmation.hidden = true;
pendingServer = null;
action('open', server);
}
form.addEventListener('submit', (event) => {
event.preventDefault();
let server;
try {
server = normalizeServer(input.value);
} catch {
status.textContent = en.invalidServer;
input.setAttribute('aria-invalid', 'true');
input.focus();
return;
}
input.removeAttribute('aria-invalid');
if (needsConfirmation(activeServer, server)) {
pendingServer = server;
confirmation.hidden = false;
document.querySelector('#confirm-switch').focus();
return;
}
connect(server);
});
document.querySelector('#confirm-switch').addEventListener('click', () => {
if (pendingServer) {
connect(pendingServer);
}
});
document.querySelector('#cancel-switch').addEventListener('click', () => {
confirmation.hidden = true;
pendingServer = null;
input.focus();
});
resume.addEventListener('click', () => action('resume'));
document.querySelector('#appearance').addEventListener('change', (event) => {
document.documentElement.dataset.appearance = event.target.value;
});
+33
View File
@@ -0,0 +1,33 @@
export function normalizeServer(input) {
const value = input.trim();
if (
!/^https:\/\//i.test(value) ||
[...value].some(
(character) =>
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127 ||
character === '\\',
)
) {
throw new Error('invalidServer');
}
const url = new URL(value);
if (
url.protocol !== 'https:' ||
!url.hostname ||
url.username ||
url.password ||
value.includes('?') ||
value.includes('#')
) {
throw new Error('invalidServer');
}
if (!url.pathname.endsWith('/')) {
url.pathname += '/';
}
return url.href;
}
export function needsConfirmation(activeServer, nextServer) {
return activeServer !== null && activeServer !== nextServer;
}
+53
View File
@@ -125,3 +125,56 @@ select {
color: inherit;
background: var(--control);
}
[hidden] {
display: none !important;
}
form {
margin-top: 24px;
}
label {
font-size: 14px;
font-weight: 600;
}
input,
button {
min-height: 44px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 4px;
font: inherit;
color: var(--foreground);
background: var(--control);
}
input {
display: block;
width: 100%;
margin-top: 8px;
font-size: 16px;
}
button {
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.open-workspace {
width: 100%;
border: 0;
margin-top: 16px;
}
#switch-confirmation {
border: 1px solid var(--border);
border-radius: 4px;
padding: 12px;
margin-bottom: 16px;
}