73ac08af17
apps/web/src/app/icon.svg (Next.js-Favicon, keine next-intl-Anbindung moeglich): <title>Tessera</title> als einzige Fundstelle, die die ganze Bedeutung allein traegt. apps/desktop/src/setup.html: Bildmarke steht direkt vor der Ueberschrift "Tessera" und wird dekorativ (aria-hidden="true"). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TPPB4ApQxzSU1rwV2Ffj9J
395 lines
11 KiB
HTML
395 lines
11 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Tessera – Desktop-App einrichten</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
min-height: 100vh;
|
||
background: oklch(0.17 0.01 260);
|
||
color: oklch(0.95 0 0);
|
||
-webkit-font-smoothing: antialiased;
|
||
-moz-osx-font-smoothing: grayscale;
|
||
}
|
||
|
||
.setup-card {
|
||
text-align: center;
|
||
max-width: 440px;
|
||
width: 100%;
|
||
padding: 48px 32px;
|
||
background: oklch(0.22 0.01 260);
|
||
border-radius: 12px;
|
||
border: 1px solid oklch(0.30 0.01 260);
|
||
}
|
||
|
||
.brand-mark {
|
||
margin: 0 auto 16px;
|
||
width: 56px;
|
||
height: 56px;
|
||
}
|
||
|
||
h1 {
|
||
font-size: 2rem;
|
||
font-weight: 700;
|
||
margin-bottom: 8px;
|
||
color: #ffed00;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 0.875rem;
|
||
color: oklch(0.65 0 0);
|
||
margin-bottom: 32px;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
text-align: left;
|
||
font-size: 0.875rem;
|
||
font-weight: 500;
|
||
margin-bottom: 8px;
|
||
color: oklch(0.85 0 0);
|
||
}
|
||
|
||
.hint {
|
||
text-align: left;
|
||
font-size: 0.8125rem;
|
||
color: oklch(0.60 0 0);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
input[type="url"] {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
border-radius: 8px;
|
||
border: 1px solid oklch(0.30 0.01 260);
|
||
background: oklch(0.20 0.01 260);
|
||
color: oklch(0.95 0 0);
|
||
font-size: 1rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.2s;
|
||
}
|
||
|
||
input[type="url"]:focus {
|
||
border-color: #9c9440;
|
||
}
|
||
|
||
input[type="url"]::placeholder {
|
||
color: oklch(0.45 0 0);
|
||
}
|
||
|
||
.error-message {
|
||
display: none;
|
||
text-align: left;
|
||
font-size: 0.8125rem;
|
||
color: oklch(0.55 0.2 27);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.warning-message {
|
||
display: none;
|
||
text-align: left;
|
||
font-size: 0.8125rem;
|
||
color: oklch(0.75 0.15 85);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
.info-message {
|
||
display: none;
|
||
text-align: left;
|
||
font-size: 0.8125rem;
|
||
color: oklch(0.70 0.15 150);
|
||
margin-top: 6px;
|
||
}
|
||
|
||
button {
|
||
margin-top: 24px;
|
||
width: 100%;
|
||
padding: 12px 32px;
|
||
border-radius: 8px;
|
||
border: none;
|
||
background: #ffed00;
|
||
color: #1a1a1a;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
font-weight: 600;
|
||
font-family: inherit;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
button:hover {
|
||
opacity: 0.9;
|
||
}
|
||
|
||
button:active {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.client-info {
|
||
margin: 20px 0 0;
|
||
font-size: 0.75rem;
|
||
color: oklch(0.6 0 0);
|
||
min-height: 1em;
|
||
}
|
||
|
||
.current-server {
|
||
display: none;
|
||
text-align: left;
|
||
font-size: 0.8125rem;
|
||
color: oklch(0.75 0 0);
|
||
margin: -20px 0 24px;
|
||
}
|
||
|
||
button.secondary {
|
||
margin-top: 12px;
|
||
background: transparent;
|
||
color: oklch(0.85 0 0);
|
||
border: 1px solid oklch(0.30 0.01 260);
|
||
}
|
||
|
||
button[hidden] {
|
||
display: none;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="setup-card">
|
||
<svg class="brand-mark" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 72 72" width="56" height="56">
|
||
<rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a"></rect>
|
||
<g>
|
||
<rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
|
||
<rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
|
||
<rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"></rect>
|
||
<rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
|
||
<rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"></rect>
|
||
</g>
|
||
</svg>
|
||
<h1>Tessera</h1>
|
||
<p class="subtitle" id="subtitle">Desktop-App einrichten</p>
|
||
<p id="current-server" class="current-server"></p>
|
||
|
||
<label for="server-url">Adresse Ihres Tessera-Servers</label>
|
||
<p class="hint">Das ist die Adresse, unter der Sie Tessera auch im Browser öffnen.</p>
|
||
<input
|
||
id="server-url"
|
||
type="url"
|
||
placeholder="https://tessera.example.com"
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
/>
|
||
<p id="error-msg" class="error-message"></p>
|
||
<p id="warning-msg" class="warning-message"></p>
|
||
<p id="info-msg" class="info-message"></p>
|
||
|
||
<button id="connect-btn" type="button">Verbinden</button>
|
||
<button id="cancel-btn" type="button" class="secondary" hidden>Abbrechen</button>
|
||
<p id="client-info" class="client-info"></p>
|
||
</div>
|
||
|
||
<script type="module">
|
||
const { invoke } = window.__TAURI__.core;
|
||
|
||
const urlInput = document.getElementById('server-url');
|
||
const connectBtn = document.getElementById('connect-btn');
|
||
const cancelBtn = document.getElementById('cancel-btn');
|
||
const errorMsg = document.getElementById('error-msg');
|
||
const warningMsg = document.getElementById('warning-msg');
|
||
const infoMsg = document.getElementById('info-msg');
|
||
const subtitle = document.getElementById('subtitle');
|
||
const currentServer = document.getElementById('current-server');
|
||
|
||
/**
|
||
* Validate a server URL using the URL constructor.
|
||
* Returns { valid: boolean, warning: string | null, error: string | null }
|
||
*/
|
||
function validateUrl(input) {
|
||
const trimmed = input.trim();
|
||
|
||
if (!trimmed) {
|
||
return { valid: false, warning: null, error: 'Bitte geben Sie die Adresse Ihres Tessera-Servers ein.' };
|
||
}
|
||
|
||
let parsed;
|
||
try {
|
||
parsed = new URL(trimmed);
|
||
} catch {
|
||
return {
|
||
valid: false,
|
||
warning: null,
|
||
error: 'Diese Adresse ist ungültig. Bitte geben Sie eine vollständige Adresse ein, z. B. https://tessera.example.com.',
|
||
};
|
||
}
|
||
|
||
// Only allow http and https protocols
|
||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||
return { valid: false, warning: null, error: 'Es sind nur Adressen mit http oder https erlaubt.' };
|
||
}
|
||
|
||
// Warn on non-https for non-localhost addresses (T-06-01 mitigation)
|
||
const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1';
|
||
if (parsed.protocol === 'http:' && !isLocalhost) {
|
||
return {
|
||
valid: true,
|
||
warning: 'Hinweis: Diese Verbindung ist unverschlüsselt (http). Für den Produktivbetrieb empfehlen wir https.',
|
||
error: null,
|
||
};
|
||
}
|
||
|
||
return { valid: true, warning: null, error: null };
|
||
}
|
||
|
||
function showError(message) {
|
||
errorMsg.textContent = message;
|
||
errorMsg.style.display = 'block';
|
||
warningMsg.style.display = 'none';
|
||
infoMsg.style.display = 'none';
|
||
}
|
||
|
||
function showWarning(message) {
|
||
warningMsg.textContent = message;
|
||
warningMsg.style.display = 'block';
|
||
errorMsg.style.display = 'none';
|
||
infoMsg.style.display = 'none';
|
||
}
|
||
|
||
function showInfo(message) {
|
||
infoMsg.textContent = message;
|
||
infoMsg.style.display = 'block';
|
||
errorMsg.style.display = 'none';
|
||
}
|
||
|
||
function clearMessages() {
|
||
errorMsg.style.display = 'none';
|
||
warningMsg.style.display = 'none';
|
||
infoMsg.style.display = 'none';
|
||
}
|
||
|
||
async function connect() {
|
||
clearMessages();
|
||
|
||
const rawUrl = urlInput.value;
|
||
const result = validateUrl(rawUrl);
|
||
|
||
if (!result.valid) {
|
||
showError(result.error);
|
||
return;
|
||
}
|
||
|
||
if (result.warning) {
|
||
showWarning(result.warning);
|
||
}
|
||
|
||
// Normalize the URL
|
||
const normalizedUrl = new URL(rawUrl.trim()).href;
|
||
|
||
// Disable button while the address is being checked
|
||
connectBtn.disabled = true;
|
||
connectBtn.textContent = 'Prüfe Verbindung …';
|
||
|
||
let version;
|
||
try {
|
||
version = await invoke('check_server', { url: normalizedUrl });
|
||
} catch (err) {
|
||
showError(String(err));
|
||
connectBtn.disabled = false;
|
||
connectBtn.textContent = 'Verbinden';
|
||
return;
|
||
}
|
||
|
||
showInfo(`Tessera ${version} gefunden – Verbindung wird hergestellt …`);
|
||
|
||
try {
|
||
await invoke('save_server_url', { url: normalizedUrl });
|
||
} catch (err) {
|
||
showError(`Die Adresse konnte nicht gespeichert werden: ${String(err)}`);
|
||
connectBtn.disabled = false;
|
||
connectBtn.textContent = 'Verbinden';
|
||
}
|
||
}
|
||
|
||
// Button click handler
|
||
connectBtn.addEventListener('click', connect);
|
||
|
||
// Enter key support
|
||
urlInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
connect();
|
||
}
|
||
});
|
||
|
||
// Clear error messages on input change
|
||
urlInput.addEventListener('input', clearMessages);
|
||
|
||
/**
|
||
* Aenderungsmodus: die Adresse steht bereits im Store (get_server_url
|
||
* liefert einen Wert). Feld vorbelegen, aktuelle Adresse anzeigen und
|
||
* den "Abbrechen"-Knopf einblenden. Der eingesetzte Wert kommt per
|
||
* textContent/value in die Seite, nie als HTML (T-JN2-05).
|
||
*/
|
||
function enterChangeMode(current) {
|
||
urlInput.value = current;
|
||
currentServer.textContent = `Aktuell verbunden mit: ${current}`;
|
||
currentServer.style.display = 'block';
|
||
subtitle.textContent = 'Server-Adresse ändern';
|
||
cancelBtn.hidden = false;
|
||
urlInput.focus();
|
||
urlInput.select();
|
||
}
|
||
|
||
cancelBtn.addEventListener('click', async () => {
|
||
clearMessages();
|
||
cancelBtn.disabled = true;
|
||
try {
|
||
await invoke('open_server');
|
||
} catch (err) {
|
||
showError(String(err));
|
||
cancelBtn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// Beim Laden pruefen, ob bereits eine Server-Adresse gespeichert ist --
|
||
// dann ist dies der Aenderungsmodus, nicht der Erststart. Ohne
|
||
// gespeicherte Adresse (oder wenn der Aufruf fehlschlaegt) bleibt es
|
||
// beim bisherigen Erststart-Verhalten.
|
||
async function init() {
|
||
// Version und Stand der installierten App -- rein informativ; ein
|
||
// Fehlschlag laesst die Zeile einfach leer.
|
||
try {
|
||
const info = await invoke('get_client_info');
|
||
if (typeof info === 'string') {
|
||
document.getElementById('client-info').textContent = info;
|
||
}
|
||
} catch {
|
||
// Zeile bleibt leer.
|
||
}
|
||
try {
|
||
const current = await invoke('get_server_url');
|
||
if (typeof current === 'string' && current) {
|
||
enterChangeMode(current);
|
||
}
|
||
} catch {
|
||
// Erststart-Verhalten: Feld bleibt leer, kein Aenderungsmodus.
|
||
}
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|