70cba72a80
Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
(_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
conversation history support for anaphora resolution; routing rules for
update/delete events, todos, update_note vs create_note disambiguation,
time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var
Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
isConversational computed — prominent "Continue this conversation" CTA when
no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
822 lines
24 KiB
Vue
822 lines
24 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 intentModel = ref("");
|
|
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);
|
|
|
|
onMounted(async () => {
|
|
await store.fetchSettings();
|
|
assistantName.value = store.assistantName;
|
|
|
|
// Load notification preferences from user settings
|
|
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
|
intentModel.value = allSettings.intent_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
|
|
}
|
|
|
|
// 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 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",
|
|
intent_model: intentModel.value.trim(),
|
|
});
|
|
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;
|
|
// Reset file input
|
|
if (restoreFileInput.value) restoreFileInput.value.value = "";
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<main class="settings-page">
|
|
<h1>Settings</h1>
|
|
|
|
<section class="settings-section">
|
|
<h2>Assistant</h2>
|
|
<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 for the AI assistant in chat messages and LLM context.
|
|
</p>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="intent-model">Intent Model</label>
|
|
<input
|
|
id="intent-model"
|
|
v-model="intentModel"
|
|
type="text"
|
|
placeholder="Same as chat model"
|
|
class="input"
|
|
/>
|
|
<p class="field-hint">
|
|
Optional smaller/faster model for intent routing (e.g. <code>llama3.2:3b</code>,
|
|
<code>qwen2.5:3b</code>). Leave empty to use the same model as chat.
|
|
Can also be set via <code>OLLAMA_INTENT_MODEL</code> environment variable.
|
|
</p>
|
|
</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>
|
|
|
|
<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>
|
|
|
|
<section class="settings-section">
|
|
<h2>Notifications</h2>
|
|
<p class="section-desc">
|
|
Choose which email notifications you'd like to receive. Requires SMTP to be 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">Receive a daily email when you have tasks due or overdue.</p>
|
|
</div>
|
|
<div class="checkbox-field">
|
|
<label>
|
|
<input type="checkbox" v-model="notifySecurityAlerts" />
|
|
Security alerts
|
|
</label>
|
|
<p class="field-hint">Receive emails for login, logout, failed login attempts, 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>
|
|
|
|
<section class="settings-section">
|
|
<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. The exact name of the calendar 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-save btn-test" @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>
|
|
|
|
<section v-if="authStore.isAdmin" class="settings-section">
|
|
<h2>Application URL</h2>
|
|
<p class="section-desc">
|
|
The public URL used in email links (invitations, password resets). Example: https://notes.example.com
|
|
</p>
|
|
<div class="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>
|
|
|
|
<section v-if="authStore.isAdmin" class="settings-section">
|
|
<h2>Email / SMTP</h2>
|
|
<p class="section-desc">
|
|
Configure SMTP settings 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">Enable STARTTLS encryption (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"
|
|
style="flex: 1;"
|
|
/>
|
|
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
|
|
{{ sendingTest ? "Sending..." : "Send Test" }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<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-export"
|
|
@click="exportData('user')"
|
|
:disabled="exporting"
|
|
>
|
|
{{ exporting ? "Exporting..." : "Export My Data" }}
|
|
</button>
|
|
|
|
<template v-if="authStore.isAdmin">
|
|
<button
|
|
class="btn-export"
|
|
@click="exportData('full')"
|
|
:disabled="exporting"
|
|
>
|
|
{{ exporting ? "Exporting..." : "Export Full Backup" }}
|
|
</button>
|
|
<button
|
|
class="btn-restore"
|
|
@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>
|
|
</main>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.settings-page {
|
|
max-width: 960px;
|
|
margin: 2rem auto;
|
|
padding: 0 1rem;
|
|
}
|
|
.settings-page h1 {
|
|
margin: 0 0 1.5rem;
|
|
}
|
|
.settings-section {
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
padding: 1.25rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.settings-section h2 {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
.section-desc {
|
|
margin: 0 0 1rem;
|
|
font-size: 0.9rem;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
.field {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.field label {
|
|
display: block;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.35rem;
|
|
color: var(--color-text);
|
|
}
|
|
.input {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.95rem;
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
box-sizing: border-box;
|
|
}
|
|
.input:focus {
|
|
outline: none;
|
|
border-color: var(--color-primary);
|
|
}
|
|
.field-hint {
|
|
margin: 0.35rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--color-text-muted);
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
.btn-save {
|
|
padding: 0.45rem 1rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: var(--radius-sm);
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
}
|
|
.btn-save:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-save:hover:not(:disabled) {
|
|
opacity: 0.9;
|
|
}
|
|
.saved-msg {
|
|
color: var(--color-success);
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
.input-error {
|
|
border-color: var(--color-danger);
|
|
}
|
|
.input-error:focus {
|
|
border-color: var(--color-danger);
|
|
}
|
|
.error-hint {
|
|
margin: 0.35rem 0 0;
|
|
font-size: 0.8rem;
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
/* Data section */
|
|
.data-actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
}
|
|
.btn-export {
|
|
padding: 0.45rem 1rem;
|
|
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.9rem;
|
|
}
|
|
.btn-export:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
}
|
|
.btn-export:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.btn-restore {
|
|
padding: 0.45rem 1rem;
|
|
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.9rem;
|
|
}
|
|
.btn-restore:hover:not(:disabled) {
|
|
border-color: var(--color-warning);
|
|
color: var(--color-warning);
|
|
}
|
|
.btn-restore:disabled {
|
|
opacity: 0.6;
|
|
cursor: default;
|
|
}
|
|
.hidden-file-input {
|
|
display: none;
|
|
}
|
|
|
|
/* Checkbox fields */
|
|
.checkbox-field {
|
|
margin-bottom: 1rem;
|
|
}
|
|
.checkbox-field label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
font-size: 0.95rem;
|
|
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;
|
|
gap: 0 1rem;
|
|
}
|
|
@media (max-width: 480px) {
|
|
.caldav-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
.btn-test {
|
|
background: var(--color-bg-secondary);
|
|
color: var(--color-text);
|
|
border: 1px solid var(--color-border);
|
|
}
|
|
.btn-test:hover:not(:disabled) {
|
|
border-color: var(--color-primary);
|
|
color: var(--color-primary);
|
|
opacity: 1;
|
|
}
|
|
.test-result {
|
|
padding: 0.75rem 1rem;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 0.9rem;
|
|
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.35rem;
|
|
font-size: 0.85rem;
|
|
opacity: 0.85;
|
|
}
|
|
|
|
/* SMTP grid */
|
|
.smtp-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 0 1rem;
|
|
}
|
|
@media (max-width: 480px) {
|
|
.smtp-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
/* Test email */
|
|
.subsection-label {
|
|
margin: 0 0 0.5rem;
|
|
font-size: 0.9rem;
|
|
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;
|
|
}
|
|
</style>
|