refactor: DRY pass on frontend — shared palette, composable, and tab loader
- Extract milestoneColor to utils/palette.ts; remove duplicate in HomeView + ProjectListView - Create useBackgroundRefresh composable; wire into HomeView + BriefingView (removes manual setInterval/clearInterval boilerplate) - Extract _loadTabContent() in SettingsView so watch and onMounted share one tab→loader mapping - Move raw fetch() api-key calls to typed helpers in api/client.ts (listApiKeys, createApiKey, revokeApiKey) - Drop unused onUnmounted import from BriefingView Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -580,3 +580,21 @@ export async function updateEvent(id: number, payload: EventUpdatePayload): Prom
|
|||||||
export async function deleteEvent(id: number): Promise<void> {
|
export async function deleteEvent(id: number): Promise<void> {
|
||||||
return apiDelete(`/api/events/${id}`);
|
return apiDelete(`/api/events/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── API Keys ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ApiKeyEntry {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
scope: string
|
||||||
|
key_prefix: string
|
||||||
|
last_used_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listApiKeys = () =>
|
||||||
|
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
|
||||||
|
|
||||||
|
export const createApiKey = (name: string, scope: 'read' | 'write') =>
|
||||||
|
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
|
||||||
|
|
||||||
|
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `refreshFn` on a recurring interval while the page is visible.
|
||||||
|
* Safe to use in any component — timer is cleared on unmount.
|
||||||
|
*
|
||||||
|
* @param refreshFn Called each tick. Should be silent (no loading state changes).
|
||||||
|
* @param intervalMs Polling interval in milliseconds.
|
||||||
|
* @param canRun Optional guard — refresh is skipped when this returns false.
|
||||||
|
*/
|
||||||
|
export function useBackgroundRefresh(
|
||||||
|
refreshFn: () => void,
|
||||||
|
intervalMs: number,
|
||||||
|
canRun?: () => boolean,
|
||||||
|
): void {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (document.hidden) return
|
||||||
|
if (canRun && !canRun()) return
|
||||||
|
refreshFn()
|
||||||
|
}, intervalMs)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer !== null) clearInterval(timer)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** Cyclic color palette for milestone progress bars. */
|
||||||
|
export const MILESTONE_PALETTE = [
|
||||||
|
'var(--color-primary)',
|
||||||
|
'var(--color-success, #22c55e)',
|
||||||
|
'#c98a00',
|
||||||
|
'#8b5cf6',
|
||||||
|
'var(--color-danger, #ef4444)',
|
||||||
|
'#06b6d4',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function milestoneColor(index: number): string {
|
||||||
|
return MILESTONE_PALETTE[index % MILESTONE_PALETTE.length]
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
|
||||||
import { useChatStore } from '@/stores/chat'
|
import { useChatStore } from '@/stores/chat'
|
||||||
import ChatMessage from '@/components/ChatMessage.vue'
|
import ChatMessage from '@/components/ChatMessage.vue'
|
||||||
import WeatherCard from '@/components/WeatherCard.vue'
|
import WeatherCard from '@/components/WeatherCard.vue'
|
||||||
@@ -190,10 +191,7 @@ function toMsg(m: BriefingMessage): Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||||
let _refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
async function _backgroundRefreshMessages() {
|
async function _backgroundRefreshMessages() {
|
||||||
if (document.hidden || chatStore.streaming || !isToday.value || !todayConvId.value) return
|
|
||||||
try {
|
try {
|
||||||
const today = await getBriefingToday()
|
const today = await getBriefingToday()
|
||||||
if (!today) return
|
if (!today) return
|
||||||
@@ -206,14 +204,15 @@ async function _backgroundRefreshMessages() {
|
|||||||
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
} catch { /* silent — don't disturb the UI on network hiccup */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useBackgroundRefresh(
|
||||||
|
_backgroundRefreshMessages,
|
||||||
|
60_000,
|
||||||
|
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await checkSetup()
|
await checkSetup()
|
||||||
if (!showWizard.value) await loadAll()
|
if (!showWizard.value) await loadAll()
|
||||||
_refreshTimer = setInterval(_backgroundRefreshMessages, 60_000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (_refreshTimer !== null) clearInterval(_refreshTimer)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
import { apiGet, listEvents } from "@/api/client";
|
import { apiGet, listEvents } from "@/api/client";
|
||||||
|
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
|
||||||
|
import { milestoneColor } from "@/utils/palette";
|
||||||
import type { Note } from "@/types/note";
|
import type { Note } from "@/types/note";
|
||||||
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
|
||||||
import type { ToolCallRecord, Message } from "@/types/chat";
|
import type { ToolCallRecord, Message } from "@/types/chat";
|
||||||
@@ -69,24 +71,8 @@ const upcomingEvents = ref<EventEntry[]>([]);
|
|||||||
const eventSlideOverOpen = ref(false);
|
const eventSlideOverOpen = ref(false);
|
||||||
const editingEvent = ref<EventEntry | null>(null);
|
const editingEvent = ref<EventEntry | null>(null);
|
||||||
|
|
||||||
// ─── Milestone color palette ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function milestoneColor(index: number): string {
|
|
||||||
const palette = [
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-success, #22c55e)",
|
|
||||||
"#c98a00",
|
|
||||||
"var(--color-danger, #e74c3c)",
|
|
||||||
"#8b5cf6",
|
|
||||||
];
|
|
||||||
return palette[index % palette.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
|
||||||
// Runs on a timer while the page is visible. Never touches `loading` so
|
// Never touches `loading` so existing content stays on screen while fetching.
|
||||||
// existing content stays on screen while the fetch is in flight.
|
|
||||||
|
|
||||||
let _refreshTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function _dateRange() {
|
function _dateRange() {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
@@ -173,9 +159,10 @@ onMounted(async () => {
|
|||||||
chatInputRef.value?.focus();
|
chatInputRef.value?.focus();
|
||||||
loadProjects();
|
loadProjects();
|
||||||
|
|
||||||
_refreshTimer = setInterval(_backgroundRefresh, 90_000)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
if (!heroProject.value) return;
|
if (!heroProject.value) return;
|
||||||
const hid = heroProject.value.id;
|
const hid = heroProject.value.id;
|
||||||
@@ -239,7 +226,6 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
|
||||||
if (_refreshTimer !== null) clearInterval(_refreshTimer)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const chatStore = useChatStore();
|
const chatStore = useChatStore();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { apiGet, apiPost } from "@/api/client";
|
import { apiGet, apiPost } from "@/api/client";
|
||||||
import { useToastStore } from "@/stores/toast";
|
import { useToastStore } from "@/stores/toast";
|
||||||
|
import { milestoneColor } from "@/utils/palette";
|
||||||
|
|
||||||
interface MilestoneSummary {
|
interface MilestoneSummary {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -73,17 +74,6 @@ async function loadProjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function milestoneColor(index: number): string {
|
|
||||||
const palette = [
|
|
||||||
"var(--color-primary)",
|
|
||||||
"var(--color-success)",
|
|
||||||
"#c98a00",
|
|
||||||
"#8b5cf6",
|
|
||||||
"#ef4444",
|
|
||||||
"#06b6d4",
|
|
||||||
];
|
|
||||||
return palette[index % palette.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadProjects);
|
onMounted(loadProjects);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ref, watch, onMounted } from "vue";
|
|||||||
import { useSettingsStore } from "@/stores/settings";
|
import { useSettingsStore } from "@/stores/settings";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { useToastStore } from "@/stores/toast";
|
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, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
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, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed } from "@/api/client";
|
||||||
import { usePushStore } from "@/stores/push";
|
import { usePushStore } from "@/stores/push";
|
||||||
import type { User } from "@/types/auth";
|
import type { User } from "@/types/auth";
|
||||||
import PaginationBar from "@/components/PaginationBar.vue";
|
import PaginationBar from "@/components/PaginationBar.vue";
|
||||||
@@ -43,17 +43,24 @@ const restoreFileInput = ref<HTMLInputElement | null>(null);
|
|||||||
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
|
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
|
||||||
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
const _stored = localStorage.getItem("settings_tab") ?? "general";
|
||||||
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "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 === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
||||||
|
}
|
||||||
|
|
||||||
watch(activeTab, (v) => {
|
watch(activeTab, (v) => {
|
||||||
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
localStorage.setItem("settings_tab", v === "admin" ? "config" : v);
|
||||||
if (v === "users" && authStore.isAdmin) loadUsersPanel();
|
_loadTabContent(v);
|
||||||
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
|
|
||||||
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
|
|
||||||
if (v === "briefing") loadBriefingTab();
|
|
||||||
if (v === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Keys
|
// API Keys
|
||||||
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
|
const apiKeys = ref<ApiKeyEntry[]>([]);
|
||||||
const newKeyName = ref('');
|
const newKeyName = ref('');
|
||||||
const newKeyScope = ref<'read' | 'write'>('write');
|
const newKeyScope = ref<'read' | 'write'>('write');
|
||||||
const newKeyValue = ref('');
|
const newKeyValue = ref('');
|
||||||
@@ -89,36 +96,24 @@ async function loadMcpInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchApiKeys() {
|
async function fetchApiKeys() {
|
||||||
const res = await fetch('/api/api-keys', { credentials: 'include' });
|
apiKeys.value = await listApiKeys();
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
apiKeys.value = data.api_keys;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createApiKey() {
|
async function createApiKey() {
|
||||||
if (!newKeyName.value) return;
|
if (!newKeyName.value) return;
|
||||||
creatingApiKey.value = true;
|
creatingApiKey.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/api-keys', {
|
const data = await apiCreateApiKey(newKeyName.value, newKeyScope.value);
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
newKeyValue.value = data.key;
|
newKeyValue.value = data.key;
|
||||||
newKeyName.value = '';
|
newKeyName.value = '';
|
||||||
await fetchApiKeys();
|
await fetchApiKeys();
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
creatingApiKey.value = false;
|
creatingApiKey.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function revokeApiKey(id: number) {
|
async function revokeApiKey(id: number) {
|
||||||
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
|
await apiRevokeApiKey(id);
|
||||||
revokeConfirmId.value = null;
|
revokeConfirmId.value = null;
|
||||||
await fetchApiKeys();
|
await fetchApiKeys();
|
||||||
}
|
}
|
||||||
@@ -521,12 +516,8 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// base URL not configured yet
|
// base URL not configured yet
|
||||||
}
|
}
|
||||||
if (activeTab.value === "groups") loadGroupsPanel();
|
|
||||||
if (activeTab.value === "users") loadUsersPanel();
|
|
||||||
if (activeTab.value === "logs") loadLogsPanel();
|
|
||||||
}
|
}
|
||||||
if (activeTab.value === "briefing") loadBriefingTab();
|
_loadTabContent(activeTab.value);
|
||||||
if (activeTab.value === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
async function changeEmail() {
|
async function changeEmail() {
|
||||||
|
|||||||
Reference in New Issue
Block a user