Files
FabledGrimoire/frontend/src/views/SettingsView.vue
T
bvandeusenandClaude Sonnet 4.6 3d7be5888e Remove intent model entirely; quick-capture uses primary model
The separate intent model (OLLAMA_INTENT_MODEL / qwen2.5:7b) is removed
from every part of the system. All classification now uses the primary model.

Changes:
- config.py: remove OLLAMA_INTENT_MODEL
- intent.py: remove classify_intent() and all supporting infrastructure
  (_SYSTEM_PROMPT_TEMPLATE, _RESEARCH_PREFIX, _PRIOR_WORK_REFS); file now
  only contains the quick-capture classifier
- quick_capture.py: classify_capture_intent() now called with Config.OLLAMA_MODEL
- generation_task.py: remove intent_model_setting DB lookup and get_setting import;
  history summarization and research pipeline use the primary model directly
- research.py: remove intent_model parameter from run_research_pipeline() and
  _generate_sub_queries(); both use the model param throughout
- routes/settings.py: remove intent_model from model-key validation and response
- app.py: remove intent model pre-warming at startup
- SettingsView.vue: remove Intent Model selector and related refs/state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 18:41:49 -05:00

1039 lines
32 KiB
Vue

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut } from "@/api/client";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const assistantName = ref("");
const defaultModel = ref("");
const installedModels = ref<string[]>([]);
const defaultChatModel = ref("");
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
const currentPassword = ref("");
const newPassword = ref("");
const confirmNewPassword = ref("");
const changingPassword = ref(false);
const saving = ref(false);
const saved = ref(false);
const exporting = ref(false);
const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Notification preferences
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
caldav_username: "",
caldav_password: "",
caldav_calendar_name: "",
});
const savingCaldav = ref(false);
const caldavSaved = ref(false);
const testingCaldav = ref(false);
const caldavTestResult = ref<{ success: boolean; message?: string; error?: string; calendars?: string[] } | null>(null);
// SMTP settings (admin only)
const smtp = ref({
smtp_host: "",
smtp_port: "587",
smtp_username: "",
smtp_password: "",
smtp_from_address: "",
smtp_from_name: "Fabled Assistant",
smtp_use_tls: "true",
});
const savingSmtp = ref(false);
const smtpSaved = ref(false);
const testRecipient = ref("");
const sendingTest = ref(false);
// Base URL setting (admin only)
const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
// Search test (SearXNG)
const searxngConfigured = ref(false);
const searxngUrl = ref("");
const searchQuery = ref("");
const searchResults = ref<{ url: string; title: string; snippet: string }[]>([]);
const searchLoading = ref(false);
const searchError = ref("");
onMounted(async () => {
await store.fetchSettings();
assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
// Load installed models and configured defaults
try {
const modelsData = await apiGet<{ models: string[]; default_chat_model: string }>("/api/settings/models");
installedModels.value = modelsData.models;
defaultChatModel.value = modelsData.default_chat_model;
} catch {
// Ollama unreachable — dropdowns will be empty
}
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
defaultModel.value = allSettings.default_model ?? "";
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
if (allSettings.notify_security_alerts !== undefined) {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load CalDAV settings
try {
const caldavConfig = await apiGet<Record<string, string>>("/api/settings/caldav");
caldav.value = { ...caldav.value, ...caldavConfig };
} catch {
// CalDAV not configured yet
}
// Check SearXNG status
try {
const sr = await apiGet<{ configured: boolean; searxng_url: string }>("/api/settings/search");
searxngConfigured.value = sr.configured;
searxngUrl.value = sr.searxng_url;
} catch {
searxngConfigured.value = false;
}
// Load admin settings
if (authStore.isAdmin) {
try {
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
smtp.value = { ...smtp.value, ...smtpConfig };
} catch {
// SMTP not configured yet
}
try {
const urlConfig = await apiGet<{ base_url: string }>("/api/admin/base-url");
baseUrl.value = urlConfig.base_url;
} catch {
// base URL not configured yet
}
}
});
async function changeEmail() {
changingEmail.value = true;
try {
const body: Record<string, string> = { email: newEmail.value.trim() };
if (authStore.user?.has_password) {
body.password = emailPassword.value;
}
const updated = await apiPut<import("@/types/auth").User>("/api/auth/email", body);
authStore.user = updated;
emailPassword.value = "";
toastStore.show("Email updated successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const b = (e as { body?: { error?: string } }).body;
toastStore.show(b?.error || "Failed to update email", "error");
} else {
toastStore.show("Failed to update email", "error");
}
} finally {
changingEmail.value = false;
}
}
async function changePassword() {
if (newPassword.value !== confirmNewPassword.value) {
toastStore.show("New passwords do not match", "error");
return;
}
changingPassword.value = true;
try {
await apiPut("/api/auth/password", {
current_password: currentPassword.value,
new_password: newPassword.value,
});
toastStore.show("Password changed successfully");
currentPassword.value = "";
newPassword.value = "";
confirmNewPassword.value = "";
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to change password", "error");
} else {
toastStore.show("Failed to change password", "error");
}
} finally {
changingPassword.value = false;
}
}
async function saveAssistant() {
saving.value = true;
saved.value = false;
try {
await store.updateSettings({
assistant_name: assistantName.value.trim() || "Fable",
default_model: defaultModel.value,
});
saved.value = true;
setTimeout(() => (saved.value = false), 2000);
} finally {
saving.value = false;
}
}
async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
const url = scope === "full" ? "/api/admin/backup" : "/api/admin/backup?scope=user";
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const data = await res.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `fabledassistant-backup-${scope}-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(a.href);
toastStore.show("Backup downloaded");
} catch (e) {
toastStore.show("Export failed: " + (e as Error).message, "error");
} finally {
exporting.value = false;
}
}
function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
async function saveNotifications() {
savingNotifications.value = true;
notificationsSaved.value = false;
try {
await apiPut("/api/settings", {
notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
});
notificationsSaved.value = true;
setTimeout(() => (notificationsSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save notification preferences", "error");
} finally {
savingNotifications.value = false;
}
}
async function saveCaldav() {
savingCaldav.value = true;
caldavSaved.value = false;
try {
await apiPut("/api/settings/caldav", caldav.value);
caldavSaved.value = true;
setTimeout(() => (caldavSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save CalDAV settings", "error");
} finally {
savingCaldav.value = false;
}
}
async function testCaldav() {
testingCaldav.value = true;
caldavTestResult.value = null;
try {
const result = await apiPost<{ success: boolean; message?: string; error?: string; calendars?: string[] }>("/api/settings/caldav/test", {});
caldavTestResult.value = result;
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
caldavTestResult.value = { success: false, error: body?.error || "Connection test failed" };
} else {
caldavTestResult.value = { success: false, error: "Connection test failed" };
}
} finally {
testingCaldav.value = false;
}
}
async function saveSmtp() {
savingSmtp.value = true;
smtpSaved.value = false;
try {
await apiPut("/api/admin/smtp", smtp.value);
smtpSaved.value = true;
setTimeout(() => (smtpSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save SMTP settings", "error");
} finally {
savingSmtp.value = false;
}
}
async function sendTestEmail() {
if (!testRecipient.value.trim()) {
toastStore.show("Enter a recipient email address", "error");
return;
}
sendingTest.value = true;
try {
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
toastStore.show("Test email sent successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send test email", "error");
} else {
toastStore.show("Failed to send test email", "error");
}
} finally {
sendingTest.value = false;
}
}
async function saveBaseUrl() {
savingBaseUrl.value = true;
baseUrlSaved.value = false;
try {
await apiPut("/api/admin/base-url", { base_url: baseUrl.value.trim() });
baseUrlSaved.value = true;
setTimeout(() => (baseUrlSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save application URL", "error");
} finally {
savingBaseUrl.value = false;
}
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
restoring.value = true;
try {
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch("/api/admin/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: `Error ${res.status}` }));
throw new Error((body as Record<string, string>).error || `Error ${res.status}`);
}
const result = await res.json();
toastStore.show(
`Restored ${result.stats?.users ?? 0} users, ${result.stats?.notes ?? 0} notes, ${result.stats?.conversations ?? 0} conversations`
);
} catch (e) {
toastStore.show("Restore failed: " + (e as Error).message, "error");
} finally {
restoring.value = false;
if (restoreFileInput.value) restoreFileInput.value.value = "";
}
}
async function testSearch() {
const q = searchQuery.value.trim();
if (!q) return;
searchLoading.value = true;
searchError.value = "";
searchResults.value = [];
try {
const data = await apiGet<{ configured: boolean; results: { url: string; title: string; snippet: string }[]; error?: string }>(
`/api/settings/search?q=${encodeURIComponent(q)}`
);
if (!data.configured) {
searchError.value = "SearXNG is not configured — set SEARXNG_URL in docker-compose.";
} else {
searchResults.value = data.results;
if (!data.results.length) searchError.value = "No results found.";
}
} catch {
searchError.value = "Search request failed.";
} finally {
searchLoading.value = false;
}
}
function onSearchKeydown(e: KeyboardEvent) {
if (e.key === "Enter") testSearch();
}
function hostname(url: string): string {
try { return new URL(url).hostname; } catch { return url; }
}
</script>
<template>
<main class="settings-page">
<h1>Settings</h1>
<div class="settings-grid">
<!-- ── Assistant ── full width ── -->
<section class="settings-section full-width">
<h2>Assistant</h2>
<div class="assistant-grid">
<div class="field">
<label for="assistant-name">Assistant Name</label>
<input
id="assistant-name"
v-model="assistantName"
type="text"
placeholder="Fable"
class="input"
/>
<p class="field-hint">The name used in chat messages and LLM context.</p>
</div>
<div class="field">
<label for="default-model">Chat Model</label>
<select id="default-model" v-model="defaultModel" class="input">
<option value="">Default ({{ defaultChatModel || "qwen3:latest" }})</option>
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
</select>
<p class="field-hint">Model used for new conversations.</p>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveAssistant" :disabled="saving">
{{ saving ? "Saving..." : "Save" }}
</button>
<span v-if="saved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- ── Email Address ── half width ── -->
<section class="settings-section">
<h2>Email Address</h2>
<p class="section-desc">Used for password resets and notifications.</p>
<div class="field">
<label for="new-email">Email</label>
<input
id="new-email"
v-model="newEmail"
type="email"
placeholder="you@example.com"
class="input"
/>
</div>
<div v-if="authStore.user?.has_password" class="field">
<label for="email-password">Current Password</label>
<input
id="email-password"
v-model="emailPassword"
type="password"
autocomplete="current-password"
class="input"
/>
<p class="field-hint">Required to confirm the change.</p>
</div>
<div class="actions">
<button
class="btn-save"
@click="changeEmail"
:disabled="changingEmail || (authStore.user?.has_password && !emailPassword)"
>
{{ changingEmail ? "Saving..." : "Save Email" }}
</button>
</div>
</section>
<!-- ── Change Password ── half width ── -->
<section class="settings-section">
<h2>Change Password</h2>
<div class="field">
<label for="current-password">Current Password</label>
<input
id="current-password"
v-model="currentPassword"
type="password"
autocomplete="current-password"
class="input"
/>
</div>
<div class="field">
<label for="new-password">New Password</label>
<input
id="new-password"
v-model="newPassword"
type="password"
autocomplete="new-password"
class="input"
/>
<p class="field-hint">Must be at least 8 characters</p>
</div>
<div class="field">
<label for="confirm-new-password">Confirm New Password</label>
<input
id="confirm-new-password"
v-model="confirmNewPassword"
type="password"
autocomplete="new-password"
class="input"
:class="{ 'input-error': confirmNewPassword && newPassword !== confirmNewPassword }"
/>
<p v-if="confirmNewPassword && newPassword !== confirmNewPassword" class="error-hint">
Passwords do not match
</p>
</div>
<div class="actions">
<button
class="btn-save"
@click="changePassword"
:disabled="changingPassword || !currentPassword || newPassword.length < 8 || newPassword !== confirmNewPassword"
>
{{ changingPassword ? "Changing..." : "Change Password" }}
</button>
</div>
</section>
<!-- ── Notifications ── half width ── -->
<section class="settings-section">
<h2>Notifications</h2>
<p class="section-desc">
Email notifications when SMTP is configured by an admin.
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="notifyTaskReminders" />
Task due date reminders
</label>
<p class="field-hint">Daily email for tasks due or overdue.</p>
</div>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="notifySecurityAlerts" />
Security alerts
</label>
<p class="field-hint">Emails for logins, logouts, and password changes.</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
{{ savingNotifications ? "Saving..." : "Save" }}
</button>
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- ── Data ── half width ── -->
<section class="settings-section">
<h2>Data</h2>
<p class="section-desc">Export your data or restore from a backup.</p>
<div class="data-actions">
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Export My Data" }}
</button>
<template v-if="authStore.isAdmin">
<button class="btn-secondary" @click="exportData('full')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Full Backup" }}
</button>
<button class="btn-secondary btn-warn" @click="triggerRestoreUpload" :disabled="restoring">
{{ restoring ? "Restoring..." : "Restore from Backup" }}
</button>
<input
ref="restoreFileInput"
type="file"
accept=".json"
class="hidden-file-input"
@change="handleRestoreFile"
/>
</template>
</div>
</section>
<!-- ── CalDAV ── full width ── -->
<section class="settings-section full-width">
<h2>Calendar (CalDAV)</h2>
<p class="section-desc">
Connect to a CalDAV server (Nextcloud, iCloud, Radicale, Baikal) to create and view calendar events from chat.
</p>
<div class="caldav-grid">
<div class="field">
<label for="caldav-url">CalDAV URL</label>
<input id="caldav-url" v-model="caldav.caldav_url" type="url" placeholder="https://cloud.example.com/remote.php/dav" class="input" />
</div>
<div class="field">
<label for="caldav-username">Username</label>
<input id="caldav-username" v-model="caldav.caldav_username" type="text" class="input" />
</div>
<div class="field">
<label for="caldav-password">Password</label>
<input id="caldav-password" v-model="caldav.caldav_password" type="password" class="input" />
</div>
<div class="field">
<label for="caldav-calendar-name">Calendar Name</label>
<input id="caldav-calendar-name" v-model="caldav.caldav_calendar_name" type="text" placeholder="Leave empty to use first available" class="input" />
<p class="field-hint">Optional. Exact calendar name to use.</p>
</div>
</div>
<div class="actions" style="margin-bottom: 1rem;">
<button class="btn-save" @click="saveCaldav" :disabled="savingCaldav">
{{ savingCaldav ? "Saving..." : "Save" }}
</button>
<span v-if="caldavSaved" class="saved-msg">Saved!</span>
<button class="btn-secondary" @click="testCaldav" :disabled="testingCaldav">
{{ testingCaldav ? "Testing..." : "Test Connection" }}
</button>
</div>
<div v-if="caldavTestResult" class="test-result" :class="{ success: caldavTestResult.success, error: !caldavTestResult.success }">
<template v-if="caldavTestResult.success">
{{ caldavTestResult.message }}
<div v-if="caldavTestResult.calendars?.length" class="test-calendars">
Calendars: {{ caldavTestResult.calendars.join(", ") }}
</div>
</template>
<template v-else>{{ caldavTestResult.error }}</template>
</div>
</section>
<!-- ── Web Search ── full width ── -->
<section class="settings-section full-width">
<h2>Web Search (SearXNG)</h2>
<template v-if="searxngConfigured">
<p class="section-desc">
Connected to <code class="url-chip">{{ searxngUrl }}</code>.
Test a query below to verify results and rate limiting.
</p>
<div class="search-row">
<input
v-model="searchQuery"
type="text"
class="input"
placeholder="Enter a search query..."
@keydown="onSearchKeydown"
/>
<button class="btn-save" @click="testSearch" :disabled="searchLoading || !searchQuery.trim()">
{{ searchLoading ? "Searching..." : "Search" }}
</button>
</div>
<p v-if="searchError" class="search-error">{{ searchError }}</p>
<ul v-if="searchResults.length" class="search-results">
<li v-for="(r, i) in searchResults" :key="i" class="search-result">
<div class="result-header">
<a :href="r.url" target="_blank" rel="noopener noreferrer" class="result-title">{{ r.title || r.url }}</a>
<span class="result-host">{{ hostname(r.url) }}</span>
</div>
<p v-if="r.snippet" class="result-snippet">{{ r.snippet }}</p>
</li>
</ul>
</template>
<template v-else>
<p class="section-desc not-configured">
Not configured. Set <code>SEARXNG_URL</code> in docker-compose to enable web research from chat.
</p>
</template>
</section>
<!-- ── Application URL (admin) ── full width ── -->
<section v-if="authStore.isAdmin" class="settings-section full-width">
<h2>Application URL</h2>
<p class="section-desc">
Public URL used in email links (invitations, password resets). Example: https://notes.example.com
</p>
<div class="field url-field">
<label for="base-url">Base URL</label>
<input
id="base-url"
v-model="baseUrl"
type="url"
placeholder="https://notes.example.com"
class="input"
/>
</div>
<div class="actions">
<button class="btn-save" @click="saveBaseUrl" :disabled="savingBaseUrl">
{{ savingBaseUrl ? "Saving..." : "Save" }}
</button>
<span v-if="baseUrlSaved" class="saved-msg">Saved!</span>
</div>
</section>
<!-- ── SMTP (admin) ── full width ── -->
<section v-if="authStore.isAdmin" class="settings-section full-width">
<h2>Email / SMTP</h2>
<p class="section-desc">Configure SMTP to enable email notifications for all users.</p>
<div class="smtp-grid">
<div class="field">
<label for="smtp-host">SMTP Host</label>
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
</div>
<div class="field">
<label for="smtp-port">Port</label>
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
</div>
<div class="field">
<label for="smtp-username">Username</label>
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
</div>
<div class="field">
<label for="smtp-password">Password</label>
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
</div>
<div class="field">
<label for="smtp-from-address">From Address</label>
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
</div>
<div class="field">
<label for="smtp-from-name">From Name</label>
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Assistant" class="input" />
</div>
</div>
<div class="checkbox-field">
<label>
<input type="checkbox" :checked="smtp.smtp_use_tls === 'true'" @change="smtp.smtp_use_tls = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
Use STARTTLS
</label>
<p class="field-hint">Recommended for port 587. Implicit TLS is used automatically for port 465.</p>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
</button>
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
</div>
<div class="test-email-section">
<h3 class="subsection-label">Test Email</h3>
<div class="test-email-row">
<input
v-model="testRecipient"
type="email"
placeholder="test@example.com"
class="input"
/>
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
{{ sendingTest ? "Sending..." : "Send Test" }}
</button>
</div>
</div>
</section>
</div>
</main>
</template>
<style scoped>
.settings-page {
max-width: 1100px;
margin: 2rem auto;
padding: 0 1.5rem;
}
.settings-page h1 {
margin: 0 0 1.5rem;
}
/* Two-column grid — small sections pair up, full-width sections span both */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
align-items: start;
}
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
}
.settings-section.full-width {
grid-column: 1 / -1;
}
.settings-section h2 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
}
.section-desc {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
line-height: 1.5;
}
/* Assistant — 3-col internal grid */
.assistant-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.assistant-grid {
grid-template-columns: 1fr;
}
}
.field {
margin-bottom: 1rem;
}
.field label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: var(--color-text);
}
.input {
width: 100%;
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
font-family: inherit;
}
.input:focus {
outline: none;
border-color: var(--color-primary);
}
.field-hint {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.actions {
display: flex;
align-items: center;
gap: 0.65rem;
flex-wrap: wrap;
}
.btn-save {
padding: 0.4rem 0.9rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
}
.btn-save:disabled { opacity: 0.6; cursor: default; }
.btn-save:hover:not(:disabled) { opacity: 0.9; }
.btn-secondary {
padding: 0.4rem 0.9rem;
background: var(--color-bg-secondary);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
white-space: nowrap;
}
.btn-secondary:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-secondary:disabled { opacity: 0.6; cursor: default; }
.btn-warn:hover:not(:disabled) {
border-color: var(--color-warning);
color: var(--color-warning);
}
.saved-msg {
color: var(--color-success);
font-size: 0.875rem;
font-weight: 600;
}
.input-error { border-color: var(--color-danger); }
.input-error:focus { border-color: var(--color-danger); }
.error-hint {
margin: 0.3rem 0 0;
font-size: 0.78rem;
color: var(--color-danger);
}
/* Data buttons */
.data-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.hidden-file-input { display: none; }
/* Checkboxes */
.checkbox-field { margin-bottom: 1rem; }
.checkbox-field label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--color-text);
cursor: pointer;
}
.checkbox-field input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
/* CalDAV grid */
.caldav-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.caldav-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.caldav-grid { grid-template-columns: 1fr; }
}
.test-result {
padding: 0.65rem 0.9rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
line-height: 1.4;
}
.test-result.success {
background: color-mix(in srgb, var(--color-success) 10%, var(--color-bg));
border: 1px solid var(--color-success);
color: var(--color-success);
}
.test-result.error {
background: color-mix(in srgb, var(--color-danger) 10%, var(--color-bg));
border: 1px solid var(--color-danger);
color: var(--color-danger);
}
.test-calendars {
margin-top: 0.3rem;
font-size: 0.82rem;
opacity: 0.85;
}
/* Search test */
.url-chip {
font-size: 0.8rem;
padding: 0.1rem 0.4rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 4px;
}
.not-configured {
font-style: italic;
opacity: 0.75;
}
.search-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.75rem;
}
.search-row .input { flex: 1; }
.search-error {
font-size: 0.875rem;
color: var(--color-danger);
margin: 0 0 0.5rem;
}
.search-results {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.search-result {
padding: 0.65rem 0.85rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.result-header {
display: flex;
align-items: baseline;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.result-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-primary);
text-decoration: none;
word-break: break-word;
}
.result-title:hover { text-decoration: underline; }
.result-host {
font-size: 0.75rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.result-snippet {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
line-height: 1.45;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Application URL — constrain input width */
.url-field { max-width: 480px; }
/* SMTP grid */
.smtp-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
.smtp-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 480px) {
.smtp-grid { grid-template-columns: 1fr; }
}
/* Test email */
.subsection-label {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.test-email-section {
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.test-email-row {
display: flex;
gap: 0.5rem;
align-items: center;
max-width: 480px;
}
/* Responsive — collapse to 1 column on narrow screens */
@media (max-width: 640px) {
.settings-grid {
grid-template-columns: 1fr;
}
.settings-section.full-width {
grid-column: 1;
}
.settings-page {
padding: 0 1rem;
margin: 1rem auto;
}
}
</style>