Settings: reorganise into tabs (General, Account, Notifications, Integrations, Data, Admin)

Replaces the single long scrolling page with a tab bar. Active tab
persists to localStorage across navigation. Admin tab only visible to
admins. Push notifications description clarified to mention HTTPS requirement.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 23:20:33 -04:00
parent 4327b9f858
commit 12644999c1
+249 -171
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
@@ -28,6 +28,10 @@ const exporting = ref(false);
const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Tab state — persisted across navigation
const activeTab = ref(localStorage.getItem("settings_tab") ?? "general");
watch(activeTab, (v) => localStorage.setItem("settings_tab", v));
// Chat retention
const chatRetentionDays = ref(90);
const savingRetention = ref(false);
@@ -439,9 +443,24 @@ function hostname(url: string): string {
<main class="settings-page">
<h1>Settings</h1>
<div class="settings-grid">
<!-- Tab bar -->
<div class="settings-tabs" role="tablist">
<button
v-for="tab in (authStore.isAdmin
? ['general', 'account', 'notifications', 'integrations', 'data', 'admin']
: ['general', 'account', 'notifications', 'integrations', 'data'])"
:key="tab"
role="tab"
:aria-selected="activeTab === tab"
:class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<!-- Assistant full width -->
<!-- ── General ── -->
<div v-show="activeTab === 'general'" class="settings-grid">
<section class="settings-section full-width">
<h2>Assistant</h2>
<div class="assistant-grid">
@@ -472,8 +491,11 @@ function hostname(url: string): string {
<span v-if="saved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<!-- ── Account ── -->
<div v-show="activeTab === 'account'" class="settings-grid">
<!-- Email Address half width -->
<section class="settings-section">
<h2>Email Address</h2>
<p class="section-desc">Used for password resets and notifications.</p>
@@ -509,21 +531,6 @@ function hostname(url: string): string {
</div>
</section>
<!-- Invalidate Sessions half width -->
<section class="settings-section">
<h2>Active Sessions</h2>
<p class="section-desc">
Sign out all other devices and sessions. Use this after an SSO password change
to ensure stale sessions are revoked.
</p>
<div class="actions">
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
</button>
</div>
</section>
<!-- Change Password half width -->
<section class="settings-section">
<h2>Change Password</h2>
<div class="field">
@@ -572,9 +579,26 @@ function hostname(url: string): string {
</div>
</section>
<!-- Notifications half width -->
<section class="settings-section">
<h2>Notifications</h2>
<h2>Active Sessions</h2>
<p class="section-desc">
Sign out all other devices and sessions. Use this after an SSO password change
to ensure stale sessions are revoked.
</p>
<div class="actions">
<button class="btn-danger-outline" @click="invalidateSessions" :disabled="invalidatingSessions">
{{ invalidatingSessions ? "Invalidating..." : "Invalidate All Other Sessions" }}
</button>
</div>
</section>
</div>
<!-- ── Notifications ── -->
<div v-show="activeTab === 'notifications'" class="settings-grid">
<section class="settings-section">
<h2>Email Notifications</h2>
<p class="section-desc">
Email notifications when SMTP is configured by an admin.
</p>
@@ -600,153 +624,13 @@ function hostname(url: string): string {
</div>
</section>
<!-- Chat Retention half width -->
<section class="settings-section">
<h2>Chat History</h2>
<p class="section-desc">
Conversations older than this many days are automatically deleted when you load the chat.
Set to <strong>0</strong> to keep conversations forever.
</p>
<div class="field retention-field">
<label class="field-label">Retention period (days)</label>
<div class="retention-row">
<input
v-model.number="chatRetentionDays"
type="number"
min="0"
max="3650"
class="input retention-input"
/>
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
{{ savingRetention ? 'Saving...' : 'Save' }}
</button>
</div>
</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="exportNotes('markdown')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
</button>
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
</button>
<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>
<!-- Push Notifications half width -->
<section class="settings-section">
<h2>Push Notifications</h2>
<p class="section-desc">
Receive browser push notifications when a response is ready.
Receive browser push notifications when a response is ready. Requires HTTPS.
</p>
<template v-if="!pushStore.isSupported">
<p class="push-unsupported">Push notifications are not supported in this browser.</p>
<p class="push-unsupported">Push notifications are not supported in this browser or connection (requires HTTPS).</p>
</template>
<template v-else>
<div class="push-status-row">
@@ -792,8 +676,164 @@ function hostname(url: string): string {
</template>
</section>
<!-- Application URL (admin) full width -->
<section v-if="authStore.isAdmin" class="settings-section full-width">
<section class="settings-section">
<h2>Chat History</h2>
<p class="section-desc">
Conversations older than this many days are automatically deleted when you load the chat.
Set to <strong>0</strong> to keep conversations forever.
</p>
<div class="field retention-field">
<label class="field-label">Retention period (days)</label>
<div class="retention-row">
<input
v-model.number="chatRetentionDays"
type="number"
min="0"
max="3650"
class="input retention-input"
/>
<button class="btn-primary" :disabled="savingRetention" @click="saveRetention">
{{ savingRetention ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</section>
</div>
<!-- ── Integrations ── -->
<div v-show="activeTab === 'integrations'" class="settings-grid">
<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>
<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>
</div>
<!-- ── Data ── -->
<div v-show="activeTab === 'data'" class="settings-grid">
<section class="settings-section full-width">
<h2>Export</h2>
<p class="section-desc">Download your notes and tasks in portable formats.</p>
<div class="data-actions">
<button class="btn-secondary" @click="exportNotes('markdown')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as Markdown" }}
</button>
<button class="btn-secondary" @click="exportNotes('json')" :disabled="exportingNotes">
{{ exportingNotes ? "Exporting..." : "Export as JSON" }}
</button>
<button class="btn-secondary" @click="exportData('user')" :disabled="exporting">
{{ exporting ? "Exporting..." : "Export My Data" }}
</button>
</div>
</section>
<template v-if="authStore.isAdmin">
<section class="settings-section full-width">
<h2>Backup &amp; Restore</h2>
<p class="section-desc">Full application backup includes all users and their data.</p>
<div class="data-actions">
<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"
/>
</div>
</section>
</template>
</div>
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'admin'" class="settings-grid">
<section 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
@@ -816,8 +856,7 @@ function hostname(url: string): string {
</div>
</section>
<!-- SMTP (admin) full width -->
<section v-if="authStore.isAdmin" class="settings-section full-width">
<section 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">
@@ -876,6 +915,7 @@ function hostname(url: string): string {
</section>
</div>
</main>
</template>
@@ -886,7 +926,39 @@ function hostname(url: string): string {
padding: 0 1.5rem;
}
.settings-page h1 {
margin: 0 0 1.5rem;
margin: 0 0 1.25rem;
}
/* Tab bar */
.settings-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 2px solid var(--color-border);
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.tab-btn {
padding: 0.5rem 1rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
color: var(--color-text-secondary);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
transition: color 0.15s, border-color 0.15s, background 0.15s;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--color-text);
background: var(--color-bg-secondary);
}
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
/* Two-column grid — small sections pair up, full-width sections span both */
@@ -916,10 +988,10 @@ function hostname(url: string): string {
line-height: 1.5;
}
/* Assistant — 3-col internal grid */
/* Assistant — 2-col internal grid */
.assistant-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
grid-template-columns: 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 700px) {
@@ -1035,7 +1107,6 @@ function hostname(url: string): string {
gap: 0.5rem;
margin-top: 0.4rem;
}
.retention-input {
width: 6rem;
}
@@ -1251,5 +1322,12 @@ function hostname(url: string): string {
padding: 0 1rem;
margin: 1rem auto;
}
.settings-tabs {
gap: 0;
}
.tab-btn {
padding: 0.45rem 0.7rem;
font-size: 0.82rem;
}
}
</style>