102c0b74a0
Replace the hand-edit-JSON instructions in Settings → API Keys with a tabbed UI (Claude Code / Claude Desktop / Other). The Claude Code tab leads with the `claude mcp add` command, pre-filled with FABLE_URL and the most recently generated API key, plus copy-to-clipboard buttons on every snippet. Recommend `uv tool install` or `pipx install` over bare `pip install` so fable-mcp reliably lands on PATH under PEP 668. Also fix incorrect priority values in fable-mcp tool docstrings — the enum is `none|low|medium|high`, not `low|normal|high`. DRY pass: extract shared `copyToClipboard()` helper used by both `copyApiKey` and the new snippet buttons; reuse existing `btn btn-secondary btn-sm` for the copy buttons instead of a bespoke class. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4582 lines
155 KiB
Vue
4582 lines
155 KiB
Vue
<script setup lang="ts">
|
||
import { ref, computed, watch, onMounted } from "vue";
|
||
import { useSettingsStore } from "@/stores/settings";
|
||
import { useAuthStore } from "@/stores/auth";
|
||
import { useToastStore } from "@/stores/toast";
|
||
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
|
||
import { usePushStore } from "@/stores/push";
|
||
import type { User } from "@/types/auth";
|
||
import PaginationBar from "@/components/PaginationBar.vue";
|
||
import TagInput from "@/components/TagInput.vue";
|
||
|
||
const store = useSettingsStore();
|
||
const authStore = useAuthStore();
|
||
const toastStore = useToastStore();
|
||
const pushStore = usePushStore();
|
||
const assistantName = ref("");
|
||
const defaultModel = ref("");
|
||
const userTimezone = ref("");
|
||
const savingTimezone = ref(false);
|
||
const timezoneSaved = ref(false);
|
||
|
||
function detectTimezone() {
|
||
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||
}
|
||
|
||
async function saveTimezone() {
|
||
savingTimezone.value = true;
|
||
timezoneSaved.value = false;
|
||
try {
|
||
await apiPut('/api/settings', { user_timezone: userTimezone.value });
|
||
timezoneSaved.value = true;
|
||
setTimeout(() => (timezoneSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save timezone', 'error');
|
||
} finally {
|
||
savingTimezone.value = false;
|
||
}
|
||
}
|
||
const backgroundModel = ref("");
|
||
const installedModels = ref<string[]>([]);
|
||
const defaultChatModel = ref("");
|
||
|
||
interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
|
||
const ollamaModels = ref<OllamaModel[]>([]);
|
||
const pullModelName = ref("");
|
||
const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
|
||
const pulling = ref(false);
|
||
const deletingModel = ref<string | null>(null);
|
||
const newEmail = ref("");
|
||
const emailPassword = ref("");
|
||
const changingEmail = ref(false);
|
||
const currentPassword = ref("");
|
||
const newPassword = ref("");
|
||
const confirmNewPassword = ref("");
|
||
const changingPassword = ref(false);
|
||
const invalidatingSessions = ref(false);
|
||
const saving = ref(false);
|
||
const saved = ref(false);
|
||
const exporting = ref(false);
|
||
const restoring = ref(false);
|
||
const appVersion = ref('dev');
|
||
const restoreFileInput = ref<HTMLInputElement | null>(null);
|
||
|
||
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
|
||
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
|
||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
|
||
|
||
function _loadTabContent(tab: string) {
|
||
if (authStore.isAdmin) {
|
||
if (tab === "users") loadUsersPanel();
|
||
else if (tab === "logs") loadLogsPanel();
|
||
else if (tab === "groups") loadGroupsPanel();
|
||
}
|
||
if (tab === "briefing") loadBriefingTab();
|
||
if (tab === "voice") loadVoiceTab();
|
||
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||
}
|
||
|
||
watch(activeTab, (v) => {
|
||
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
||
_loadTabContent(v);
|
||
});
|
||
|
||
// API Keys
|
||
const apiKeys = ref<ApiKeyEntry[]>([]);
|
||
const newKeyName = ref('');
|
||
const newKeyScope = ref<'read' | 'write'>('write');
|
||
const newKeyValue = ref('');
|
||
const apiKeyCopied = ref(false);
|
||
const creatingApiKey = ref(false);
|
||
const revokeConfirmId = ref<number | null>(null);
|
||
const mcpInfo = ref<{ available: boolean; filename: string | null } | null>(null);
|
||
const mcpInfoLoading = ref(false);
|
||
|
||
const origin = window.location.origin;
|
||
const mcpClientTab = ref<'claude-code' | 'claude-desktop' | 'other'>('claude-code');
|
||
const copiedSnippetKey = ref<string | null>(null);
|
||
|
||
const effectiveApiKey = computed(() => newKeyValue.value || '<your-api-key>');
|
||
|
||
const claudeCodeCommand = computed(() => {
|
||
return `claude mcp add --transport stdio --scope user fable \\
|
||
--env FABLE_URL=${origin} \\
|
||
--env FABLE_API_KEY=${effectiveApiKey.value} \\
|
||
-- fable-mcp`;
|
||
});
|
||
|
||
const mcpConfigSnippet = computed(() => JSON.stringify({
|
||
mcpServers: {
|
||
fable: {
|
||
command: "fable-mcp",
|
||
env: {
|
||
FABLE_URL: origin,
|
||
FABLE_API_KEY: effectiveApiKey.value,
|
||
},
|
||
},
|
||
},
|
||
}, null, 2));
|
||
|
||
async function copyToClipboard(text: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch {
|
||
// Fallback for http (non-secure) contexts where clipboard API is unavailable
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
ta.style.position = 'fixed';
|
||
ta.style.opacity = '0';
|
||
document.body.appendChild(ta);
|
||
ta.focus();
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
}
|
||
}
|
||
|
||
async function copySnippet(text: string, key: string) {
|
||
await copyToClipboard(text);
|
||
copiedSnippetKey.value = key;
|
||
setTimeout(() => {
|
||
if (copiedSnippetKey.value === key) copiedSnippetKey.value = null;
|
||
}, 2000);
|
||
}
|
||
|
||
async function loadMcpInfo() {
|
||
if (mcpInfo.value !== null) return;
|
||
mcpInfoLoading.value = true;
|
||
try {
|
||
mcpInfo.value = await getFableMcpInfo();
|
||
} catch {
|
||
mcpInfo.value = { available: false, filename: null };
|
||
} finally {
|
||
mcpInfoLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function fetchApiKeys() {
|
||
apiKeys.value = await listApiKeys();
|
||
}
|
||
|
||
async function createApiKey() {
|
||
if (!newKeyName.value) return;
|
||
creatingApiKey.value = true;
|
||
try {
|
||
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
|
||
newKeyValue.value = data.key;
|
||
newKeyName.value = '';
|
||
await fetchApiKeys();
|
||
} finally {
|
||
creatingApiKey.value = false;
|
||
}
|
||
}
|
||
|
||
async function revokeApiKey(id: number) {
|
||
await apiRevokeApiKey(id);
|
||
revokeConfirmId.value = null;
|
||
await fetchApiKeys();
|
||
}
|
||
|
||
async function copyApiKey() {
|
||
await copyToClipboard(newKeyValue.value);
|
||
apiKeyCopied.value = true;
|
||
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
|
||
}
|
||
|
||
function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
|
||
const blob = new Blob([content], { type: mimeType });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function downloadEnvFile() {
|
||
const baseUrl = window.location.origin;
|
||
const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
|
||
_downloadFile('fable-mcp.env', content);
|
||
}
|
||
|
||
function downloadMcpConfig() {
|
||
const baseUrl = window.location.origin;
|
||
const config = {
|
||
mcpServers: {
|
||
fable: {
|
||
command: 'fable-mcp',
|
||
env: {
|
||
FABLE_URL: baseUrl,
|
||
FABLE_API_KEY: newKeyValue.value,
|
||
},
|
||
},
|
||
},
|
||
};
|
||
_downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
|
||
}
|
||
|
||
// Groups management
|
||
const groups = ref<GroupEntry[]>([]);
|
||
const groupsLoading = ref(false);
|
||
const newGroupName = ref("");
|
||
const newGroupDesc = ref("");
|
||
const creatingGroup = ref(false);
|
||
const expandedGroupId = ref<number | null>(null);
|
||
const groupMembers = ref<Record<number, GroupMember[]>>({});
|
||
const groupMemberSearch = ref("");
|
||
const groupMemberResults = ref<UserSearchResult[]>([]);
|
||
let groupSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const groupMemberRole = ref("member");
|
||
|
||
async function loadGroupsPanel() {
|
||
groupsLoading.value = true;
|
||
try {
|
||
groups.value = await listGroups();
|
||
} finally {
|
||
groupsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function createNewGroup() {
|
||
if (!newGroupName.value.trim() || creatingGroup.value) return;
|
||
creatingGroup.value = true;
|
||
try {
|
||
await createGroup(newGroupName.value.trim(), newGroupDesc.value.trim() || undefined);
|
||
newGroupName.value = "";
|
||
newGroupDesc.value = "";
|
||
await loadGroupsPanel();
|
||
} finally {
|
||
creatingGroup.value = false;
|
||
}
|
||
}
|
||
|
||
async function deleteGroupConfirm(g: GroupEntry) {
|
||
if (!confirm(`Delete group "${g.name}"? This cannot be undone.`)) return;
|
||
await deleteGroup(g.id);
|
||
if (expandedGroupId.value === g.id) expandedGroupId.value = null;
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
async function toggleGroupExpand(g: GroupEntry) {
|
||
if (expandedGroupId.value === g.id) {
|
||
expandedGroupId.value = null;
|
||
return;
|
||
}
|
||
expandedGroupId.value = g.id;
|
||
groupMemberSearch.value = "";
|
||
groupMemberResults.value = [];
|
||
groupMembers.value[g.id] = await listGroupMembers(g.id);
|
||
}
|
||
|
||
function debounceGroupMemberSearch() {
|
||
if (groupSearchTimer) clearTimeout(groupSearchTimer);
|
||
groupSearchTimer = setTimeout(async () => {
|
||
if (groupMemberSearch.value.length < 2) { groupMemberResults.value = []; return; }
|
||
groupMemberResults.value = await searchUsers(groupMemberSearch.value);
|
||
}, 300);
|
||
}
|
||
|
||
async function addMemberToGroup(groupId: number, user: UserSearchResult) {
|
||
await addGroupMember(groupId, user.id, groupMemberRole.value);
|
||
groupMembers.value[groupId] = await listGroupMembers(groupId);
|
||
groupMemberSearch.value = "";
|
||
groupMemberResults.value = [];
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
async function removeMemberFromGroup(groupId: number, userId: number) {
|
||
await removeGroupMember(groupId, userId);
|
||
groupMembers.value[groupId] = await listGroupMembers(groupId);
|
||
await loadGroupsPanel();
|
||
}
|
||
|
||
// Briefing settings
|
||
const briefingConfig = ref<BriefingConfig>({
|
||
enabled: false,
|
||
locations: {},
|
||
use_caldav_event_locations: false,
|
||
work_days: [1, 2, 3, 4, 5],
|
||
slots: { compilation: true, morning: true, midday: false, afternoon: false },
|
||
notifications: true,
|
||
temp_unit: 'C',
|
||
});
|
||
const briefingFeeds = ref<BriefingFeed[]>([]);
|
||
const briefingSaving = ref(false);
|
||
const briefingSaved = ref(false);
|
||
const briefingGeocoding = ref<Record<string, boolean>>({});
|
||
const briefingGeoError = ref<Record<string, string>>({});
|
||
const newFeedUrl = ref('');
|
||
const newFeedCategory = ref('');
|
||
const addingFeed = ref(false);
|
||
const refreshingFeeds = ref(false);
|
||
const briefingIncludeTopics = ref<string[]>([]);
|
||
const briefingExcludeTopics = ref<string[]>([]);
|
||
|
||
function _parseTopics(raw: unknown): string[] {
|
||
try {
|
||
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
||
return Array.isArray(val) ? val.map(String) : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
async function loadBriefingTab() {
|
||
briefingConfig.value = await getBriefingConfig();
|
||
briefingFeeds.value = await getBriefingFeeds();
|
||
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
|
||
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
|
||
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
|
||
}
|
||
|
||
async function saveIncludeTopics(topics: string[]) {
|
||
briefingIncludeTopics.value = topics;
|
||
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
|
||
}
|
||
|
||
async function saveExcludeTopics(topics: string[]) {
|
||
briefingExcludeTopics.value = topics;
|
||
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
|
||
}
|
||
|
||
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
|
||
async function fetchTopicSuggestions(q: string): Promise<string[]> {
|
||
if (!q) return _STANDARD_TOPICS;
|
||
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
|
||
}
|
||
|
||
async function geocodeLocation(key: 'home' | 'work') {
|
||
const loc = briefingConfig.value.locations[key];
|
||
if (!loc?.address?.trim()) return;
|
||
briefingGeocoding.value[key] = true;
|
||
briefingGeoError.value[key] = '';
|
||
try {
|
||
const result = await geocodeAddress(loc.address.trim());
|
||
if (result) {
|
||
briefingConfig.value.locations[key] = {
|
||
...loc,
|
||
lat: result.lat,
|
||
lon: result.lon,
|
||
label: key.charAt(0).toUpperCase() + key.slice(1),
|
||
};
|
||
briefingGeoError.value[key] = '';
|
||
} else {
|
||
briefingGeoError.value[key] = 'Location not found — check the address';
|
||
}
|
||
} catch {
|
||
briefingGeoError.value[key] = 'Geocoding failed';
|
||
} finally {
|
||
briefingGeocoding.value[key] = false;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
async function saveBriefingSettings() {
|
||
briefingSaving.value = true;
|
||
briefingSaved.value = false;
|
||
try {
|
||
await saveBriefingConfig(briefingConfig.value);
|
||
briefingSaved.value = true;
|
||
setTimeout(() => (briefingSaved.value = false), 2000);
|
||
} catch {
|
||
toastStore.show('Failed to save briefing settings', 'error');
|
||
} finally {
|
||
briefingSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function addFeed() {
|
||
if (!newFeedUrl.value.trim() || addingFeed.value) return;
|
||
addingFeed.value = true;
|
||
try {
|
||
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
|
||
briefingFeeds.value.push(feed);
|
||
newFeedUrl.value = '';
|
||
newFeedCategory.value = '';
|
||
} catch (err: any) {
|
||
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
|
||
} finally {
|
||
addingFeed.value = false;
|
||
}
|
||
}
|
||
|
||
async function removeFeed(id: number) {
|
||
await deleteBriefingFeed(id);
|
||
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
|
||
}
|
||
|
||
async function refreshFeeds() {
|
||
if (refreshingFeeds.value) return;
|
||
refreshingFeeds.value = true;
|
||
try {
|
||
const result = await refreshBriefingFeeds();
|
||
// Reload feed list so last_fetched_at updates
|
||
briefingFeeds.value = await getBriefingFeeds();
|
||
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''} — ${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
|
||
} catch {
|
||
toastStore.show('Failed to refresh feeds', 'error');
|
||
} finally {
|
||
refreshingFeeds.value = false;
|
||
}
|
||
}
|
||
|
||
function feedAge(isoStr: string | null): string {
|
||
if (!isoStr) return 'never fetched';
|
||
const diff = Date.now() - new Date(isoStr).getTime();
|
||
const m = Math.floor(diff / 60000);
|
||
if (m < 1) return 'just now';
|
||
if (m < 60) return `${m}m ago`;
|
||
const h = Math.floor(m / 60);
|
||
if (h < 24) return `${h}h ago`;
|
||
return `${Math.floor(h / 24)}d ago`;
|
||
}
|
||
|
||
// Chat retention
|
||
const chatRetentionDays = ref(90);
|
||
const savingRetention = ref(false);
|
||
|
||
async function saveRetention() {
|
||
savingRetention.value = true;
|
||
try {
|
||
await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
|
||
} finally {
|
||
savingRetention.value = false;
|
||
}
|
||
}
|
||
|
||
// Notification preferences
|
||
const notifyTaskReminders = ref(true);
|
||
const notifySecurityAlerts = ref(true);
|
||
const savingNotifications = ref(false);
|
||
const notificationsSaved = ref(false);
|
||
|
||
// VAPID key reset (admin)
|
||
const vapidResetting = ref(false);
|
||
const vapidResetMsg = ref("");
|
||
const vapidResetError = ref(false);
|
||
|
||
async function resetVapidKeys() {
|
||
if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
|
||
vapidResetting.value = true;
|
||
vapidResetMsg.value = "";
|
||
vapidResetError.value = false;
|
||
try {
|
||
await apiPost("/api/push/reset-vapid", {});
|
||
vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
|
||
pushStore.checkSubscription();
|
||
} catch {
|
||
vapidResetError.value = true;
|
||
vapidResetMsg.value = "Reset failed — check server logs.";
|
||
} finally {
|
||
vapidResetting.value = 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);
|
||
|
||
// Voice config (admin only)
|
||
const adminVoiceEnabled = ref(false);
|
||
const adminVoiceSttModel = ref("base.en");
|
||
const savingAdminVoice = ref(false);
|
||
const adminVoiceSaved = ref(false);
|
||
const voiceLoadingModels = ref(false);
|
||
|
||
async function saveAdminVoice() {
|
||
savingAdminVoice.value = true;
|
||
adminVoiceSaved.value = false;
|
||
try {
|
||
await apiPut("/api/admin/voice", {
|
||
voice_enabled: adminVoiceEnabled.value,
|
||
voice_stt_model: adminVoiceSttModel.value,
|
||
});
|
||
adminVoiceSaved.value = true;
|
||
setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
|
||
voiceTabLoaded.value = false;
|
||
// Always update global voice state so mic buttons appear/disappear immediately
|
||
await store.checkVoiceStatus();
|
||
if (adminVoiceEnabled.value) {
|
||
await reloadVoiceModels();
|
||
}
|
||
} finally {
|
||
savingAdminVoice.value = false;
|
||
}
|
||
}
|
||
|
||
async function reloadVoiceModels() {
|
||
voiceLoadingModels.value = true;
|
||
try {
|
||
await apiPost("/api/admin/voice/reload", {});
|
||
// Poll /api/voice/status until both STT and TTS are ready (max 3 min)
|
||
const deadline = Date.now() + 3 * 60 * 1000;
|
||
while (Date.now() < deadline) {
|
||
await new Promise((r) => setTimeout(r, 2500));
|
||
try {
|
||
const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status");
|
||
if (status.stt && status.tts) {
|
||
voiceStatus.value = status;
|
||
voiceTabLoaded.value = true;
|
||
// Propagate to global store so mic buttons appear everywhere
|
||
store.checkVoiceStatus();
|
||
break;
|
||
}
|
||
} catch { /* keep polling */ }
|
||
}
|
||
} catch {
|
||
toastStore.show("Failed to trigger model reload", "error");
|
||
} finally {
|
||
voiceLoadingModels.value = 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("");
|
||
|
||
// Voice settings
|
||
const voiceStatus = ref<VoiceStatusResult | null>(null);
|
||
const voiceStatusLoading = ref(false);
|
||
const availableVoices = ref<VoiceEntry[]>([]);
|
||
const voiceTtsVoice = ref("af_heart");
|
||
const voiceTtsSpeed = ref(1.0);
|
||
const voiceSpeechStyle = ref("conversational");
|
||
const savingVoice = ref(false);
|
||
const voiceSaved = ref(false);
|
||
const voiceTabLoaded = ref(false);
|
||
|
||
// Voice blend
|
||
const blendEnabled = ref(false);
|
||
const voiceBlend = ref<VoiceBlendEntry[]>([
|
||
{ voice: "af_heart", weight: 1.0 },
|
||
{ voice: "af_bella", weight: 1.0 },
|
||
]);
|
||
const blendPreviewText = ref("Hello! I'm your assistant. How can I help you today?");
|
||
const blendPreviewing = ref(false);
|
||
|
||
function addBlendSlot() {
|
||
voiceBlend.value.push({ voice: "af_heart", weight: 1.0 });
|
||
}
|
||
|
||
function removeBlendSlot(idx: number) {
|
||
if (voiceBlend.value.length > 2) voiceBlend.value.splice(idx, 1);
|
||
}
|
||
|
||
async function previewBlend() {
|
||
if (blendPreviewing.value || !blendPreviewText.value.trim()) return;
|
||
blendPreviewing.value = true;
|
||
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
|
||
const ctx = new AudioContext();
|
||
try {
|
||
const blob = await synthesiseSpeech(
|
||
blendPreviewText.value,
|
||
undefined,
|
||
voiceTtsSpeed.value,
|
||
voiceBlend.value,
|
||
);
|
||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.connect(ctx.destination);
|
||
src.start(0);
|
||
} catch {
|
||
toastStore.show("Preview failed — TTS may not be ready", "error");
|
||
} finally {
|
||
blendPreviewing.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadVoiceTab() {
|
||
if (voiceTabLoaded.value) return;
|
||
voiceTabLoaded.value = true;
|
||
voiceStatusLoading.value = true;
|
||
try {
|
||
voiceStatus.value = await getVoiceStatus();
|
||
if (voiceStatus.value.tts) {
|
||
availableVoices.value = await getVoiceList();
|
||
}
|
||
} catch {
|
||
// Voice feature may be disabled
|
||
} finally {
|
||
voiceStatusLoading.value = false;
|
||
}
|
||
}
|
||
|
||
const voicePreviewing = ref(false);
|
||
|
||
async function previewVoice() {
|
||
if (voicePreviewing.value) return;
|
||
voicePreviewing.value = true;
|
||
// Create AudioContext synchronously in user-gesture context to bypass autoplay policy
|
||
const ctx = new AudioContext();
|
||
try {
|
||
const blob = await synthesiseSpeech(
|
||
"Hello! I'm your assistant. How can I help you today?",
|
||
voiceTtsVoice.value,
|
||
voiceTtsSpeed.value,
|
||
);
|
||
const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.connect(ctx.destination);
|
||
src.start(0);
|
||
} catch {
|
||
toastStore.show("Preview failed — TTS may not be ready", "error");
|
||
} finally {
|
||
voicePreviewing.value = false;
|
||
}
|
||
}
|
||
|
||
async function saveVoiceSettings() {
|
||
savingVoice.value = true;
|
||
voiceSaved.value = false;
|
||
try {
|
||
await apiPut("/api/settings", {
|
||
voice_tts_voice: voiceTtsVoice.value,
|
||
voice_tts_speed: String(voiceTtsSpeed.value),
|
||
voice_speech_style: voiceSpeechStyle.value,
|
||
voice_tts_blend: blendEnabled.value ? JSON.stringify(voiceBlend.value) : "",
|
||
});
|
||
voiceSaved.value = true;
|
||
setTimeout(() => { voiceSaved.value = false; }, 2000);
|
||
} finally {
|
||
savingVoice.value = false;
|
||
}
|
||
}
|
||
|
||
// ── Profile ──────────────────────────────────────────────────────────────────
|
||
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||
const profile = ref<UserProfile>({
|
||
display_name: '', job_title: '', industry: '',
|
||
expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
|
||
interests: [], work_schedule: {}, learned_summary: '',
|
||
observations_count: 0, observations_updated_at: null,
|
||
})
|
||
const profileSaving = ref(false)
|
||
const profileSaved = ref(false)
|
||
const consolidating = ref(false)
|
||
const clearingObs = ref(false)
|
||
|
||
async function loadProfile() {
|
||
try { profile.value = await getProfile() } catch { /* non-critical */ }
|
||
}
|
||
|
||
async function saveProfile() {
|
||
profileSaving.value = true
|
||
profileSaved.value = false
|
||
try {
|
||
profile.value = await updateProfile({
|
||
display_name: profile.value.display_name,
|
||
job_title: profile.value.job_title,
|
||
industry: profile.value.industry,
|
||
expertise_level: profile.value.expertise_level,
|
||
response_style: profile.value.response_style,
|
||
tone: profile.value.tone,
|
||
interests: profile.value.interests,
|
||
work_schedule: profile.value.work_schedule,
|
||
})
|
||
profileSaved.value = true
|
||
setTimeout(() => { profileSaved.value = false }, 2000)
|
||
} catch { toastStore.show('Failed to save profile', 'error') }
|
||
finally { profileSaving.value = false }
|
||
}
|
||
|
||
function toggleProfileWorkDay(day: string) {
|
||
const days = [...(profile.value.work_schedule.days ?? [])]
|
||
const idx = days.indexOf(day)
|
||
if (idx >= 0) days.splice(idx, 1)
|
||
else days.push(day)
|
||
profile.value.work_schedule = { ...profile.value.work_schedule, days }
|
||
}
|
||
|
||
async function runConsolidate() {
|
||
consolidating.value = true
|
||
try {
|
||
const result = await consolidateProfile()
|
||
profile.value.learned_summary = result.learned_summary
|
||
toastStore.show('Profile updated from observations')
|
||
} catch { toastStore.show('Consolidation failed', 'error') }
|
||
finally { consolidating.value = false }
|
||
}
|
||
|
||
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
|
||
|
||
async function clearObservations() {
|
||
if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return
|
||
clearingObs.value = true
|
||
try {
|
||
await clearProfileObservations()
|
||
profile.value.learned_summary = ''
|
||
profile.value.observations_count = 0
|
||
profile.value.observations_updated_at = null
|
||
toastStore.show('Learned data cleared')
|
||
} catch { toastStore.show('Failed to clear observations', 'error') }
|
||
finally { clearingObs.value = false }
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
const v = await apiGet<{ version: string }>('/api/version')
|
||
appVersion.value = v.version
|
||
} catch { /* non-critical */ }
|
||
await store.fetchSettings();
|
||
pushStore.checkSubscription();
|
||
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
|
||
}
|
||
await loadOllamaModels();
|
||
|
||
// Load notification preferences from user settings
|
||
const allSettings = await apiGet<Record<string, string>>("/api/settings");
|
||
defaultModel.value = allSettings.default_model ?? "";
|
||
backgroundModel.value = allSettings.background_model ?? "";
|
||
userTimezone.value = allSettings.user_timezone ?? "";
|
||
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
|
||
? Number(allSettings.chat_retention_days)
|
||
: 90;
|
||
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 user profile
|
||
await loadProfile();
|
||
|
||
// Load voice settings
|
||
if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
|
||
if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
|
||
if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;
|
||
if (allSettings.voice_tts_blend) {
|
||
try {
|
||
const parsed = JSON.parse(allSettings.voice_tts_blend);
|
||
if (Array.isArray(parsed) && parsed.length >= 2) {
|
||
voiceBlend.value = parsed;
|
||
blendEnabled.value = true;
|
||
}
|
||
} catch { /* ignore malformed */ }
|
||
}
|
||
|
||
// 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
|
||
}
|
||
try {
|
||
const vc = await apiGet<Record<string, string>>("/api/admin/voice");
|
||
adminVoiceEnabled.value = vc.voice_enabled === "true";
|
||
adminVoiceSttModel.value = vc.voice_stt_model ?? "base.en";
|
||
} catch {
|
||
// voice not configured yet
|
||
}
|
||
}
|
||
_loadTabContent(activeTab.value);
|
||
});
|
||
|
||
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 invalidateSessions() {
|
||
invalidatingSessions.value = true;
|
||
try {
|
||
await apiPost("/api/auth/invalidate-sessions", {});
|
||
toastStore.show("All other sessions have been invalidated");
|
||
} catch {
|
||
toastStore.show("Failed to invalidate sessions", "error");
|
||
} finally {
|
||
invalidatingSessions.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;
|
||
}
|
||
}
|
||
|
||
function formatBytes(bytes: number): string {
|
||
if (bytes === 0) return "—";
|
||
const gb = bytes / 1e9;
|
||
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
||
return `${(bytes / 1e6).toFixed(0)} MB`;
|
||
}
|
||
|
||
async function loadOllamaModels() {
|
||
try {
|
||
const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
|
||
ollamaModels.value = data.models ?? [];
|
||
installedModels.value = ollamaModels.value.map((m) => m.name);
|
||
} catch {
|
||
// Ollama unreachable
|
||
}
|
||
}
|
||
|
||
async function pullModel() {
|
||
const name = pullModelName.value.trim();
|
||
if (!name || pulling.value) return;
|
||
pulling.value = true;
|
||
pullProgress.value = { status: "Connecting…", pct: null };
|
||
try {
|
||
const resp = await fetch("/api/chat/models/pull", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ model: name }),
|
||
credentials: "include",
|
||
});
|
||
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
|
||
const reader = resp.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buf = "";
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buf += decoder.decode(value, { stream: true });
|
||
const lines = buf.split("\n");
|
||
buf = lines.pop() ?? "";
|
||
for (const line of lines) {
|
||
const trimmed = line.replace(/^data: /, "").trim();
|
||
if (!trimmed) continue;
|
||
try {
|
||
const msg = JSON.parse(trimmed);
|
||
if (msg.error) throw new Error(msg.error);
|
||
if (msg.status === "success") {
|
||
pullProgress.value = { status: "Complete", pct: 100 };
|
||
pullModelName.value = "";
|
||
} else if (msg.total && msg.completed) {
|
||
const pct = Math.round((msg.completed / msg.total) * 100);
|
||
pullProgress.value = { status: msg.status || "Downloading…", pct };
|
||
} else {
|
||
pullProgress.value = { status: msg.status || "Working…", pct: null };
|
||
}
|
||
} catch (parseErr: any) {
|
||
if (parseErr.message !== "JSON parse error") throw parseErr;
|
||
}
|
||
}
|
||
}
|
||
await loadOllamaModels();
|
||
toastStore.show(`Model ${name} ready`);
|
||
} catch (err: any) {
|
||
toastStore.show(`Pull failed: ${err.message}`, "error");
|
||
} finally {
|
||
pulling.value = false;
|
||
setTimeout(() => { pullProgress.value = null; }, 3000);
|
||
}
|
||
}
|
||
|
||
async function deleteModel(name: string) {
|
||
deletingModel.value = name;
|
||
try {
|
||
await apiPost("/api/chat/models/delete", { model: name });
|
||
await loadOllamaModels();
|
||
if (defaultModel.value === name) defaultModel.value = "";
|
||
toastStore.show(`Deleted ${name}`);
|
||
} catch {
|
||
toastStore.show(`Failed to delete ${name}`, "error");
|
||
} finally {
|
||
deletingModel.value = null;
|
||
}
|
||
}
|
||
|
||
async function saveAssistant() {
|
||
saving.value = true;
|
||
saved.value = false;
|
||
try {
|
||
await store.updateSettings({
|
||
assistant_name: assistantName.value.trim() || "Fable",
|
||
default_model: defaultModel.value,
|
||
background_model: backgroundModel.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;
|
||
}
|
||
}
|
||
|
||
const exportingNotes = ref(false);
|
||
|
||
async function exportNotes(format: "markdown" | "json") {
|
||
exportingNotes.value = true;
|
||
try {
|
||
const res = await fetch(`/api/export?format=${format}`);
|
||
if (!res.ok) throw new Error(`Error ${res.status}`);
|
||
const blob = await res.blob();
|
||
const ext = format === "json" ? "json" : "zip";
|
||
const stamp = new Date().toISOString().slice(0, 10);
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `fabledassistant-${stamp}.${ext}`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
toastStore.show("Export downloaded");
|
||
} catch (e) {
|
||
toastStore.show("Export failed: " + (e as Error).message, "error");
|
||
} finally {
|
||
exportingNotes.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; }
|
||
}
|
||
|
||
// ── Users panel ──
|
||
|
||
interface Invitation {
|
||
id: number;
|
||
email: string;
|
||
created_at: string;
|
||
expires_at: string;
|
||
}
|
||
|
||
const users = ref<User[]>([]);
|
||
const registrationOpen = ref(false);
|
||
const usersLoading = ref(false);
|
||
const toggling = ref(false);
|
||
const confirmDeleteId = ref<number | null>(null);
|
||
const deleting = ref<number | null>(null);
|
||
const inviteEmail = ref("");
|
||
const sendingInvite = ref(false);
|
||
const invitations = ref<Invitation[]>([]);
|
||
const revokingId = ref<number | null>(null);
|
||
|
||
async function fetchUsers() {
|
||
try {
|
||
const data = await apiGet<{ users: User[] }>("/api/admin/users");
|
||
users.value = data.users;
|
||
} catch {
|
||
toastStore.show("Failed to load users", "error");
|
||
}
|
||
}
|
||
|
||
async function fetchRegistration() {
|
||
try {
|
||
const data = await apiGet<{ open: boolean }>("/api/admin/registration");
|
||
registrationOpen.value = data.open;
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function fetchInvitations() {
|
||
try {
|
||
const data = await apiGet<{ invitations: Invitation[] }>("/api/admin/invitations");
|
||
invitations.value = data.invitations;
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadUsersPanel() {
|
||
if (users.value.length > 0) return; // already loaded
|
||
usersLoading.value = true;
|
||
await Promise.all([fetchUsers(), fetchRegistration(), fetchInvitations()]);
|
||
usersLoading.value = false;
|
||
}
|
||
|
||
// ── Logs panel ──
|
||
interface LogEntry {
|
||
id: number;
|
||
category: string;
|
||
user_id: number | null;
|
||
username: string | null;
|
||
action: string | null;
|
||
endpoint: string | null;
|
||
method: string | null;
|
||
status_code: number | null;
|
||
duration_ms: number | null;
|
||
ip_address: string | null;
|
||
details: string | null;
|
||
created_at: string;
|
||
}
|
||
|
||
interface LogStats {
|
||
audit: number;
|
||
usage: number;
|
||
error: number;
|
||
total: number;
|
||
}
|
||
|
||
const logs = ref<LogEntry[]>([]);
|
||
const logStats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
|
||
const logTotal = ref(0);
|
||
const logsLoading = ref(false);
|
||
const logsLoaded = ref(false);
|
||
const expandedLogId = ref<number | null>(null);
|
||
const logCategory = ref("");
|
||
const logSearch = ref("");
|
||
const logDateFrom = ref("");
|
||
const logDateTo = ref("");
|
||
const logLimit = 50;
|
||
const logOffset = ref(0);
|
||
let logSearchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
watch([logCategory, logDateFrom, logDateTo], () => {
|
||
logOffset.value = 0;
|
||
if (logsLoaded.value) fetchLogs();
|
||
});
|
||
watch(logSearch, () => {
|
||
if (logSearchTimeout) clearTimeout(logSearchTimeout);
|
||
logSearchTimeout = setTimeout(() => {
|
||
logOffset.value = 0;
|
||
if (logsLoaded.value) fetchLogs();
|
||
}, 300);
|
||
});
|
||
watch(logOffset, () => {
|
||
if (logsLoaded.value) fetchLogs();
|
||
});
|
||
|
||
async function fetchLogs() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (logCategory.value) params.set("category", logCategory.value);
|
||
if (logSearch.value) params.set("search", logSearch.value);
|
||
if (logDateFrom.value) params.set("date_from", logDateFrom.value);
|
||
if (logDateTo.value) params.set("date_to", logDateTo.value);
|
||
params.set("limit", String(logLimit));
|
||
params.set("offset", String(logOffset.value));
|
||
const data = await apiGet<{ logs: LogEntry[]; total: number }>(`/api/admin/logs?${params}`);
|
||
logs.value = data.logs;
|
||
logTotal.value = data.total;
|
||
} catch {
|
||
toastStore.show("Failed to load logs", "error");
|
||
}
|
||
}
|
||
|
||
async function fetchLogStats() {
|
||
try {
|
||
logStats.value = await apiGet<LogStats>("/api/admin/logs/stats");
|
||
} catch { /* ignore */ }
|
||
}
|
||
|
||
async function loadLogsPanel() {
|
||
if (logsLoaded.value) return;
|
||
logsLoading.value = true;
|
||
await Promise.all([fetchLogs(), fetchLogStats()]);
|
||
logsLoaded.value = true;
|
||
logsLoading.value = false;
|
||
}
|
||
|
||
function toggleLogExpand(id: number) {
|
||
expandedLogId.value = expandedLogId.value === id ? null : id;
|
||
}
|
||
|
||
function formatLogTime(iso: string): string {
|
||
const d = new Date(iso);
|
||
return d.toLocaleString(undefined, {
|
||
month: "short", day: "numeric",
|
||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||
});
|
||
}
|
||
|
||
function formatLogDetails(details: string | null): string {
|
||
if (!details) return "";
|
||
try { return JSON.stringify(JSON.parse(details), null, 2); } catch { return details; }
|
||
}
|
||
|
||
function logDisplayLabel(entry: LogEntry): string {
|
||
if (entry.category === "audit" && entry.action) return entry.action;
|
||
if (entry.endpoint) return entry.endpoint;
|
||
return "—";
|
||
}
|
||
|
||
function clearLogFilters() {
|
||
logCategory.value = "";
|
||
logSearch.value = "";
|
||
logDateFrom.value = "";
|
||
logDateTo.value = "";
|
||
logOffset.value = 0;
|
||
}
|
||
|
||
async function sendInvite() {
|
||
const email = inviteEmail.value.trim().toLowerCase();
|
||
if (!email) return;
|
||
sendingInvite.value = true;
|
||
try {
|
||
await apiPost("/api/admin/invitations", { email });
|
||
toastStore.show(`Invitation sent to ${email}`);
|
||
inviteEmail.value = "";
|
||
await fetchInvitations();
|
||
} catch (e: unknown) {
|
||
const body = (e as { body?: { error?: string } })?.body;
|
||
toastStore.show(body?.error || "Failed to send invitation", "error");
|
||
} finally {
|
||
sendingInvite.value = false;
|
||
}
|
||
}
|
||
|
||
async function revokeInvitation(id: number) {
|
||
revokingId.value = id;
|
||
try {
|
||
await apiDelete(`/api/admin/invitations/${id}`);
|
||
invitations.value = invitations.value.filter((inv) => inv.id !== id);
|
||
toastStore.show("Invitation revoked");
|
||
} catch {
|
||
toastStore.show("Failed to revoke invitation", "error");
|
||
} finally {
|
||
revokingId.value = null;
|
||
}
|
||
}
|
||
|
||
async function toggleRegistration() {
|
||
toggling.value = true;
|
||
try {
|
||
const data = await apiPut<{ open: boolean }>("/api/admin/registration", {
|
||
open: !registrationOpen.value,
|
||
});
|
||
registrationOpen.value = data.open;
|
||
toastStore.show(data.open ? "Registration opened" : "Registration closed");
|
||
} catch {
|
||
toastStore.show("Failed to update registration setting", "error");
|
||
} finally {
|
||
toggling.value = false;
|
||
}
|
||
}
|
||
|
||
function confirmDelete(userId: number) {
|
||
if (confirmDeleteId.value === userId) {
|
||
deleteUser(userId);
|
||
} else {
|
||
confirmDeleteId.value = userId;
|
||
}
|
||
}
|
||
function cancelDelete() { confirmDeleteId.value = null; }
|
||
|
||
async function deleteUser(userId: number) {
|
||
confirmDeleteId.value = null;
|
||
deleting.value = userId;
|
||
try {
|
||
await apiDelete(`/api/admin/users/${userId}`);
|
||
users.value = users.value.filter((u) => u.id !== userId);
|
||
toastStore.show("User deleted");
|
||
} catch (e: unknown) {
|
||
const body = (e as { body?: { error?: string } })?.body;
|
||
toastStore.show(body?.error || "Failed to delete user", "error");
|
||
} finally {
|
||
deleting.value = null;
|
||
}
|
||
}
|
||
|
||
function formatUserDate(iso: string): string {
|
||
return new Date(iso).toLocaleDateString(undefined, {
|
||
year: "numeric", month: "short", day: "numeric",
|
||
});
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<main class="settings-root">
|
||
<aside class="settings-sidebar" role="navigation" aria-label="Settings navigation">
|
||
<div class="sidebar-group">
|
||
<div class="sidebar-group-label">User</div>
|
||
<button
|
||
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
|
||
:key="tab"
|
||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||
@click="activeTab = tab"
|
||
>
|
||
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||
</button>
|
||
</div>
|
||
<div v-if="authStore.isAdmin" class="sidebar-group">
|
||
<div class="sidebar-group-label">Admin</div>
|
||
<button
|
||
v-for="tab in ['config', 'users', 'groups', 'logs']"
|
||
:key="tab"
|
||
:class="['sidebar-item', { active: activeTab === tab }]"
|
||
@click="activeTab = tab"
|
||
>
|
||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
<div class="settings-content">
|
||
|
||
<!-- ── General ── -->
|
||
<div v-show="activeTab === 'general'" class="settings-grid">
|
||
<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 class="field">
|
||
<label for="background-model">Background Model</label>
|
||
<select id="background-model" v-model="backgroundModel" class="input">
|
||
<option value="">Default (qwen2.5:0.5b)</option>
|
||
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
|
||
</select>
|
||
<p class="field-hint">
|
||
Model used for background tasks: title generation, tag suggestions, project summaries, and RSS classification.
|
||
Using a small dedicated model (e.g. qwen2.5:0.5b) keeps the chat model's KV cache warm between messages, significantly reducing response time.
|
||
<span v-if="backgroundModel && backgroundModel === (defaultModel || defaultChatModel)" class="field-hint-warn">
|
||
⚠ Setting this to the same model as Chat Model will wipe the KV cache after every background task, increasing response latency.
|
||
</span>
|
||
</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>
|
||
|
||
<!-- Timezone -->
|
||
<section class="settings-section full-width">
|
||
<h2>Timezone</h2>
|
||
<p class="section-desc">Used to schedule briefings and format times in chat. Set this to your local IANA timezone (e.g. America/New_York, Europe/London).</p>
|
||
<div class="field">
|
||
<label for="user-timezone">Your timezone</label>
|
||
<div style="display:flex; gap:0.5rem; align-items:center">
|
||
<input
|
||
id="user-timezone"
|
||
v-model="userTimezone"
|
||
type="text"
|
||
class="input"
|
||
placeholder="e.g. America/New_York"
|
||
/>
|
||
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
|
||
</div>
|
||
<p class="field-hint">Click Detect to auto-fill from your browser.</p>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
|
||
{{ savingTimezone ? 'Saving…' : 'Save' }}
|
||
</button>
|
||
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Model Management -->
|
||
<section class="settings-section full-width">
|
||
<div class="model-mgmt-header">
|
||
<div>
|
||
<h2>Model Management</h2>
|
||
<p class="section-desc">Install and remove Ollama models without leaving the app. Search <a href="https://ollama.com/library" target="_blank" rel="noopener">ollama.com/library</a> for available models.</p>
|
||
</div>
|
||
<button class="btn-secondary" @click="loadOllamaModels" title="Refresh list">↺ Refresh</button>
|
||
</div>
|
||
|
||
<!-- Installed models -->
|
||
<div v-if="ollamaModels.length" class="model-list">
|
||
<div v-for="m in ollamaModels" :key="m.name" class="model-row">
|
||
<div class="model-row-info">
|
||
<span class="model-name">{{ m.name }}</span>
|
||
<span v-if="m.loaded" class="model-badge model-badge--loaded">in VRAM</span>
|
||
<span v-if="m.name === (defaultModel || defaultChatModel)" class="model-badge model-badge--default">default</span>
|
||
</div>
|
||
<div class="model-row-right">
|
||
<span class="model-size">{{ formatBytes(m.size) }}</span>
|
||
<button
|
||
class="model-delete-btn"
|
||
:disabled="deletingModel === m.name"
|
||
@click="deleteModel(m.name)"
|
||
:title="`Remove ${m.name}`"
|
||
>{{ deletingModel === m.name ? '…' : '✕' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<p v-else class="field-hint">No models installed, or Ollama is unreachable.</p>
|
||
|
||
<!-- Pull a model -->
|
||
<div class="model-pull-form">
|
||
<input
|
||
v-model="pullModelName"
|
||
class="input"
|
||
placeholder="e.g. qwen3:7b"
|
||
:disabled="pulling"
|
||
@keydown.enter="pullModel"
|
||
/>
|
||
<button class="btn-secondary" @click="pullModel" :disabled="pulling || !pullModelName.trim()">
|
||
{{ pulling ? 'Pulling…' : 'Pull' }}
|
||
</button>
|
||
</div>
|
||
<div class="model-suggestions">
|
||
<span class="suggestions-label">Suggestions:</span>
|
||
<button v-for="s in ['qwen3:7b','qwen3:14b','qwen3:4b','llama3.1:8b','nomic-embed-text']"
|
||
:key="s" class="suggestion-chip"
|
||
:disabled="pulling || ollamaModels.some(m => m.name === s)"
|
||
@click="pullModelName = s"
|
||
>{{ s }}</button>
|
||
</div>
|
||
|
||
<!-- Pull progress -->
|
||
<div v-if="pullProgress" class="model-pull-progress">
|
||
<div class="pull-status">{{ pullProgress.status }}</div>
|
||
<div class="pull-bar-track" v-if="pullProgress.pct !== null">
|
||
<div class="pull-bar-fill" :style="{ width: pullProgress.pct + '%' }"></div>
|
||
</div>
|
||
<div v-else class="pull-bar-indeterminate"></div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- ── Account ── -->
|
||
<div v-show="activeTab === 'account'" class="settings-grid">
|
||
|
||
<!-- SSO accounts: no local credential management -->
|
||
<section v-if="!authStore.user?.has_password" class="settings-section">
|
||
<h2>Account</h2>
|
||
<p class="section-desc">
|
||
Your account is managed by an external identity provider.
|
||
Email and password changes are made through your provider, not here.
|
||
</p>
|
||
</section>
|
||
|
||
<template v-if="authStore.user?.has_password">
|
||
<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 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 || !emailPassword"
|
||
>
|
||
{{ changingEmail ? "Saving..." : "Save Email" }}
|
||
</button>
|
||
</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>
|
||
</template>
|
||
|
||
<section class="settings-section">
|
||
<h2>Active Sessions</h2>
|
||
<p class="section-desc">
|
||
Sign out all other devices and sessions. Use this after a 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 ── -->
|
||
<!-- ── Profile ── -->
|
||
<div v-show="activeTab === 'profile'" class="settings-grid">
|
||
<section class="settings-section full-width">
|
||
<h2>About You</h2>
|
||
<p class="section-desc">This information is used by the assistant to personalise responses in chat and briefings.</p>
|
||
<div class="assistant-grid">
|
||
<div class="field">
|
||
<label>Display Name</label>
|
||
<input v-model="profile.display_name" type="text" class="input" placeholder="e.g. Alex" />
|
||
<p class="field-hint">How the assistant addresses you.</p>
|
||
</div>
|
||
<div class="field">
|
||
<label>Job Title</label>
|
||
<input v-model="profile.job_title" type="text" class="input" placeholder="e.g. Product Manager" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Industry</label>
|
||
<input v-model="profile.industry" type="text" class="input" placeholder="e.g. Technology" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Expertise Level</label>
|
||
<select v-model="profile.expertise_level" class="input">
|
||
<option value="novice">Novice — explain things simply</option>
|
||
<option value="intermediate">Intermediate — balanced explanations</option>
|
||
<option value="expert">Expert — assume deep knowledge</option>
|
||
</select>
|
||
<p class="field-hint">Calibrates how the assistant explains concepts.</p>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Response Preferences</h2>
|
||
<div class="assistant-grid">
|
||
<div class="field">
|
||
<label>Response Style</label>
|
||
<select v-model="profile.response_style" class="input">
|
||
<option value="concise">Concise — short and direct</option>
|
||
<option value="balanced">Balanced — default</option>
|
||
<option value="detailed">Detailed — thorough explanations</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label>Tone</label>
|
||
<select v-model="profile.tone" class="input">
|
||
<option value="casual">Casual — friendly and relaxed</option>
|
||
<option value="professional">Professional — formal and precise</option>
|
||
<option value="technical">Technical — jargon-friendly</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Interests</h2>
|
||
<p class="section-desc">Topics you care about — used to personalise news and briefing context.</p>
|
||
<TagInput v-model="profile.interests" placeholder="Add an interest…" :fetchTags="emptyTagsFetch" />
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Work Schedule</h2>
|
||
<p class="section-desc">Helps the briefing understand when you're working and what's relevant each morning.</p>
|
||
<div class="field">
|
||
<label>Work Days</label>
|
||
<div class="day-picker">
|
||
<button
|
||
v-for="day in WORK_DAYS"
|
||
:key="day"
|
||
class="day-btn"
|
||
:class="{ active: (profile.work_schedule.days ?? []).includes(day) }"
|
||
@click="toggleProfileWorkDay(day)"
|
||
type="button"
|
||
>{{ day }}</button>
|
||
</div>
|
||
</div>
|
||
<div class="assistant-grid" style="margin-top:0.75rem">
|
||
<div class="field">
|
||
<label>Start Time</label>
|
||
<input v-model="profile.work_schedule.start" type="time" class="input" />
|
||
</div>
|
||
<div class="field">
|
||
<label>End Time</label>
|
||
<input v-model="profile.work_schedule.end" type="time" class="input" />
|
||
</div>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveProfile" :disabled="profileSaving">{{ profileSaving ? 'Saving…' : 'Save' }}</button>
|
||
<span v-if="profileSaved" class="saved-msg">Saved!</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>What the Assistant Has Learned</h2>
|
||
<p class="section-desc">
|
||
The assistant observes patterns from your briefing conversations and builds a summary over time.
|
||
<span v-if="profile.observations_count > 0"> {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.</span>
|
||
</p>
|
||
<div v-if="profile.learned_summary" class="learned-summary">{{ profile.learned_summary }}</div>
|
||
<div v-else class="learned-empty">No learned summary yet. Observations accumulate from daily briefing conversations.</div>
|
||
<div class="actions" style="gap:0.5rem;flex-wrap:wrap">
|
||
<button class="btn-secondary" @click="runConsolidate" :disabled="consolidating || profile.observations_count === 0">
|
||
{{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
|
||
</button>
|
||
<button class="btn-danger-outline" @click="clearObservations" :disabled="clearingObs">
|
||
{{ clearingObs ? 'Clearing…' : 'Reset Learned Data' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<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>
|
||
<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>
|
||
|
||
<section class="settings-section">
|
||
<h2>Push Notifications</h2>
|
||
<p class="section-desc">
|
||
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 or connection (requires HTTPS).</p>
|
||
</template>
|
||
<template v-else>
|
||
<div class="push-status-row">
|
||
<span class="push-status-label">Permission:</span>
|
||
<span
|
||
:class="['push-permission-badge', {
|
||
'perm-granted': pushStore.permission === 'granted',
|
||
'perm-denied': pushStore.permission === 'denied',
|
||
'perm-default': pushStore.permission === 'default',
|
||
}]"
|
||
>
|
||
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
|
||
</span>
|
||
</div>
|
||
<div class="push-status-row">
|
||
<span class="push-status-label">Status:</span>
|
||
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
|
||
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
|
||
</span>
|
||
</div>
|
||
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
|
||
<div class="actions" style="margin-top: 0.75rem;">
|
||
<button
|
||
v-if="!pushStore.isSubscribed"
|
||
class="btn-save"
|
||
@click="pushStore.subscribe()"
|
||
:disabled="pushStore.loading || pushStore.permission === 'denied'"
|
||
>
|
||
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="btn-secondary"
|
||
@click="pushStore.unsubscribe()"
|
||
:disabled="pushStore.loading"
|
||
>
|
||
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
|
||
Notifications are blocked. Allow them in your browser site settings to re-enable.
|
||
</p>
|
||
</template>
|
||
<template v-if="authStore.isAdmin">
|
||
<div class="field-divider" style="margin: 1rem 0;" />
|
||
<p class="section-desc">
|
||
If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
|
||
the key pair here. All existing subscriptions will be cleared — you will need to
|
||
re-enable notifications in this browser afterwards.
|
||
</p>
|
||
<div class="actions" style="margin-top: 0.5rem;">
|
||
<button class="btn-danger" :disabled="vapidResetting" @click="resetVapidKeys">
|
||
{{ vapidResetting ? 'Regenerating…' : 'Regenerate VAPID Keys' }}
|
||
</button>
|
||
</div>
|
||
<p v-if="vapidResetMsg" :class="vapidResetError ? 'field-error' : 'field-hint'" style="margin-top: 0.5rem;">
|
||
{{ vapidResetMsg }}
|
||
</p>
|
||
</template>
|
||
</section>
|
||
|
||
<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>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>About</h2>
|
||
<p class="version-line">Fabled Assistant <span class="version-badge">{{ appVersion }}</span></p>
|
||
</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 & 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>
|
||
|
||
<!-- ── Briefing ── -->
|
||
<div v-show="activeTab === 'briefing'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Daily Briefing</h2>
|
||
<p class="section-desc">
|
||
Configure your daily briefing — a conversation that summarises your day,
|
||
weather, and RSS feeds on a schedule.
|
||
</p>
|
||
|
||
<!-- Enable -->
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="briefingConfig.enabled" />
|
||
Enable Daily Briefing
|
||
</label>
|
||
<p class="field-hint">Start daily briefings at the configured slots.</p>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Locations -->
|
||
<section class="settings-section full-width">
|
||
<h2>Locations</h2>
|
||
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
|
||
|
||
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
|
||
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
|
||
<div class="briefing-input-group">
|
||
<input
|
||
type="text"
|
||
class="input"
|
||
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
|
||
:value="briefingConfig.locations[key]?.address ?? ''"
|
||
@input="(e) => {
|
||
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
|
||
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
|
||
}"
|
||
/>
|
||
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
|
||
{{ briefingGeocoding[key] ? 'Looking up…' : 'Look up' }}
|
||
</button>
|
||
</div>
|
||
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
|
||
✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
|
||
</div>
|
||
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
|
||
</div>
|
||
|
||
<div class="checkbox-field" style="margin-top: 0.75rem">
|
||
<label>
|
||
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
|
||
Also check CalDAV event locations
|
||
</label>
|
||
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
|
||
</div>
|
||
|
||
<div class="field" style="margin-top: 1rem">
|
||
<label class="field-label">Temperature unit</label>
|
||
<div class="briefing-unit-toggle">
|
||
<button
|
||
type="button"
|
||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
|
||
@click="briefingConfig.temp_unit = 'C'"
|
||
>°C</button>
|
||
<button
|
||
type="button"
|
||
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
|
||
@click="briefingConfig.temp_unit = 'F'"
|
||
>°F</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Slots -->
|
||
<section class="settings-section full-width">
|
||
<h2>Scheduled Slots</h2>
|
||
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
|
||
<div class="briefing-slot-list">
|
||
<div
|
||
v-for="[key, label, localTime] in ([
|
||
['compilation', 'Morning briefing', '4:00 am'],
|
||
['morning', 'Office check-in', '8:00 am'],
|
||
['midday', 'Midday update', '12:00 pm'],
|
||
['afternoon', 'End of day', '4:00 pm'],
|
||
] as const)"
|
||
:key="key"
|
||
class="briefing-slot-row"
|
||
>
|
||
<div class="briefing-slot-info">
|
||
<span class="briefing-slot-label">{{ label }}</span>
|
||
<span class="briefing-slot-time">{{ localTime }}</span>
|
||
</div>
|
||
<label>
|
||
<input type="checkbox" v-model="briefingConfig.slots[key]" />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
<p class="field-hint" style="margin-top: 0.5rem">
|
||
Firing in timezone: <strong>{{ userTimezone || 'UTC (not configured — set in General settings)' }}</strong>
|
||
</p>
|
||
</section>
|
||
|
||
<!-- RSS Feeds -->
|
||
<section class="settings-section full-width">
|
||
<div class="briefing-feeds-header">
|
||
<div>
|
||
<h2>RSS Feeds</h2>
|
||
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
|
||
</div>
|
||
<button
|
||
v-if="briefingFeeds.length"
|
||
class="btn-secondary briefing-refresh-btn"
|
||
@click="refreshFeeds"
|
||
:disabled="refreshingFeeds"
|
||
title="Fetch latest items from all feeds"
|
||
>{{ refreshingFeeds ? 'Refreshing…' : 'Refresh all' }}</button>
|
||
</div>
|
||
|
||
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
|
||
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
|
||
<div class="briefing-feed-info">
|
||
<div class="briefing-feed-title-row">
|
||
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
|
||
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
|
||
</div>
|
||
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
|
||
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
|
||
</div>
|
||
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
|
||
</div>
|
||
</div>
|
||
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
|
||
|
||
<div class="briefing-add-feed-form">
|
||
<div class="briefing-add-feed-inputs">
|
||
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
|
||
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
|
||
</div>
|
||
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
|
||
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- News Preferences -->
|
||
<section class="settings-section full-width">
|
||
<h2>News Preferences</h2>
|
||
<p class="section-desc">
|
||
Tell the briefing what topics you care about. Topics are matched against
|
||
classified RSS items before each briefing runs.
|
||
</p>
|
||
<div class="settings-field">
|
||
<label class="field-label">Interested in</label>
|
||
<TagInput
|
||
:model-value="briefingIncludeTopics"
|
||
placeholder="e.g. technology, science, local"
|
||
:fetch-tags="fetchTopicSuggestions"
|
||
@update:model-value="saveIncludeTopics"
|
||
/>
|
||
</div>
|
||
<div class="settings-field" style="margin-top: 0.75rem">
|
||
<label class="field-label">Not interested in</label>
|
||
<TagInput
|
||
:model-value="briefingExcludeTopics"
|
||
placeholder="e.g. sports, celebrity"
|
||
:fetch-tags="fetchTopicSuggestions"
|
||
@update:model-value="saveExcludeTopics"
|
||
/>
|
||
</div>
|
||
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
|
||
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
|
||
<p class="field-hint" style="margin-top: 0.35rem">
|
||
technology · science · politics · business · health · environment ·
|
||
local · entertainment · sports · other
|
||
</p>
|
||
<p class="field-hint">Custom terms are also accepted.</p>
|
||
</details>
|
||
</section>
|
||
|
||
<!-- Notifications -->
|
||
<section class="settings-section full-width">
|
||
<h2>Notifications</h2>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="briefingConfig.notifications" />
|
||
Push notification when each briefing slot is ready
|
||
</label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<div class="actions">
|
||
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
|
||
{{ briefingSaved ? 'Saved ✓' : briefingSaving ? 'Saving…' : 'Save Briefing Settings' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Voice ── -->
|
||
<div v-show="activeTab === 'voice'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Voice</h2>
|
||
<p class="section-desc">
|
||
Configure text-to-speech and speech-to-text for voice conversations.
|
||
Requires <code>VOICE_ENABLED=true</code> on the server.
|
||
</p>
|
||
|
||
<!-- Status indicator -->
|
||
<div v-if="voiceStatusLoading" class="field-hint">Loading voice status…</div>
|
||
<div v-else-if="voiceStatus === null" class="field-hint">Voice status unavailable.</div>
|
||
<div v-else class="voice-status-row">
|
||
<span :class="['status-badge', voiceStatus.enabled ? 'status-on' : 'status-off']">
|
||
{{ voiceStatus.enabled ? 'Enabled' : 'Disabled' }}
|
||
</span>
|
||
<span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.stt ? 'status-on' : 'status-off'">
|
||
STT {{ voiceStatus.stt ? 'ready' : 'loading…' }}
|
||
</span>
|
||
<span v-if="voiceStatus.enabled" class="status-badge" :class="voiceStatus.tts ? 'status-on' : 'status-off'">
|
||
TTS {{ voiceStatus.tts ? 'ready' : 'loading…' }}
|
||
</span>
|
||
<span v-if="voiceStatus.stt_model" class="field-hint" style="margin-left:0.5rem;">
|
||
Model: {{ voiceStatus.stt_model }}
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="!voiceStatus?.enabled" class="field-hint" style="margin-top:0.75rem;">
|
||
Voice is disabled. An administrator can enable it under
|
||
<strong>Admin → Config → Voice</strong>.
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="voiceStatus?.enabled" class="settings-section full-width">
|
||
<h2>Speech Style</h2>
|
||
<p class="section-desc">Controls how the assistant phrases spoken responses.</p>
|
||
|
||
<div class="field-group">
|
||
<label class="field-label">Style</label>
|
||
<div class="radio-group">
|
||
<label v-for="opt in [
|
||
{ value: 'conversational', label: 'Conversational', hint: 'Warm and natural, like speaking to a friend' },
|
||
{ value: 'concise', label: 'Concise', hint: 'Short and to the point' },
|
||
{ value: 'detailed', label: 'Detailed', hint: 'Thorough explanations spoken aloud' },
|
||
]" :key="opt.value" class="radio-option">
|
||
<input type="radio" v-model="voiceSpeechStyle" :value="opt.value" />
|
||
<span>
|
||
<strong>{{ opt.label }}</strong>
|
||
<span class="field-hint">{{ opt.hint }}</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="voiceStatus?.enabled" class="settings-section full-width">
|
||
<h2>Voice & Speed</h2>
|
||
<p class="section-desc">Choose the TTS voice and playback speed.</p>
|
||
|
||
<div class="field-group">
|
||
<label class="field-label" for="voice-select">Voice</label>
|
||
<select id="voice-select" class="input" v-model="voiceTtsVoice" :disabled="availableVoices.length === 0">
|
||
<option v-if="availableVoices.length === 0" value="af_heart">af_heart (default)</option>
|
||
<option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
|
||
</select>
|
||
<p class="field-hint">Voice character and accent. Applies to all voice conversations.</p>
|
||
</div>
|
||
|
||
<div class="field-group">
|
||
<label class="field-label" for="voice-speed">
|
||
Speed: {{ voiceTtsSpeed.toFixed(1) }}×
|
||
</label>
|
||
<input
|
||
id="voice-speed"
|
||
type="range"
|
||
min="0.7"
|
||
max="1.3"
|
||
step="0.05"
|
||
v-model.number="voiceTtsSpeed"
|
||
class="range-input"
|
||
/>
|
||
<div class="range-labels">
|
||
<span>0.7× (slow)</span>
|
||
<span>1.0× (normal)</span>
|
||
<span>1.3× (fast)</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button class="btn-secondary" @click="previewVoice" :disabled="voicePreviewing || !voiceStatus?.tts">
|
||
{{ voicePreviewing ? 'Generating…' : '▶ Preview' }}
|
||
</button>
|
||
<button class="btn-save" @click="saveVoiceSettings" :disabled="savingVoice">
|
||
{{ voiceSaved ? 'Saved ✓' : savingVoice ? 'Saving…' : 'Save Voice Settings' }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="voiceStatus?.enabled && voiceStatus?.tts" class="settings-section full-width">
|
||
<h2>Voice Blend</h2>
|
||
<p class="section-desc">
|
||
Mix two or more voice styles by weight. The resulting blended voice is used instead of
|
||
the single voice above when enabled.
|
||
</p>
|
||
|
||
<div class="field-group">
|
||
<label class="toggle-label">
|
||
<input type="checkbox" v-model="blendEnabled" />
|
||
<span>Enable voice blending</span>
|
||
</label>
|
||
</div>
|
||
|
||
<template v-if="blendEnabled">
|
||
<div class="blend-slots">
|
||
<div v-for="(entry, idx) in voiceBlend" :key="idx" class="blend-slot">
|
||
<select class="input blend-voice-select" v-model="entry.voice">
|
||
<option v-for="v in availableVoices" :key="v.id" :value="v.id">{{ v.label }}</option>
|
||
</select>
|
||
<div class="blend-weight-row">
|
||
<input
|
||
type="range"
|
||
min="0.1"
|
||
max="2.0"
|
||
step="0.05"
|
||
v-model.number="entry.weight"
|
||
class="range-input blend-weight-slider"
|
||
/>
|
||
<span class="blend-weight-label">{{ entry.weight.toFixed(2) }}</span>
|
||
<button
|
||
v-if="voiceBlend.length > 2"
|
||
class="btn-remove-slot"
|
||
@click="removeBlendSlot(idx)"
|
||
title="Remove this voice"
|
||
>✕</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="blend-actions">
|
||
<button class="btn-secondary" @click="addBlendSlot" :disabled="voiceBlend.length >= 5">
|
||
+ Add voice
|
||
</button>
|
||
</div>
|
||
|
||
<div class="field-group" style="margin-top: 1rem;">
|
||
<label class="field-label">Preview text</label>
|
||
<input
|
||
type="text"
|
||
class="input"
|
||
v-model="blendPreviewText"
|
||
placeholder="Text to speak for preview…"
|
||
maxlength="300"
|
||
/>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button class="btn-secondary" @click="previewBlend" :disabled="blendPreviewing">
|
||
{{ blendPreviewing ? 'Generating…' : '▶ Preview blend' }}
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── API Keys ── -->
|
||
<div v-show="activeTab === 'apikeys'" class="settings-grid">
|
||
<section class="settings-section full-width">
|
||
<h2>API Keys</h2>
|
||
<p class="settings-description">
|
||
API keys let external tools (like the Fable MCP server) access your data without a browser session.
|
||
Write-scoped keys can create and update content; read-only keys can only query.
|
||
</p>
|
||
|
||
<!-- Create form -->
|
||
<div class="api-key-create-form">
|
||
<input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" class="settings-input" />
|
||
<div class="api-key-scope-select">
|
||
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
|
||
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
|
||
</div>
|
||
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
|
||
{{ creatingApiKey ? 'Generating…' : 'Generate Key' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- One-time key reveal -->
|
||
<div v-if="newKeyValue" class="api-key-reveal">
|
||
<p><strong>Copy this key now — it will not be shown again.</strong></p>
|
||
<div class="api-key-value-row">
|
||
<code class="api-key-value">{{ newKeyValue }}</code>
|
||
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
|
||
</div>
|
||
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
|
||
<button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
|
||
<button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
|
||
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Keys table -->
|
||
<table v-if="apiKeys.length > 0" class="api-keys-table">
|
||
<thead>
|
||
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="key in apiKeys" :key="key.id">
|
||
<td>{{ key.name }}</td>
|
||
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
|
||
<td><code>{{ key.key_prefix }}…</code></td>
|
||
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
|
||
<td>
|
||
<span v-if="revokeConfirmId !== key.id">
|
||
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
|
||
</span>
|
||
<span v-else>
|
||
Sure?
|
||
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
|
||
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
|
||
</section>
|
||
|
||
<!-- Fable MCP install section -->
|
||
<section class="settings-section full-width">
|
||
<h2>Fable MCP</h2>
|
||
<p class="settings-description">
|
||
The Fable MCP server lets Claude (and other MCP clients) read and write your notes, tasks, and projects.
|
||
Install it locally, then point it at this Fable instance with an API key above.
|
||
</p>
|
||
|
||
<div v-if="mcpInfoLoading" class="mcp-status">Checking package availability…</div>
|
||
<template v-else-if="mcpInfo">
|
||
<div v-if="mcpInfo.available" class="mcp-available">
|
||
<div class="mcp-pkg-row">
|
||
<span class="mcp-pkg-name">{{ mcpInfo.filename }}</span>
|
||
<a href="/api/fable-mcp/download" class="btn btn-primary btn-sm" download>Download</a>
|
||
</div>
|
||
|
||
<div class="mcp-install-steps">
|
||
<h3>Installation</h3>
|
||
|
||
<div class="mcp-client-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'claude-code'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-code' }]"
|
||
@click="mcpClientTab = 'claude-code'"
|
||
>Claude Code</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'claude-desktop'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'claude-desktop' }]"
|
||
@click="mcpClientTab = 'claude-desktop'"
|
||
>Claude Desktop</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="mcpClientTab === 'other'"
|
||
:class="['mcp-client-tab', { active: mcpClientTab === 'other' }]"
|
||
@click="mcpClientTab = 'other'"
|
||
>Other</button>
|
||
</div>
|
||
|
||
<!-- Claude Code tab -->
|
||
<ol v-if="mcpClientTab === 'claude-code'">
|
||
<li>
|
||
Download the wheel above and install it with <a href="https://docs.astral.sh/uv/" target="_blank" rel="noopener">uv</a> or <a href="https://pipx.pypa.io/" target="_blank" rel="noopener">pipx</a> — either one works:
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">uv tool install ./{{ mcpInfo.filename }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(`uv tool install ./${mcpInfo.filename}`, 'cc-install-uv')">
|
||
{{ copiedSnippetKey === 'cc-install-uv' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">pipx install ./{{ mcpInfo.filename }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(`pipx install ./${mcpInfo.filename}`, 'cc-install-pipx')">
|
||
{{ copiedSnippetKey === 'cc-install-pipx' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
<p class="mcp-hint">This puts <code>fable-mcp</code> on your PATH so Claude Code can launch it.</p>
|
||
</li>
|
||
<li>
|
||
Register the server with Claude Code:
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ claudeCodeCommand }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(claudeCodeCommand, 'cc-add')">
|
||
{{ copiedSnippetKey === 'cc-add' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
<p class="mcp-hint">
|
||
<code>--scope user</code> makes <code>fable</code> available across all your projects. Use <code>--scope project</code> to write it into the current repo's <code>.mcp.json</code> instead, or <code>--scope local</code> for this machine and repo only.
|
||
<span v-if="!newKeyValue"> Generate an API key above to pre-fill the command.</span>
|
||
</p>
|
||
</li>
|
||
<li>
|
||
Verify the connection by running <code>/mcp</code> inside Claude Code — <code>fable</code> should appear as connected.
|
||
</li>
|
||
</ol>
|
||
|
||
<!-- Claude Desktop tab -->
|
||
<ol v-else-if="mcpClientTab === 'claude-desktop'">
|
||
<li>
|
||
Download the wheel above and install it with uv or pipx:
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">uv tool install ./{{ mcpInfo.filename }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(`uv tool install ./${mcpInfo.filename}`, 'cd-install')">
|
||
{{ copiedSnippetKey === 'cd-install' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
Add this block to your Claude Desktop MCP config file (<code>~/Library/Application Support/Claude/claude_desktop_config.json</code> on macOS, <code>%APPDATA%\Claude\claude_desktop_config.json</code> on Windows):
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">{{ mcpConfigSnippet }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(mcpConfigSnippet, 'cd-config')">
|
||
{{ copiedSnippetKey === 'cd-config' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
<li>
|
||
Restart Claude Desktop. The Fable tools should appear in the available tools list.
|
||
</li>
|
||
</ol>
|
||
|
||
<!-- Other tab -->
|
||
<div v-else class="mcp-other">
|
||
<p>
|
||
Any MCP-compatible client can launch <code>fable-mcp</code> over stdio. The exact setup depends on the client, but in general you'll need:
|
||
</p>
|
||
<ul class="mcp-plain-list">
|
||
<li>Install the wheel: <code>uv tool install ./{{ mcpInfo.filename }}</code> or <code>pipx install ./{{ mcpInfo.filename }}</code></li>
|
||
<li>Command: <code>fable-mcp</code></li>
|
||
<li>Transport: <code>stdio</code></li>
|
||
<li>Environment variables:
|
||
<div class="mcp-code-row">
|
||
<pre class="mcp-code">FABLE_URL={{ origin }}
|
||
FABLE_API_KEY={{ effectiveApiKey }}</pre>
|
||
<button class="btn btn-secondary btn-sm" @click="copySnippet(`FABLE_URL=${origin}\nFABLE_API_KEY=${effectiveApiKey}`, 'other-env')">
|
||
{{ copiedSnippetKey === 'other-env' ? 'Copied' : 'Copy' }}
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
<p class="mcp-hint">Consult your MCP client's documentation for how to register a stdio server. These instructions have only been tested with Claude Code.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-else class="mcp-unavailable">
|
||
<p>The Fable MCP package is not bundled in this image build. It will be available after the next image rebuild.</p>
|
||
</div>
|
||
</template>
|
||
</section>
|
||
</div>
|
||
|
||
<!-- ── Admin ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" 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
|
||
</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>
|
||
|
||
<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">
|
||
<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>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Voice (Speech-to-Speech)</h2>
|
||
<p class="section-desc">
|
||
Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
|
||
Save settings then click "Reload models" to apply without restarting the server.
|
||
Install dependencies first: <code>pip install faster-whisper kokoro soundfile</code>
|
||
</p>
|
||
<div class="checkbox-field">
|
||
<label>
|
||
<input type="checkbox" v-model="adminVoiceEnabled" />
|
||
Enable Voice
|
||
</label>
|
||
<p class="field-hint">Shows the PTT button and voice settings to all users.</p>
|
||
</div>
|
||
<div v-if="adminVoiceEnabled" class="field" style="max-width:280px; margin-top:0.75rem;">
|
||
<label for="stt-model">STT Model</label>
|
||
<select id="stt-model" v-model="adminVoiceSttModel" class="input">
|
||
<option value="tiny.en">tiny.en — fastest, lowest accuracy (~75 MB)</option>
|
||
<option value="base.en">base.en — balanced (recommended, ~145 MB)</option>
|
||
<option value="small.en">small.en — better accuracy (~465 MB)</option>
|
||
<option value="medium.en">medium.en — best accuracy (~1.5 GB)</option>
|
||
</select>
|
||
<p class="field-hint">Larger models are more accurate but use more RAM and take longer to load.</p>
|
||
</div>
|
||
<div class="actions" style="margin-top:0.85rem;">
|
||
<button class="btn-save" @click="saveAdminVoice" :disabled="savingAdminVoice || voiceLoadingModels">
|
||
{{ savingAdminVoice ? 'Saving…' : adminVoiceSaved ? 'Saved ✓' : 'Save' }}
|
||
</button>
|
||
<button
|
||
v-if="!voiceLoadingModels && adminVoiceEnabled"
|
||
class="btn-secondary"
|
||
@click="reloadVoiceModels"
|
||
:disabled="voiceLoadingModels"
|
||
title="Reload models without restarting the server"
|
||
>Reload models</button>
|
||
<span v-if="voiceLoadingModels" class="field-hint">
|
||
Loading models… this may take a minute.
|
||
<span class="voice-admin-spinner">
|
||
<span></span><span></span><span></span>
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Users ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Registration</h2>
|
||
<div class="registration-row">
|
||
<div class="registration-info">
|
||
<p class="registration-status">
|
||
Registration is currently
|
||
<strong :class="registrationOpen ? 'text-success' : 'text-muted'">
|
||
{{ registrationOpen ? "open" : "closed" }}
|
||
</strong>
|
||
</p>
|
||
<p class="field-hint">When closed, new users can only be added by an administrator.</p>
|
||
</div>
|
||
<button
|
||
class="btn-toggle"
|
||
:class="registrationOpen ? 'btn-toggle-close' : 'btn-toggle-open'"
|
||
@click="toggleRegistration"
|
||
:disabled="toggling"
|
||
>
|
||
{{ toggling ? "Updating..." : registrationOpen ? "Close Registration" : "Open Registration" }}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Invite User</h2>
|
||
<form class="invite-form" @submit.prevent="sendInvite">
|
||
<input
|
||
v-model="inviteEmail"
|
||
type="email"
|
||
placeholder="Email address"
|
||
class="input invite-input"
|
||
required
|
||
:disabled="sendingInvite"
|
||
/>
|
||
<button type="submit" class="btn-save" :disabled="sendingInvite || !inviteEmail.trim()">
|
||
{{ sendingInvite ? "Sending..." : "Send Invite" }}
|
||
</button>
|
||
</form>
|
||
<p class="field-hint">Send an invitation link to allow someone to register, even when public registration is closed.</p>
|
||
<div v-if="invitations.length > 0" class="invite-list">
|
||
<h3 class="subsection-label">Pending Invitations</h3>
|
||
<table class="users-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Email</th>
|
||
<th class="hide-mobile">Sent</th>
|
||
<th class="hide-mobile">Expires</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="inv in invitations" :key="inv.id">
|
||
<td class="cell-email">{{ inv.email }}</td>
|
||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.created_at) }}</td>
|
||
<td class="hide-mobile cell-date">{{ formatUserDate(inv.expires_at) }}</td>
|
||
<td class="cell-actions">
|
||
<button class="btn-delete" @click="revokeInvitation(inv.id)" :disabled="revokingId !== null">
|
||
{{ revokingId === inv.id ? "Revoking..." : "Revoke" }}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Users</h2>
|
||
<div v-if="usersLoading" class="loading-msg">Loading users...</div>
|
||
<div v-else-if="users.length === 0" class="empty-msg">No users found.</div>
|
||
<table v-else class="users-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Username</th>
|
||
<th class="hide-mobile">Email</th>
|
||
<th>Role</th>
|
||
<th class="hide-mobile">Joined</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="u in users" :key="u.id">
|
||
<td class="cell-username">{{ u.username }}</td>
|
||
<td class="hide-mobile cell-email">{{ u.email || "—" }}</td>
|
||
<td>
|
||
<span class="role-badge" :class="u.role === 'admin' ? 'role-admin' : 'role-user'">
|
||
{{ u.role }}
|
||
</span>
|
||
</td>
|
||
<td class="hide-mobile cell-date">{{ formatUserDate(u.created_at) }}</td>
|
||
<td class="cell-actions">
|
||
<template v-if="u.id === authStore.user?.id">
|
||
<span class="you-label">You</span>
|
||
</template>
|
||
<template v-else-if="confirmDeleteId === u.id">
|
||
<button class="btn-confirm-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">
|
||
{{ deleting === u.id ? "Deleting..." : "Confirm" }}
|
||
</button>
|
||
<button class="btn-cancel-delete" @click="cancelDelete">Cancel</button>
|
||
</template>
|
||
<template v-else>
|
||
<button class="btn-delete" @click="confirmDelete(u.id)" :disabled="deleting !== null">Delete</button>
|
||
</template>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Logs ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'logs'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width stats-section">
|
||
<h2>Overview</h2>
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<span class="stat-count">{{ logStats.total.toLocaleString() }}</span>
|
||
<span class="stat-label">Total</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-count stat-audit">{{ logStats.audit.toLocaleString() }}</span>
|
||
<span class="stat-label">Audit</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-count stat-usage">{{ logStats.usage.toLocaleString() }}</span>
|
||
<span class="stat-label">Usage</span>
|
||
</div>
|
||
<div class="stat-card">
|
||
<span class="stat-count stat-error">{{ logStats.error.toLocaleString() }}</span>
|
||
<span class="stat-label">Error</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Log Entries</h2>
|
||
<div class="filter-bar">
|
||
<select v-model="logCategory" class="filter-select input">
|
||
<option value="">All categories</option>
|
||
<option value="audit">Audit</option>
|
||
<option value="usage">Usage</option>
|
||
<option value="error">Error</option>
|
||
</select>
|
||
<input v-model="logSearch" type="text" placeholder="Search logs..." class="filter-input input" />
|
||
<input v-model="logDateFrom" type="date" class="filter-date input" title="From date" />
|
||
<input v-model="logDateTo" type="date" class="filter-date input" title="To date" />
|
||
<button
|
||
v-if="logCategory || logSearch || logDateFrom || logDateTo"
|
||
class="btn-secondary"
|
||
@click="clearLogFilters"
|
||
>Clear</button>
|
||
</div>
|
||
|
||
<div v-if="logsLoading" class="loading-msg">Loading logs...</div>
|
||
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
|
||
<template v-else>
|
||
<table class="users-table logs-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Time</th>
|
||
<th>Category</th>
|
||
<th class="hide-mobile">User</th>
|
||
<th>Action / Endpoint</th>
|
||
<th class="hide-mobile">Status</th>
|
||
<th class="hide-mobile">Duration</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<template v-for="entry in logs" :key="entry.id">
|
||
<tr class="log-row" :class="{ 'row-expanded': expandedLogId === entry.id }" @click="toggleLogExpand(entry.id)">
|
||
<td class="cell-time">{{ formatLogTime(entry.created_at) }}</td>
|
||
<td>
|
||
<span class="category-badge" :class="'cat-' + entry.category">{{ entry.category }}</span>
|
||
</td>
|
||
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
|
||
<td class="cell-action">
|
||
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
|
||
{{ logDisplayLabel(entry) }}
|
||
</td>
|
||
<td class="hide-mobile cell-status">
|
||
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
|
||
{{ entry.status_code }}
|
||
</span>
|
||
<span v-else>—</span>
|
||
</td>
|
||
<td class="hide-mobile cell-duration">
|
||
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
|
||
</td>
|
||
</tr>
|
||
<tr v-if="expandedLogId === entry.id && (entry.details || entry.ip_address)" class="detail-row">
|
||
<td colspan="6">
|
||
<div v-if="entry.ip_address" class="detail-ip">IP: {{ entry.ip_address }}</div>
|
||
<pre v-if="entry.details" class="detail-json">{{ formatLogDetails(entry.details) }}</pre>
|
||
</td>
|
||
</tr>
|
||
</template>
|
||
</tbody>
|
||
</table>
|
||
<PaginationBar
|
||
:total="logTotal"
|
||
:limit="logLimit"
|
||
:offset="logOffset"
|
||
@update:offset="logOffset = $event"
|
||
/>
|
||
</template>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- ── Groups ── -->
|
||
<div v-if="authStore.isAdmin" v-show="activeTab === 'groups'" class="settings-grid">
|
||
|
||
<section class="settings-section full-width">
|
||
<h2>Groups</h2>
|
||
<p class="section-desc">Manage platform-wide groups for sharing projects and notes.</p>
|
||
|
||
<!-- Create group form -->
|
||
<div class="group-create-form">
|
||
<input v-model="newGroupName" class="input-field" placeholder="Group name" maxlength="100" @keydown.enter="createNewGroup" />
|
||
<input v-model="newGroupDesc" class="input-field" placeholder="Description (optional)" maxlength="255" @keydown.enter="createNewGroup" />
|
||
<button class="btn-primary" @click="createNewGroup" :disabled="creatingGroup || !newGroupName.trim()">
|
||
{{ creatingGroup ? 'Creating…' : 'Create Group' }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Groups list -->
|
||
<div v-if="groupsLoading" class="loading-msg">Loading groups…</div>
|
||
<div v-else-if="!groups.length" class="empty-msg">No groups yet.</div>
|
||
<div v-else class="groups-list">
|
||
<div v-for="g in groups" :key="g.id" class="group-card">
|
||
<div class="group-card-header">
|
||
<div class="group-card-info">
|
||
<span class="group-name">{{ g.name }}</span>
|
||
<span class="group-meta">{{ g.member_count }} member{{ g.member_count !== 1 ? 's' : '' }}</span>
|
||
<span v-if="g.description" class="group-desc">{{ g.description }}</span>
|
||
</div>
|
||
<div class="group-card-actions">
|
||
<button class="btn-sm" @click="toggleGroupExpand(g)">
|
||
{{ expandedGroupId === g.id ? 'Collapse' : 'Manage' }}
|
||
</button>
|
||
<button class="btn-sm btn-danger-sm" @click="deleteGroupConfirm(g)">Delete</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Members panel -->
|
||
<div v-if="expandedGroupId === g.id" class="group-members-panel">
|
||
<div class="members-search">
|
||
<div class="member-search-wrap">
|
||
<input
|
||
v-model="groupMemberSearch"
|
||
class="input-field"
|
||
placeholder="Search user to add…"
|
||
@input="debounceGroupMemberSearch"
|
||
autocomplete="off"
|
||
/>
|
||
<ul v-if="groupMemberResults.length" class="member-results">
|
||
<li v-for="u in groupMemberResults" :key="u.id" class="member-result-item" @click="addMemberToGroup(g.id, u)">
|
||
<span class="member-result-name">{{ u.username }}</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<select v-model="groupMemberRole" class="role-select">
|
||
<option value="member">Member</option>
|
||
<option value="owner">Owner</option>
|
||
</select>
|
||
</div>
|
||
<ul class="members-list">
|
||
<li v-for="m in (groupMembers[g.id] || [])" :key="m.user_id" class="member-row">
|
||
<span class="member-name">{{ m.username }}</span>
|
||
<span class="member-role-badge" :class="`role-${m.role}`">{{ m.role }}</span>
|
||
<button class="btn-sm btn-danger-sm" @click="removeMemberFromGroup(g.id, m.user_id)">Remove</button>
|
||
</li>
|
||
<li v-if="!(groupMembers[g.id]?.length)" class="members-empty">No members yet.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
</div><!-- end .settings-content -->
|
||
</main>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* Settings root layout */
|
||
.settings-root {
|
||
display: flex;
|
||
gap: 0;
|
||
max-width: 1100px;
|
||
margin: 2rem auto;
|
||
padding: 0 1.5rem;
|
||
align-items: flex-start;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Sidebar */
|
||
.settings-sidebar {
|
||
width: 175px;
|
||
flex-shrink: 0;
|
||
position: sticky;
|
||
top: 1.5rem;
|
||
padding-right: 1rem;
|
||
}
|
||
.sidebar-group {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.sidebar-group-label {
|
||
font-size: 0.65rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
color: var(--color-text-muted);
|
||
padding: 0.25rem 0.75rem 0.2rem;
|
||
}
|
||
.sidebar-item {
|
||
display: block;
|
||
width: 100%;
|
||
text-align: left;
|
||
padding: 0.4rem 0.75rem;
|
||
border: none;
|
||
border-left: 2px solid transparent;
|
||
background: none;
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-secondary);
|
||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.sidebar-item:hover {
|
||
color: var(--color-text);
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.sidebar-item.active {
|
||
color: var(--color-primary);
|
||
background: rgba(124, 58, 237, 0.08);
|
||
border-left-color: var(--color-primary);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Content panel */
|
||
.settings-content {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* 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.75rem;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.07em;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.section-desc {
|
||
margin: 0 0 1rem;
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-secondary);
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── Model Management ───────────────────────────────────────── */
|
||
.model-mgmt-header {
|
||
display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem;
|
||
}
|
||
.model-mgmt-header h2 { margin: 0; }
|
||
.model-mgmt-header .section-desc { margin: 0.25rem 0 0; }
|
||
.model-list { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
|
||
.model-row {
|
||
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
|
||
padding: 0.45rem 0.6rem;
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
}
|
||
.model-row-info { display: flex; align-items: center; gap: 0.4rem; min-width: 0; flex: 1; }
|
||
.model-name { font-size: 0.88rem; font-weight: 500; font-family: monospace; }
|
||
.model-badge {
|
||
font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 3px; font-weight: 600; white-space: nowrap;
|
||
}
|
||
.model-badge--loaded { background: color-mix(in srgb, #22c55e 18%, transparent); color: #22c55e; }
|
||
.model-badge--default { background: color-mix(in srgb, var(--color-primary) 18%, transparent); color: var(--color-primary); }
|
||
.model-row-right { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||
.model-size { font-size: 0.78rem; color: var(--color-text-muted); }
|
||
.model-delete-btn {
|
||
background: none; border: none; cursor: pointer; color: var(--color-text-muted);
|
||
font-size: 0.8rem; padding: 0.2rem 0.35rem; border-radius: 3px; line-height: 1;
|
||
transition: color 0.15s, background 0.15s;
|
||
}
|
||
.model-delete-btn:hover:not(:disabled) { color: var(--color-danger, #ef4444); background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent); }
|
||
.model-delete-btn:disabled { opacity: 0.4; cursor: default; }
|
||
.model-pull-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
||
.model-pull-form .input { flex: 1; }
|
||
.model-suggestions { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.4rem; }
|
||
.suggestions-label { font-size: 0.75rem; color: var(--color-text-muted); white-space: nowrap; }
|
||
.suggestion-chip {
|
||
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 4px;
|
||
border: 1px solid var(--color-border); background: var(--color-bg-card);
|
||
cursor: pointer; font-family: monospace; color: var(--color-text);
|
||
transition: border-color 0.12s, background 0.12s;
|
||
}
|
||
.suggestion-chip:hover:not(:disabled) { border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card)); }
|
||
.suggestion-chip:disabled { opacity: 0.4; cursor: default; }
|
||
.model-pull-progress { margin-top: 0.6rem; }
|
||
.pull-status { font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.25rem; }
|
||
.pull-bar-track {
|
||
height: 4px; background: var(--color-border); border-radius: 2px; overflow: hidden;
|
||
}
|
||
.pull-bar-fill {
|
||
height: 100%; background: var(--color-primary); border-radius: 2px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
.pull-bar-indeterminate {
|
||
height: 4px; background: var(--color-border); border-radius: 2px;
|
||
position: relative; overflow: hidden;
|
||
}
|
||
.pull-bar-indeterminate::after {
|
||
content: ""; position: absolute; top: 0; left: -40%;
|
||
width: 40%; height: 100%; background: var(--color-primary); border-radius: 2px;
|
||
animation: indeterminate 1.2s ease-in-out infinite;
|
||
}
|
||
@keyframes indeterminate {
|
||
0% { left: -40%; } 100% { left: 100%; }
|
||
}
|
||
|
||
/* Assistant — 2-col internal grid */
|
||
.assistant-grid {
|
||
display: grid;
|
||
grid-template-columns: 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);
|
||
}
|
||
.field-hint-warn {
|
||
display: block;
|
||
margin-top: 0.3rem;
|
||
color: #f59e0b;
|
||
}
|
||
.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-danger-outline {
|
||
padding: 0.4rem 0.9rem;
|
||
background: none;
|
||
color: var(--color-danger, #e74c3c);
|
||
border: 1px solid var(--color-danger, #e74c3c);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
white-space: nowrap;
|
||
}
|
||
.btn-danger-outline:hover:not(:disabled) {
|
||
background: var(--color-danger, #e74c3c);
|
||
color: #fff;
|
||
}
|
||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||
|
||
.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);
|
||
}
|
||
|
||
.retention-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-top: 0.4rem;
|
||
}
|
||
.retention-input {
|
||
width: 6rem;
|
||
}
|
||
|
||
/* 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;
|
||
}
|
||
|
||
/* Push notifications */
|
||
.push-unsupported {
|
||
font-size: 0.875rem;
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
}
|
||
.push-status-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.4rem;
|
||
font-size: 0.875rem;
|
||
}
|
||
.push-status-label {
|
||
color: var(--color-text-secondary);
|
||
font-weight: 500;
|
||
}
|
||
.push-permission-badge,
|
||
.push-sub-badge {
|
||
font-size: 0.72rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 999px;
|
||
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
|
||
color: var(--color-text-muted);
|
||
}
|
||
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
|
||
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
|
||
.push-error {
|
||
font-size: 0.82rem;
|
||
color: var(--color-danger, #e74c3c);
|
||
margin: 0.25rem 0 0;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.settings-root {
|
||
flex-direction: column;
|
||
padding: 0 1rem;
|
||
}
|
||
.settings-sidebar {
|
||
width: 100%;
|
||
position: static;
|
||
padding-right: 0;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
margin-bottom: 1rem;
|
||
}
|
||
.sidebar-group {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.25rem;
|
||
margin-bottom: 0.25rem;
|
||
}
|
||
.sidebar-group-label {
|
||
width: 100%;
|
||
}
|
||
.sidebar-item {
|
||
width: auto;
|
||
border-left: none;
|
||
border-bottom: 2px solid transparent;
|
||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||
font-size: 0.82rem;
|
||
padding: 0.35rem 0.7rem;
|
||
}
|
||
.sidebar-item.active {
|
||
border-bottom-color: var(--color-primary);
|
||
background: var(--color-primary-tint);
|
||
}
|
||
.settings-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.settings-section.full-width {
|
||
grid-column: auto;
|
||
}
|
||
.assistant-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.smtp-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.caldav-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* Users panel */
|
||
.registration-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
}
|
||
.registration-info { flex: 1; }
|
||
.registration-status { margin: 0; font-size: 0.95rem; }
|
||
.text-success { color: var(--color-success); }
|
||
.text-muted { color: var(--color-text-muted); }
|
||
.invite-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.invite-input { flex: 1; }
|
||
.invite-list { margin-top: 1rem; }
|
||
.subsection-label {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 0.85rem;
|
||
font-weight: 600;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.users-table { width: 100%; border-collapse: collapse; }
|
||
.users-table th {
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-muted);
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
.users-table td {
|
||
padding: 0.65rem 0.75rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
font-size: 0.9rem;
|
||
}
|
||
.users-table tbody tr:last-child td { border-bottom: none; }
|
||
.cell-username { font-weight: 600; }
|
||
.cell-email { color: var(--color-text-secondary); }
|
||
.cell-date { color: var(--color-text-muted); font-size: 0.85rem; }
|
||
.cell-actions { white-space: nowrap; }
|
||
.role-badge {
|
||
display: inline-block;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.role-admin {
|
||
color: var(--color-primary);
|
||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||
}
|
||
.role-user {
|
||
color: var(--color-text-muted);
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.you-label { font-size: 0.8rem; color: var(--color-text-muted); font-style: italic; }
|
||
.btn-delete {
|
||
padding: 0.25rem 0.6rem;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
.btn-delete:hover:not(:disabled) { border-color: var(--color-danger); color: var(--color-danger); }
|
||
.btn-delete:disabled { opacity: 0.4; cursor: default; }
|
||
.btn-confirm-delete {
|
||
padding: 0.25rem 0.6rem;
|
||
background: var(--color-danger);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
margin-right: 0.25rem;
|
||
}
|
||
.btn-confirm-delete:hover:not(:disabled) { filter: brightness(0.9); }
|
||
.btn-confirm-delete:disabled { opacity: 0.6; cursor: default; }
|
||
.btn-cancel-delete {
|
||
padding: 0.25rem 0.6rem;
|
||
background: transparent;
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.8rem;
|
||
}
|
||
.btn-cancel-delete:hover { color: var(--color-text); border-color: var(--color-text-muted); }
|
||
.btn-toggle {
|
||
padding: 0.45rem 1rem;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.btn-toggle:disabled { opacity: 0.6; cursor: default; }
|
||
.btn-toggle-open { background: var(--color-primary); color: #fff; }
|
||
.btn-toggle-open:hover:not(:disabled) { opacity: 0.9; }
|
||
.btn-toggle-close {
|
||
background: var(--color-bg-secondary);
|
||
color: var(--color-text);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
.btn-toggle-close:hover:not(:disabled) { border-color: var(--color-warning); color: var(--color-warning); }
|
||
.loading-msg, .empty-msg {
|
||
text-align: center;
|
||
color: var(--color-text-muted);
|
||
font-size: 0.9rem;
|
||
padding: 1rem 0;
|
||
}
|
||
|
||
/* Logs panel */
|
||
.stats-section { padding: 1rem 1.25rem; }
|
||
.stats-grid { display: flex; gap: 1rem; flex-wrap: wrap; }
|
||
.stat-card {
|
||
flex: 1;
|
||
min-width: 80px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 0.15rem;
|
||
}
|
||
.stat-count { font-size: 1.5rem; font-weight: 700; color: var(--color-text); }
|
||
.stat-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.stat-audit { color: var(--color-primary); }
|
||
.stat-usage { color: var(--color-success); }
|
||
.stat-error { color: var(--color-danger); }
|
||
|
||
.filter-bar { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||
.filter-select { min-width: 140px; }
|
||
.filter-input { flex: 1; min-width: 150px; }
|
||
.filter-date { width: 140px; }
|
||
|
||
.logs-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||
.logs-table th {
|
||
text-align: left;
|
||
font-size: 0.8rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
color: var(--color-text-muted);
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
.logs-table td {
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
font-size: 0.85rem;
|
||
}
|
||
.logs-table tbody tr:last-child td { border-bottom: none; }
|
||
.log-row { cursor: pointer; transition: background 0.1s; }
|
||
.log-row:hover { background: var(--color-bg-secondary); }
|
||
.row-expanded { background: var(--color-bg-secondary); }
|
||
.cell-time { white-space: nowrap; color: var(--color-text-muted); font-size: 0.8rem; }
|
||
.cell-user { color: var(--color-text-secondary); }
|
||
.cell-action { max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.cell-status { font-family: monospace; font-size: 0.85rem; }
|
||
.cell-duration { color: var(--color-text-muted); font-size: 0.8rem; white-space: nowrap; }
|
||
.text-error { color: var(--color-danger); }
|
||
.detail-row td { padding: 0 0.75rem 0.75rem; border-bottom: 1px solid var(--color-border); }
|
||
.detail-ip { font-family: monospace; font-size: 0.8rem; color: var(--color-text-muted); margin-bottom: 0.4rem; }
|
||
.detail-json {
|
||
margin: 0; padding: 0.75rem;
|
||
background: var(--color-bg); border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm); font-size: 0.8rem;
|
||
overflow-x: auto; white-space: pre-wrap; word-break: break-all; max-height: 300px;
|
||
}
|
||
.category-badge {
|
||
display: inline-block;
|
||
font-size: 0.65rem; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.05em;
|
||
padding: 0.1rem 0.35rem; border-radius: var(--radius-sm);
|
||
}
|
||
.cat-audit { color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 15%, transparent); }
|
||
.cat-usage { color: var(--color-success); background: color-mix(in srgb, var(--color-success) 15%, transparent); }
|
||
.cat-error { color: var(--color-danger); background: color-mix(in srgb, var(--color-danger) 15%, transparent); }
|
||
.method-tag {
|
||
display: inline-block;
|
||
font-size: 0.65rem; font-weight: 700; font-family: monospace;
|
||
padding: 0.05rem 0.25rem; border-radius: 3px;
|
||
background: var(--color-bg-secondary); color: var(--color-text-muted);
|
||
margin-right: 0.25rem;
|
||
}
|
||
|
||
/* ── About / version ─────────────────────────────────────────── */
|
||
.version-line {
|
||
margin: 0;
|
||
font-size: 0.9rem;
|
||
color: var(--color-text);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
}
|
||
.version-badge {
|
||
font-family: ui-monospace, monospace;
|
||
font-size: 0.8rem;
|
||
padding: 0.15rem 0.5rem;
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-primary);
|
||
border-radius: 4px;
|
||
color: var(--color-primary);
|
||
}
|
||
|
||
/* ── Groups tab ──────────────────────────────────────────────── */
|
||
.btn-primary {
|
||
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-primary:disabled { opacity: 0.6; cursor: default; }
|
||
.btn-primary:hover:not(:disabled) { opacity: 0.9; }
|
||
|
||
.input-field {
|
||
width: 100%;
|
||
padding: 0.4rem 0.6rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--color-surface);
|
||
color: var(--color-text);
|
||
font-size: 0.875rem;
|
||
font-family: inherit;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.input-field:focus { border-color: var(--color-primary); }
|
||
|
||
.group-create-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1.5rem;
|
||
align-items: center;
|
||
}
|
||
.group-create-form .input-field { flex: 1; min-width: 160px; }
|
||
|
||
.loading-msg, .empty-msg {
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
font-size: 0.88rem;
|
||
padding: 0.5rem 0;
|
||
}
|
||
|
||
.groups-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.group-card {
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md, 8px);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.group-card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
|
||
.group-card-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.group-name {
|
||
font-weight: 600;
|
||
font-size: 0.9rem;
|
||
color: var(--color-text);
|
||
}
|
||
.group-meta {
|
||
font-size: 0.78rem;
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
}
|
||
.group-desc {
|
||
font-size: 0.82rem;
|
||
color: var(--color-text-muted);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.group-card-actions {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 0.25rem 0.6rem;
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 4px;
|
||
font-size: 0.78rem;
|
||
color: var(--color-text-secondary);
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: border-color 0.15s, color 0.15s;
|
||
}
|
||
.btn-sm:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
.btn-danger-sm:hover { border-color: var(--color-danger, #e74c3c); color: var(--color-danger, #e74c3c); }
|
||
|
||
.group-members-panel {
|
||
padding: 0.75rem 1rem 1rem;
|
||
border-top: 1px solid var(--color-border);
|
||
background: var(--color-surface);
|
||
}
|
||
|
||
.members-search {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: flex-start;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
|
||
.member-search-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
}
|
||
|
||
.member-results {
|
||
position: absolute;
|
||
top: 100%;
|
||
left: 0;
|
||
right: 0;
|
||
background: var(--color-surface);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
margin-top: 2px;
|
||
list-style: none;
|
||
padding: 0.25rem 0;
|
||
z-index: 10;
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||
max-height: 160px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.member-result-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.45rem 0.8rem;
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
.member-result-item:hover { background: var(--color-hover); }
|
||
.member-result-name { font-weight: 600; font-size: 0.85rem; }
|
||
.member-result-email { color: var(--color-text-muted); font-size: 0.78rem; }
|
||
|
||
.role-select {
|
||
padding: 0.4rem 0.5rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
background: var(--color-surface);
|
||
color: var(--color-text);
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.members-list {
|
||
list-style: none;
|
||
padding: 0;
|
||
margin: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.35rem;
|
||
}
|
||
|
||
.member-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.4rem 0.5rem;
|
||
border-radius: 6px;
|
||
background: var(--color-hover);
|
||
}
|
||
|
||
.member-name { flex: 1; font-size: 0.88rem; font-weight: 500; color: var(--color-text); }
|
||
|
||
.member-role-badge {
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
padding: 0.15rem 0.4rem;
|
||
border-radius: 4px;
|
||
}
|
||
.role-owner { background: color-mix(in srgb, var(--color-warning, #f59e0b) 15%, transparent); color: var(--color-warning, #f59e0b); }
|
||
.role-member { background: color-mix(in srgb, var(--color-muted) 15%, transparent); color: var(--color-muted); }
|
||
|
||
.members-empty {
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
font-size: 0.82rem;
|
||
padding: 0.25rem 0.5rem;
|
||
}
|
||
|
||
/* Briefing tab */
|
||
.briefing-location-row {
|
||
margin-bottom: 1rem;
|
||
}
|
||
.briefing-input-group {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
margin-top: 0.25rem;
|
||
}
|
||
.briefing-geo-confirmed {
|
||
font-size: 0.78rem;
|
||
color: var(--color-success, #22c55e);
|
||
margin-top: 0.2rem;
|
||
}
|
||
.briefing-geo-error {
|
||
font-size: 0.78rem;
|
||
color: var(--color-danger, #ef4444);
|
||
margin-top: 0.2rem;
|
||
}
|
||
.briefing-day-toggles {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.briefing-unit-toggle {
|
||
display: flex;
|
||
gap: 0;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
width: fit-content;
|
||
margin-top: 0.25rem;
|
||
}
|
||
.briefing-unit-btn {
|
||
padding: 0.35rem 1rem;
|
||
background: var(--color-bg-card);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.85rem;
|
||
cursor: pointer;
|
||
border: none;
|
||
font-family: inherit;
|
||
transition: all 0.15s;
|
||
}
|
||
.briefing-unit-btn:first-child {
|
||
border-right: 1px solid var(--color-border);
|
||
}
|
||
.briefing-unit-btn.active {
|
||
background: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
.briefing-day-btn {
|
||
padding: 0.35rem 0.65rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
background: var(--color-bg-card);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.briefing-day-btn.active {
|
||
background: var(--color-primary);
|
||
border-color: var(--color-primary);
|
||
color: #fff;
|
||
}
|
||
.briefing-slot-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.4rem;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.briefing-slot-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.45rem 0.75rem;
|
||
border-radius: 8px;
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.briefing-slot-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
.briefing-slot-label {
|
||
font-size: 0.88rem;
|
||
font-weight: 500;
|
||
}
|
||
.briefing-slot-time {
|
||
font-size: 0.78rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.briefing-feeds-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.3rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.briefing-feed-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.4rem 0.6rem;
|
||
border-radius: 6px;
|
||
background: var(--color-bg-secondary);
|
||
}
|
||
.briefing-feed-info {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.briefing-feed-title {
|
||
font-size: 0.85rem;
|
||
font-weight: 500;
|
||
display: block;
|
||
}
|
||
.briefing-feed-url {
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: block;
|
||
}
|
||
.briefing-btn-remove {
|
||
background: none;
|
||
border: none;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
font-size: 0.85rem;
|
||
padding: 0.2rem 0.4rem;
|
||
border-radius: 4px;
|
||
flex-shrink: 0;
|
||
transition: color 0.15s;
|
||
}
|
||
.briefing-btn-remove:hover { color: var(--color-danger, #ef4444); }
|
||
.briefing-feeds-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 1rem;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.briefing-feeds-header h2 { margin: 0; }
|
||
.briefing-feeds-header .section-desc { margin: 0.25rem 0 0; }
|
||
.briefing-refresh-btn { white-space: nowrap; flex-shrink: 0; }
|
||
.briefing-feed-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
.briefing-feed-cat {
|
||
font-size: 0.7rem;
|
||
padding: 0.1rem 0.4rem;
|
||
border-radius: 3px;
|
||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||
color: var(--color-primary);
|
||
font-weight: 500;
|
||
}
|
||
.briefing-feed-age {
|
||
font-size: 0.72rem;
|
||
color: var(--color-text-muted);
|
||
display: block;
|
||
margin-top: 0.1rem;
|
||
}
|
||
.briefing-add-feed-form {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
margin-top: 0.75rem;
|
||
}
|
||
.briefing-add-feed-inputs {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex: 1;
|
||
flex-wrap: wrap;
|
||
}
|
||
.briefing-add-feed-inputs .input { flex: 1; min-width: 160px; }
|
||
.briefing-cat-input { max-width: 180px; }
|
||
.briefing-add-feed {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-top: 0.5rem;
|
||
}
|
||
|
||
/* API Keys tab */
|
||
.api-key-create-form {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.api-key-scope-select {
|
||
display: flex;
|
||
gap: 1rem;
|
||
}
|
||
.api-key-scope-select label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.3rem;
|
||
cursor: pointer;
|
||
}
|
||
.api-key-reveal {
|
||
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
|
||
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
|
||
border-radius: var(--radius-md);
|
||
padding: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
}
|
||
.api-key-value-row {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.api-key-value {
|
||
flex: 1;
|
||
background: var(--color-surface-2, var(--color-surface));
|
||
padding: 0.4rem 0.6rem;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 0.85rem;
|
||
word-break: break-all;
|
||
}
|
||
.api-keys-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
margin-top: 1rem;
|
||
}
|
||
.api-keys-table th, .api-keys-table td {
|
||
text-align: left;
|
||
padding: 0.5rem 0.75rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
font-size: 0.9rem;
|
||
}
|
||
.api-keys-table th { font-weight: 600; opacity: 0.7; }
|
||
.scope-badge {
|
||
display: inline-block;
|
||
padding: 0.1rem 0.5rem;
|
||
border-radius: 9999px;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
}
|
||
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
|
||
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
|
||
.settings-empty { opacity: 0.5; margin-top: 1rem; }
|
||
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
|
||
|
||
/* Fable MCP section */
|
||
.mcp-status { opacity: 0.6; font-size: 0.9rem; }
|
||
.mcp-unavailable p { opacity: 0.7; }
|
||
.mcp-available { display: flex; flex-direction: column; gap: 1.25rem; }
|
||
.mcp-pkg-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 1rem;
|
||
padding: 0.65rem 0.9rem;
|
||
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
|
||
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
|
||
border-radius: 8px;
|
||
}
|
||
.mcp-pkg-name { font-family: monospace; font-size: 0.9rem; flex: 1; }
|
||
.mcp-install-steps h3 { font-size: 0.95rem; font-weight: 600; margin-bottom: 0.75rem; }
|
||
.mcp-install-steps ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
|
||
.mcp-install-steps li { line-height: 1.6; font-size: 0.9rem; }
|
||
.mcp-code {
|
||
margin-top: 0.4rem;
|
||
padding: 0.55rem 0.75rem;
|
||
background: color-mix(in srgb, var(--color-text) 6%, transparent);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
font-size: 0.82rem;
|
||
font-family: monospace;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
overflow-x: auto;
|
||
flex: 1;
|
||
}
|
||
.mcp-client-tabs {
|
||
display: flex;
|
||
gap: 0.25rem;
|
||
margin-bottom: 1rem;
|
||
border-bottom: 1px solid var(--color-border);
|
||
}
|
||
.mcp-client-tab {
|
||
background: transparent;
|
||
border: none;
|
||
padding: 0.5rem 0.9rem;
|
||
font-size: 0.85rem;
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
border-bottom: 2px solid transparent;
|
||
margin-bottom: -1px;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.mcp-client-tab:hover { color: var(--color-text); }
|
||
.mcp-client-tab.active {
|
||
color: var(--color-primary);
|
||
border-bottom-color: var(--color-primary);
|
||
font-weight: 600;
|
||
}
|
||
.mcp-code-row {
|
||
display: flex;
|
||
align-items: stretch;
|
||
gap: 0.4rem;
|
||
margin-top: 0.4rem;
|
||
}
|
||
.mcp-code-row .mcp-code { margin-top: 0; }
|
||
.mcp-code-row .btn-sm { white-space: nowrap; }
|
||
.mcp-hint {
|
||
margin-top: 0.5rem;
|
||
font-size: 0.8rem;
|
||
opacity: 0.7;
|
||
line-height: 1.5;
|
||
}
|
||
.mcp-other p { font-size: 0.9rem; line-height: 1.6; }
|
||
.mcp-plain-list {
|
||
list-style: disc;
|
||
padding-left: 1.25rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
font-size: 0.9rem;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
/* Voice tab */
|
||
.voice-status-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
flex-wrap: wrap;
|
||
margin-top: 0.5rem;
|
||
}
|
||
.status-badge {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
padding: 0.15rem 0.55rem;
|
||
border-radius: 999px;
|
||
}
|
||
.status-on {
|
||
background: color-mix(in srgb, var(--color-success, #22c55e) 15%, transparent);
|
||
color: var(--color-success, #22c55e);
|
||
border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 40%, transparent);
|
||
}
|
||
.status-off {
|
||
background: color-mix(in srgb, var(--color-text-muted) 10%, transparent);
|
||
color: var(--color-text-muted);
|
||
border: 1px solid var(--color-border);
|
||
}
|
||
.radio-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.65rem;
|
||
margin-top: 0.35rem;
|
||
}
|
||
.radio-option {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 0.55rem;
|
||
cursor: pointer;
|
||
}
|
||
.radio-option input[type="radio"] { margin-top: 0.2rem; flex-shrink: 0; }
|
||
.radio-option span { display: flex; flex-direction: column; gap: 0.15rem; }
|
||
.radio-option strong { font-size: 0.875rem; }
|
||
.range-input {
|
||
width: 100%;
|
||
max-width: 360px;
|
||
accent-color: var(--color-primary);
|
||
margin: 0.35rem 0 0.2rem;
|
||
}
|
||
.range-labels {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
max-width: 360px;
|
||
font-size: 0.75rem;
|
||
color: var(--color-text-muted);
|
||
}
|
||
.form-actions {
|
||
margin-top: 1rem;
|
||
}
|
||
|
||
.voice-admin-spinner {
|
||
display: inline-flex;
|
||
gap: 3px;
|
||
align-items: center;
|
||
margin-left: 0.4rem;
|
||
vertical-align: middle;
|
||
}
|
||
.voice-admin-spinner span {
|
||
display: inline-block;
|
||
width: 4px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: var(--color-text-muted);
|
||
animation: va-dot-bounce 1.2s ease-in-out infinite;
|
||
}
|
||
.voice-admin-spinner span:nth-child(2) { animation-delay: 0.2s; }
|
||
.voice-admin-spinner span:nth-child(3) { animation-delay: 0.4s; }
|
||
|
||
/* ── Voice blend ────────────────────────────────────────────────────────── */
|
||
.blend-slots {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
}
|
||
.blend-slot {
|
||
background: color-mix(in srgb, var(--color-surface) 60%, transparent);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: 0.75rem 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
.blend-voice-select {
|
||
width: 100%;
|
||
}
|
||
.blend-weight-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
}
|
||
.blend-weight-slider {
|
||
flex: 1;
|
||
}
|
||
.blend-weight-label {
|
||
font-size: 0.85rem;
|
||
font-variant-numeric: tabular-nums;
|
||
min-width: 2.5rem;
|
||
text-align: right;
|
||
color: var(--color-text-secondary);
|
||
}
|
||
.btn-remove-slot {
|
||
background: none;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-text-muted);
|
||
cursor: pointer;
|
||
font-size: 0.75rem;
|
||
padding: 0.2rem 0.45rem;
|
||
line-height: 1;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.btn-remove-slot:hover {
|
||
color: var(--color-danger, #e05555);
|
||
border-color: var(--color-danger, #e05555);
|
||
}
|
||
.blend-actions {
|
||
margin-top: 0.25rem;
|
||
}
|
||
.toggle-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
cursor: pointer;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
/* ── Profile tab ─────────────────────────────────────────────────────────── */
|
||
.day-picker {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
flex-wrap: wrap;
|
||
margin-top: 0.35rem;
|
||
}
|
||
.day-btn {
|
||
padding: 0.3rem 0.65rem;
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 6px;
|
||
background: var(--color-bg-card);
|
||
color: var(--color-text-muted);
|
||
font-size: 0.82rem;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
font-family: inherit;
|
||
}
|
||
.day-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||
.day-btn.active {
|
||
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
|
||
border-color: var(--color-primary);
|
||
color: var(--color-primary);
|
||
font-weight: 600;
|
||
}
|
||
.learned-summary {
|
||
background: var(--color-bg-secondary);
|
||
border: 1px solid var(--color-border);
|
||
border-left: 3px solid var(--color-primary);
|
||
border-radius: 8px;
|
||
padding: 0.85rem 1rem;
|
||
font-size: 0.9rem;
|
||
line-height: 1.55;
|
||
color: var(--color-text);
|
||
white-space: pre-wrap;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.learned-empty {
|
||
font-size: 0.85rem;
|
||
color: var(--color-text-muted);
|
||
font-style: italic;
|
||
margin-bottom: 0.75rem;
|
||
}
|
||
.btn-danger-outline {
|
||
padding: 0.45rem 1rem;
|
||
background: none;
|
||
border: 1px solid var(--color-danger, #e74c3c);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--color-danger, #e74c3c);
|
||
font-size: 0.9rem;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: background 0.15s, color 0.15s;
|
||
}
|
||
.btn-danger-outline:hover:not(:disabled) {
|
||
background: var(--color-danger, #e74c3c);
|
||
color: #fff;
|
||
}
|
||
.btn-danger-outline:disabled { opacity: 0.5; cursor: default; }
|
||
@keyframes va-dot-bounce {
|
||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
|
||
40% { transform: scale(1); opacity: 1; }
|
||
}
|
||
</style>
|