feat: replace settings with a full page
This commit is contained in:
@@ -1,138 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import GeneralSettings from '~/components/Settings/GeneralSettings.vue';
|
||||
import ProviderSettings from '~/components/Settings/ProviderSettings.vue';
|
||||
import ProviderSidebar from '~/components/Settings/ProviderSidebar.vue';
|
||||
import SystemAssistants from '~/components/Settings/SystemAssistants.vue';
|
||||
import AppearanceSettings from '~/components/Settings/AppearanceSettings.vue';
|
||||
import AIServiceProvider from '~/components/Settings/AIServiceProvider.vue';
|
||||
|
||||
const { providers } = await useModels();
|
||||
|
||||
const PAGES_CONFIG = {
|
||||
general: {
|
||||
label: 'General',
|
||||
icon: 'i-mynaui-cog-four',
|
||||
component: GeneralSettings
|
||||
},
|
||||
appearance: {
|
||||
label: 'Appearance',
|
||||
icon: 'i-tabler-palette',
|
||||
component: AppearanceSettings
|
||||
},
|
||||
providers: {
|
||||
label: 'AI Providers',
|
||||
icon: 'i-mynaui-api',
|
||||
component: ProviderSettings,
|
||||
sidebar: ProviderSidebar
|
||||
},
|
||||
systemAssistants: {
|
||||
label: 'System Assistants',
|
||||
icon: 'i-mynaui-sparkles',
|
||||
component: SystemAssistants
|
||||
},
|
||||
} as const;
|
||||
|
||||
const props = defineProps<{
|
||||
page: keyof typeof PAGES_CONFIG;
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
const { close, setOptions } = useDialog();
|
||||
|
||||
const mobileNavOpen = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', () => {
|
||||
if (window.innerWidth >= 768) {
|
||||
mobileNavOpen.value = false;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const handleNavigate = (page: string, params?: string) => {
|
||||
setOptions({ page: page as keyof typeof PAGES_CONFIG, params });
|
||||
mobileNavOpen.value = false;
|
||||
};
|
||||
|
||||
const runtimePage = computed(() => {
|
||||
// 1. Get the base config (e.g., 'providers' or 'general')
|
||||
const config = PAGES_CONFIG[props.page as keyof typeof PAGES_CONFIG] || PAGES_CONFIG.general;
|
||||
|
||||
// 2. Determine the actual component to show
|
||||
let component = config.component;
|
||||
let label = config.label as string;
|
||||
|
||||
if (props.page === 'providers' && props.params) {
|
||||
component = AIServiceProvider;
|
||||
const providerId = props.params;
|
||||
const provider = providers.value!.find(p => p.id === providerId);
|
||||
label = provider ? provider.name : 'Unknown Provider';
|
||||
}
|
||||
|
||||
return {
|
||||
...config,
|
||||
label,
|
||||
component,
|
||||
params: props.params,
|
||||
} as {
|
||||
label: string;
|
||||
icon: string;
|
||||
component: Component;
|
||||
sidebar?: Component;
|
||||
params: string;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-2 flex flex-col md:flex-row h-screen md:h-[70vh] md:h-[70dvh] w-screen md:w-[85vw]">
|
||||
<nav :class="[
|
||||
'w-full md:w-64 flex-col gap-1 mr-2 overflow-y-auto text-xl md:text-base',
|
||||
// Mobile: hidden by default, shown as fixed overlay
|
||||
'hidden md:flex',
|
||||
mobileNavOpen ? '!flex fixed inset-y-0 left-0 z-15 bg-[var(--bg-base)] p-2 shadow-xl' : ''
|
||||
]">
|
||||
<!-- Mobile close button -->
|
||||
<button
|
||||
class="md:hidden flex items-center justify-center h-9 w-full rounded-lg @hover:bg-[var(--color-hover)] text-[var(--text-secondary)]"
|
||||
@click="mobileNavOpen = false">
|
||||
<span class="i-mynaui-x-solid mr-2"></span>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<!-- If the page has a custom sidebar (for nested lists), show it; otherwise show default nav -->
|
||||
<component v-if="runtimePage?.sidebar" :is="runtimePage.sidebar" @navigate="handleNavigate"
|
||||
:params="props.params" />
|
||||
|
||||
<button v-else v-for="(config, id) in PAGES_CONFIG" :key="id" @click="handleNavigate(id)"
|
||||
:class="[page === id ? 'bg-[var(--color-hover)]' : '@hover:bg-[var(--color-hover)]', 'flex justify-between items-center shrink-0 px-1 rounded-lg transition-colors cursor-pointer h-9']">
|
||||
<div class="flex items-center gap-2 max-w-full flex-1">
|
||||
<span :class="['w-5 h-5 text-5', config.icon]"></span>
|
||||
{{ config.label }}
|
||||
</div>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- DYNAMIC CONTENT -->
|
||||
<main
|
||||
class="flex-1 flex flex-col overflow-y-hidden max-h-full h-full px-3 bg-[var(--bg-surface)] border rounded-none md:rounded-lg border-[var(--color-border)]">
|
||||
<header class="flex items-center justify-between pl-2 pb-2 pt-2 ">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
class="md:hidden flex items-center justify-center h-8 w-8 rounded-lg @hover:bg-[var(--color-hover)]"
|
||||
@click="mobileNavOpen = true">
|
||||
<span class="i-mynaui-menu text-5"></span>
|
||||
</button>
|
||||
<h2 class="text-lg font-semibold m-0 case-capital">{{ runtimePage.label }}</h2>
|
||||
</div>
|
||||
<button
|
||||
class="@hover:bg-[var(--color-hover)] p-1.5 rounded-md transition-colors duration-200 ease-[cubic-bezier(0,0.55,0.45,1)]"
|
||||
@click="close">
|
||||
<span class="i-mynaui-x-solid"></span>
|
||||
</button>
|
||||
</header>
|
||||
<component @navigate="handleNavigate" :is="runtimePage.component" :params="props.params" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import Textbox from './Textbox.vue';
|
||||
import Settings from './Settings.vue';
|
||||
import QuickSwitcher from './QuickSwitcher.vue';
|
||||
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
@@ -11,7 +10,7 @@ const { addShortcut } = useKeyboardShortcuts();
|
||||
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
openDialog(DialogType.Settings);
|
||||
navigateTo('/settings');
|
||||
});
|
||||
|
||||
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
|
||||
@@ -57,9 +56,7 @@ onUnmounted(() => {
|
||||
<div
|
||||
class="absolute w-full h-full md:w-auto md:h-auto max-w-6xl bg-[var(--bg-base)] rounded-none md:rounded-2xl shadow-2xl border border-[var(--color-border)] flex">
|
||||
<KeepAlive>
|
||||
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" />
|
||||
|
||||
<Textbox v-else-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||
<Textbox v-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||
|
||||
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
||||
</KeepAlive>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/vue';
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
import type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
const { allModels } = await useModels();
|
||||
const { addShortcut } = useKeyboardShortcuts();
|
||||
|
||||
@@ -17,6 +15,12 @@ const props = defineProps<{
|
||||
|
||||
const selectedModel = defineModel<ModelWithProvider | null>();
|
||||
|
||||
const openProviderSettings = async (providerId?: string) => {
|
||||
closeDropdown();
|
||||
|
||||
await navigateTo(providerId ? `/settings/providers/${providerId}` : '/settings/providers');
|
||||
};
|
||||
|
||||
const isOpen = ref(false);
|
||||
const dropdownButton = ref<HTMLElement | null>(null);
|
||||
const dropdownContent = ref<HTMLElement | null>(null);
|
||||
@@ -269,7 +273,7 @@ onUnmounted(() => {
|
||||
class="px-4 py-1.5 text-[13px] h-10.5 items-end font-medium text-[var(--text-secondary)] case-capital tracking-wider flex justify-between">
|
||||
{{ item.provider.name }}
|
||||
<button
|
||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers', params: item.provider.id })"
|
||||
@click="openProviderSettings(item.provider.id)"
|
||||
class="flex h-5 w-5 items-center justify-center @hover:bg-[var(--color-hover)] rounded transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-cog-four text-4"></span>
|
||||
</button>
|
||||
@@ -295,7 +299,7 @@ onUnmounted(() => {
|
||||
<div class="p-1 border-t border-[var(--color-border)]">
|
||||
<button
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-[var(--text-secondary)] @hover:text-[var(--text-primary)] @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-150"
|
||||
@click="closeDropdown(); openDialog(DialogType.Settings, undefined, { page: 'providers' });">
|
||||
@click="openProviderSettings()">
|
||||
<span class="i-mynaui-cog-four text-4.5"></span>
|
||||
<span>Manage Providers</span>
|
||||
<span class="i-mynaui-arrow-right text-4 ml-auto"></span>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
hi
|
||||
</template>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
const { providers } = await useModels();
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-1 overflow-auto">
|
||||
<button @click="$emit('navigate', 'general')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-chevron-left md:text-4"></span> Back to General
|
||||
</button>
|
||||
|
||||
<button @click="$emit('navigate', 'providers')"
|
||||
class="flex items-center gap-2 p-2 rounded-lg @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-envelope-open md:text-4"></span> All
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Enabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => p.enabled)" :key="p.id" @click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
|
||||
<div class="flex items-center gap-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="h-6 w-6 md:w-4 md:h-4 text-[var(--text-primary)]" />
|
||||
<span>{{ p.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="px-2 py-4 font-bold md:text-xs text-sm uppercase opacity-50">Disabled Providers</div>
|
||||
|
||||
<button v-for="p in providers?.filter(p => !p.enabled)" :key="p.id"
|
||||
@click="$emit('navigate', 'providers', p.id)"
|
||||
:class="['case-capital flex items-center justify-between p-2 @hover:bg-[var(--color-hover)] transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg', p.id === params ? 'bg-[var(--color-hover)]' : '']">
|
||||
<div class="flex items-center gap-2">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="h-6 w-6 md:w-4 md:h-4 h-full text-[var(--text-primary)]" />
|
||||
<span>{{ p.name }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { user, signOut } = useAuth();
|
||||
|
||||
// to prevent the user details from going blank for a
|
||||
@@ -16,10 +14,14 @@ watch(user, () => {
|
||||
cachedUser.value = user.value;
|
||||
})
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const { isHovered } = useSidenavContext();
|
||||
|
||||
const openSettings = async () => {
|
||||
dropdownOpen.value = false;
|
||||
|
||||
await navigateTo('/settings');
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
|
||||
@@ -31,11 +33,7 @@ const handleLogout = async () => {
|
||||
<header class="flex items-center justify-between overflow-hidden">
|
||||
<button @click="dropdownOpen = !dropdownOpen"
|
||||
class="flex gap-1 pr-1 items-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<div
|
||||
:class="['w-9 h-9 md:w-7 md:h-7 flex-shrink-0 rounded-lg overflow-hidden bg-[var(--bg-surface)] flex items-center justify-center', user?.image ? '' : 'border border-[var(--color-border)]']">
|
||||
<img v-if="cachedUser?.image" :src="cachedUser.image" class="w-full h-full object-cover" />
|
||||
<span v-else class="w-5.5 h-5.5 md:h-4 md:w-4 i-mynaui-user text-[var(--text-secondary)]"></span>
|
||||
</div>
|
||||
<UserAvatar :user="cachedUser" class="w-9 h-9 md:w-7 md:h-7 rounded-lg md:text-sm" />
|
||||
<span
|
||||
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
{{ cachedUser?.name }}
|
||||
@@ -55,7 +53,7 @@ const handleLogout = async () => {
|
||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||
<div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false"
|
||||
class="w-full top-full text-sm mt-1 absolute z-30 bg-[var(--bg-surface)] border border-[var(--color-border)] rounded-xl p-1.5 flex flex-col gap-2">
|
||||
<button @click="dropdownOpen = false; openDialog(DialogType.Settings)"
|
||||
<button @click="openSettings"
|
||||
class="text-left px-3 py-1.5 items-center gap-1 @hover:bg-[var(--color-hover)] rounded-lg transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span class="i-mynaui-cog-four text-4.5"></span>
|
||||
<span>Settings</span>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
const returnTo = useState('settings:returnTo', () => '/');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="flex items-center rounded-lg overflow-hidden flex-1 min-w-0">
|
||||
<NuxtLink :to="returnTo" aria-label="Back"
|
||||
class="flex items-center gap-1 p-1.5 pr-2.5 @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg decoration-none transition-inherit">
|
||||
<span class="i-mynaui-chevron-left text-5.5 md:text-4.5 text-[var(--text-secondary)]"></span>
|
||||
<span
|
||||
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||
Settings
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const route = useRoute();
|
||||
const { providers } = await useModels();
|
||||
|
||||
const groups = [
|
||||
{
|
||||
label: 'Account',
|
||||
items: [
|
||||
{ to: '/settings/profile', name: 'Profile', icon: 'i-mynaui-user' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'App',
|
||||
items: [
|
||||
{ to: '/settings/general', name: 'General', icon: 'i-mynaui-cog-four' },
|
||||
{ to: '/settings/appearance', name: 'Appearance', icon: 'i-tabler-palette' },
|
||||
{ to: '/settings/providers', name: 'AI Providers', icon: 'i-mynaui-api' },
|
||||
{ to: '/settings/assistants', name: 'System Assistants', icon: 'i-mynaui-sparkles' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const inProviders = computed(() => route.path.startsWith('/settings/providers'));
|
||||
|
||||
// enabled providers first, same order the provider list uses
|
||||
const sortedProviders = computed(() => [
|
||||
...providers.value.filter(p => p.enabled),
|
||||
...providers.value.filter(p => !p.enabled),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav aria-label="Settings" class="flex flex-col gap-1 overflow-auto">
|
||||
<template v-for="group in groups" :key="group.label">
|
||||
<div class="px-2 pt-3 pb-0.5 text-sm md:text-xs font-medium uppercase tracking-wider">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
|
||||
<template v-for="item in group.items" :key="item.to">
|
||||
<SidenavItem :to="item.to" :name="item.name" :icon="item.icon" :active="route.path === item.to" />
|
||||
|
||||
<!-- the providers are listed under their section while it is open -->
|
||||
<div v-if="item.to === '/settings/providers' && inProviders" class="flex flex-col gap-1 pl-4">
|
||||
<NuxtLink v-for="p in sortedProviders" :key="p.id" :to="`/settings/providers/${p.id}`" :class="[
|
||||
'case-capital decoration-none flex items-center gap-2 shrink-0 px-2 h-9 rounded-lg transition-colors',
|
||||
route.path === `/settings/providers/${p.id}`
|
||||
? 'text-[var(--text-primary)] bg-[var(--color-hover)]'
|
||||
: 'text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)]',
|
||||
p.enabled ? '' : 'opacity-50',
|
||||
]">
|
||||
<component v-if="providerIcons[p.type]" :color="true" :is="providerIcons[p.type]"
|
||||
class="h-6 w-6 md:w-4 md:h-4 flex-shrink-0 text-[var(--text-primary)]" />
|
||||
<span class="md:text-sm font-medium overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
{{ p.name }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,6 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import { DialogType } from '~/composables/useDialog';
|
||||
|
||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
||||
const route = useRoute();
|
||||
|
||||
@@ -37,8 +35,6 @@ const onFocusOut = (e: FocusEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const onResizeStart = (event: MouseEvent) => {
|
||||
isResizing.value = true;
|
||||
startX.value = event.clientX;
|
||||
@@ -97,8 +93,18 @@ const navKind = computed(() => {
|
||||
if (route.path === '/') return 'home';
|
||||
if (route.path.startsWith('/agent/')) return 'agent';
|
||||
if (route.path === '/resources') return 'resources';
|
||||
if (route.path.startsWith('/settings')) return 'settings';
|
||||
return null;
|
||||
});
|
||||
|
||||
// remember where the user was so the settings back button can return there
|
||||
const settingsReturnTo = useState('settings:returnTo', () => '/');
|
||||
|
||||
watch(() => route.fullPath, (path) => {
|
||||
if (!path.startsWith('/settings')) {
|
||||
settingsReturnTo.value = path;
|
||||
}
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -126,6 +132,7 @@ const navKind = computed(() => {
|
||||
<SidenavHeader v-if="navKind === 'home'" />
|
||||
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
|
||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
||||
<SidenavHeaderSettings v-else-if="navKind === 'settings'" />
|
||||
|
||||
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||
<div :class="[
|
||||
@@ -158,15 +165,18 @@ const navKind = computed(() => {
|
||||
<SidenavNavHome v-if="navKind === 'home'" />
|
||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||
<SidenavNavResources v-else-if="navKind === 'resources'" />
|
||||
<SidenavNavSettings v-else-if="navKind === 'settings'" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
|
||||
<div class="flex">
|
||||
<Tooltip :hotkey="['ctrl', ',']">
|
||||
<button @click="openDialog(DialogType.Settings)"
|
||||
class="flex items-center justify-center h-9 w-9 md:h-7 md:w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors text-[var(--text-secondary)] active:text-[var(--text-primary)]">
|
||||
<NuxtLink to="/settings" aria-label="Settings" :class="[
|
||||
'flex items-center justify-center h-9 w-9 md:h-7 md:w-7 cursor-pointer @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] rounded-lg transition-colors active:text-[var(--text-primary)]',
|
||||
navKind === 'settings' ? 'text-[var(--text-primary)] bg-[var(--color-hover)]' : 'text-[var(--text-secondary)]',
|
||||
]">
|
||||
<span class="i-mynaui-cog-four text-6 md:text-5"></span>
|
||||
</button>
|
||||
</NuxtLink>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { avatarColor, avatarInitial } from '~/utils/avatar';
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
image?: string | null;
|
||||
} | null | undefined;
|
||||
}>();
|
||||
|
||||
// fall back to the letter placeholder if the image fails to load
|
||||
const imageFailed = ref(false);
|
||||
|
||||
watch(() => props.user?.image, () => {
|
||||
imageFailed.value = false;
|
||||
});
|
||||
|
||||
const showImage = computed(() => !!props.user?.image && !imageFailed.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-shrink-0 overflow-hidden flex items-center justify-center font-semibold text-white select-none"
|
||||
:style="showImage ? {} : { backgroundColor: avatarColor(user?.id ?? '') }">
|
||||
<img v-if="showImage" :src="user!.image!" alt="" class="w-full h-full object-cover"
|
||||
@error="imageFailed = true" />
|
||||
<span v-else aria-hidden="true">{{ avatarInitial(user?.name) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ export enum AuthError {
|
||||
SignInFailed = 'SIGN_IN_FAILED',
|
||||
SignUpFailed = 'SIGN_UP_FAILED',
|
||||
SignOutFailed = 'SIGN_OUT_FAILED',
|
||||
UpdateFailed = 'UPDATE_FAILED',
|
||||
NetworkError = 'NETWORK_ERROR',
|
||||
}
|
||||
|
||||
@@ -166,6 +167,28 @@ export const useAuth = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateProfile = async (
|
||||
updates: { name?: string; image?: string | null }
|
||||
): Promise<Result<void, AuthError>> => {
|
||||
try {
|
||||
const { error } = await authClient.updateUser(updates);
|
||||
|
||||
if (error) {
|
||||
console.error('Profile update failed:', error);
|
||||
return Err(AuthError.UpdateFailed);
|
||||
}
|
||||
|
||||
if (user.value) {
|
||||
user.value = { ...user.value, ...updates };
|
||||
}
|
||||
|
||||
return Ok(undefined);
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
return Err(AuthError.NetworkError);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
client: authClient,
|
||||
session,
|
||||
@@ -174,6 +197,7 @@ export const useAuth = () => {
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
updateProfile,
|
||||
fetchSession,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Err, Ok, type Result } from "~~/types/result";
|
||||
|
||||
export enum DialogType {
|
||||
Settings = 'settings',
|
||||
Textbox = 'textbox',
|
||||
QuickSwitcher = 'quick-switcher',
|
||||
Confirm = 'confirm',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
const { open: sidebarOpen, openSidebar } = useSidebar();
|
||||
const { providers } = await useModels();
|
||||
const route = useRoute();
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
profile: 'Profile',
|
||||
general: 'General',
|
||||
appearance: 'Appearance',
|
||||
providers: 'AI Providers',
|
||||
assistants: 'System Assistants',
|
||||
};
|
||||
|
||||
const title = computed(() => {
|
||||
const [section, id] = route.path.split('/').slice(2);
|
||||
|
||||
if (section === 'providers' && id) {
|
||||
const provider = providers.value.find(p => p.id === id);
|
||||
return provider ? provider.name : 'Unknown Provider';
|
||||
}
|
||||
|
||||
return TITLES[section ?? ''] ?? 'Settings';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full w-full flex flex-col">
|
||||
<div class="h-14 flex items-center justify-between px-4 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<button v-if="!sidebarOpen" @click="openSidebar"
|
||||
class="flex p-2 rounded-lg text-[var(--text-primary)] bg-transparent @hover:bg-[var(--color-hover)] transition-colors">
|
||||
<span class="i-mynaui-panel-left-open text-5"></span>
|
||||
</button>
|
||||
<h1 class="text-lg font-semibold text-[var(--text-primary)] case-capital">{{ title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-h-0 flex flex-col px-4">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,8 +16,6 @@ const updateHinting = (e: Event) => {
|
||||
const hinting = parseInt((e.target as HTMLInputElement).value);
|
||||
updateSettings({ appearance: { hinting } });
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -33,8 +33,6 @@ const getModel = (id: string | null | undefined) => {
|
||||
|
||||
return allModels.value.find(m => m.id === id);
|
||||
}
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="py-4 text-sm text-[var(--text-secondary)]">
|
||||
Nothing to configure here yet.
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
redirect: '/settings/profile',
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
@@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
import { Err, Ok, type Result } from '~~/types/result';
|
||||
|
||||
const AVATAR_SIZE = 256;
|
||||
const MAX_SOURCE_SIZE = 10 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
|
||||
|
||||
const { user, updateProfile } = useAuth();
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const avatarBusy = ref(false);
|
||||
const avatarError = ref('');
|
||||
|
||||
const name = ref(user.value?.name ?? '');
|
||||
const nameSaving = ref(false);
|
||||
const nameError = ref('');
|
||||
|
||||
watch(() => user.value?.name, (value) => {
|
||||
if (!nameSaving.value) name.value = value ?? '';
|
||||
});
|
||||
|
||||
const trimmedName = computed(() => name.value.trim());
|
||||
const nameChanged = computed(() => trimmedName.value !== '' && trimmedName.value !== user.value?.name);
|
||||
|
||||
// Center-crops the image to a square and scales it down, so we only ever
|
||||
// store a small file no matter what the user picked.
|
||||
const cropToSquare = async (file: File): Promise<Result<Blob, string>> => {
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file);
|
||||
} catch {
|
||||
return Err('That file could not be read as an image.');
|
||||
}
|
||||
|
||||
const side = Math.min(bitmap.width, bitmap.height);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = AVATAR_SIZE;
|
||||
canvas.height = AVATAR_SIZE;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx === null) {
|
||||
bitmap.close();
|
||||
return Err('Your browser could not process the image.');
|
||||
}
|
||||
|
||||
ctx.imageSmoothingQuality = 'high';
|
||||
ctx.drawImage(
|
||||
bitmap,
|
||||
(bitmap.width - side) / 2, (bitmap.height - side) / 2, side, side,
|
||||
0, 0, AVATAR_SIZE, AVATAR_SIZE,
|
||||
);
|
||||
bitmap.close();
|
||||
|
||||
// browsers that can't encode webp hand back a png instead
|
||||
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/webp', 0.9));
|
||||
if (blob === null) {
|
||||
return Err('Your browser could not process the image.');
|
||||
}
|
||||
|
||||
return Ok(blob);
|
||||
};
|
||||
|
||||
const uploadAvatar = async (blob: Blob): Promise<Result<string, string>> => {
|
||||
const extension = blob.type === 'image/webp' ? 'webp' : 'png';
|
||||
|
||||
try {
|
||||
const { url, assetUrl } = await $fetch<{ url: string; assetUrl: string }>('/api/upload/presigned', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
file: {
|
||||
name: `avatar.${extension}`,
|
||||
mimeType: blob.type,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': blob.type },
|
||||
body: blob,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return Err('Upload failed. Check that file storage is configured for this instance.');
|
||||
}
|
||||
|
||||
return Ok(assetUrl);
|
||||
} catch (error) {
|
||||
console.error('Avatar upload failed:', error);
|
||||
return Err('Upload failed. Check that file storage is configured for this instance.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
// reset so picking the same file again still fires a change event
|
||||
input.value = '';
|
||||
|
||||
if (!file) return;
|
||||
|
||||
avatarError.value = '';
|
||||
|
||||
if (!ACCEPTED_TYPES.includes(file.type)) {
|
||||
avatarError.value = 'Pick a PNG, JPG or WebP image.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > MAX_SOURCE_SIZE) {
|
||||
avatarError.value = 'That image is over 10 MB. Pick a smaller one.';
|
||||
return;
|
||||
}
|
||||
|
||||
avatarBusy.value = true;
|
||||
|
||||
const cropped = await cropToSquare(file);
|
||||
if (cropped.ok === false) {
|
||||
avatarError.value = cropped.error;
|
||||
avatarBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const uploaded = await uploadAvatar(cropped.data);
|
||||
if (uploaded.ok === false) {
|
||||
avatarError.value = uploaded.error;
|
||||
avatarBusy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateProfile({ image: uploaded.data });
|
||||
if (updated.ok === false) {
|
||||
avatarError.value = 'The image uploaded, but your profile could not be updated. Try again.';
|
||||
}
|
||||
|
||||
avatarBusy.value = false;
|
||||
};
|
||||
|
||||
const removeAvatar = async () => {
|
||||
avatarError.value = '';
|
||||
avatarBusy.value = true;
|
||||
|
||||
const updated = await updateProfile({ image: null });
|
||||
if (updated.ok === false) {
|
||||
avatarError.value = 'Your profile picture could not be removed. Try again.';
|
||||
}
|
||||
|
||||
avatarBusy.value = false;
|
||||
};
|
||||
|
||||
const saveName = async () => {
|
||||
if (!nameChanged.value || nameSaving.value) return;
|
||||
|
||||
nameError.value = '';
|
||||
nameSaving.value = true;
|
||||
|
||||
const updated = await updateProfile({ name: trimmedName.value });
|
||||
if (updated.ok === false) {
|
||||
nameError.value = 'Your name could not be saved. Try again.';
|
||||
} else {
|
||||
name.value = trimmedName.value;
|
||||
}
|
||||
|
||||
nameSaving.value = false;
|
||||
};
|
||||
|
||||
const memberSince = computed(() => {
|
||||
if (!user.value?.createdAt) return '';
|
||||
|
||||
// fixed locale and time zone so the server and client render the same text
|
||||
return new Date(user.value.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col gap-5 py-4 overflow-auto [scrollbar-width:thin] [scrollbar-color:#888_transparent] [scrollbar-gutter:stable]">
|
||||
<div class="flex flex-col gap-5 w-full max-w-3xl">
|
||||
<section class="flex flex-wrap items-center gap-5 pb-5 border-b border-[var(--color-border)]">
|
||||
<UserAvatar :user="user" class="w-20 h-20 rounded-xl text-3xl" />
|
||||
|
||||
<div class="flex flex-col gap-2 flex-1 min-w-60">
|
||||
<h2 class="font-medium">Profile picture</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" :disabled="avatarBusy" @click="fileInput?.click()"
|
||||
class="flex items-center gap-2 h-8 px-3 text-sm rounded-md bg-[var(--bg-container)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
<span :class="avatarBusy ? 'i-svg-spinners-180-ring-with-bg' : 'i-mynaui-upload'"
|
||||
class="text-4"></span>
|
||||
{{ avatarBusy ? 'Working…' : 'Upload image' }}
|
||||
</button>
|
||||
<button v-if="user?.image" type="button" :disabled="avatarBusy" @click="removeAvatar"
|
||||
class="flex items-center h-8 px-3 text-sm rounded-md border border-[var(--color-border)] text-[var(--text-secondary)] @hover:bg-[var(--color-hover)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)]">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" class="hidden" :accept="ACCEPTED_TYPES.join(',')"
|
||||
aria-label="Upload a profile picture" @change="handleFileChange" />
|
||||
<p v-if="avatarError" role="alert" class="text-xs text-red-400">{{ avatarError }}</p>
|
||||
<p v-else class="text-xs text-[var(--text-secondary)]">
|
||||
PNG, JPG or WebP up to 10MB.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<form class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2"
|
||||
@submit.prevent="saveName">
|
||||
<label class="whitespace-nowrap" for="profile-name">Display name</label>
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-60 max-w-120">
|
||||
<div class="flex gap-2">
|
||||
<input id="profile-name" v-model="name" type="text" autocomplete="name" maxlength="64"
|
||||
class="flex-1 min-w-0 h-8.5 px-2.5 text-sm rounded-md bg-[var(--bg-container)] placeholder:text-[var(--text-tertiary)]" />
|
||||
<button type="submit" :disabled="!nameChanged || nameSaving"
|
||||
class="h-8.5 px-3 items-center text-sm font-semibold rounded-md bg-[var(--color-accent)] text-[var(--color-accent-text)] disabled:opacity-40 disabled:cursor-not-allowed transition-opacity duration-200">
|
||||
{{ nameSaving ? 'Saving…' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="nameError" role="alert" class="text-xs text-red-400">{{ nameError }}</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||
<span class="whitespace-nowrap">Email</span>
|
||||
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)] overflow-hidden text-ellipsis">
|
||||
{{ user?.email }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row flex-wrap items-center justify-between gap-x-16 gap-y-2">
|
||||
<span class="whitespace-nowrap">Member since</span>
|
||||
<span class="flex-1 min-w-60 max-w-120 text-sm text-[var(--text-secondary)]">
|
||||
{{ memberSince }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+8
-11
@@ -2,20 +2,19 @@
|
||||
import { sortByReleaseDate } from '~/utils/sort';
|
||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||
import { type Model } from '~/types/model';
|
||||
import ModelItem from './ModelItem.vue';
|
||||
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
|
||||
import ModelItem from '~/components/Settings/ModelItem.vue';
|
||||
import RowVirtualizerDynamic from '~/components/RowVirtualizerDynamic.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
const route = useRoute();
|
||||
const providerId = computed(() => route.params.id as string | undefined);
|
||||
|
||||
const { providers, updateProvider, createModel, updateModel } = await useModels();
|
||||
|
||||
const scrollContainerRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
const provider = computed(() => {
|
||||
if (props.params === undefined) return null;
|
||||
return providers.value!.find(p => p.id === props.params);
|
||||
if (providerId.value === undefined) return null;
|
||||
return providers.value!.find(p => p.id === providerId.value);
|
||||
});
|
||||
|
||||
watch(provider, async () => {
|
||||
@@ -29,8 +28,8 @@ const apiKey = ref('');
|
||||
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
|
||||
const modelSearch = ref('');
|
||||
|
||||
watch(() => props.params, () => {
|
||||
if (props.params === undefined) return;
|
||||
watch(() => providerId.value, () => {
|
||||
if (providerId.value === undefined) return;
|
||||
apiKey.value = provider.value?.config.apiKey ?? '';
|
||||
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
|
||||
modelSearch.value = '';
|
||||
@@ -373,8 +372,6 @@ const formatMoney = (value: string) => {
|
||||
const isFormValid = computed(() => {
|
||||
return formData.value.name.trim() && formData.value.externalId.trim();
|
||||
});
|
||||
|
||||
defineEmits(['navigate']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
+3
-7
@@ -3,10 +3,6 @@ import { providerIcons } from '~/utils/model-mapping';
|
||||
|
||||
const { providers, updateProvider } = await useModels();
|
||||
|
||||
const props = defineProps<{
|
||||
params?: string;
|
||||
}>();
|
||||
|
||||
if (providers.value === undefined) throw new Error('Providers not loaded');
|
||||
|
||||
const toggleProvider = async (id: string) => {
|
||||
@@ -18,7 +14,7 @@ const toggleProvider = async (id: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
defineEmits(['navigate']);
|
||||
const openProvider = (id: string) => navigateTo(`/settings/providers/${id}`);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -31,7 +27,7 @@ defineEmits(['navigate']);
|
||||
</h2>
|
||||
<div
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
||||
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
@@ -55,7 +51,7 @@ defineEmits(['navigate']);
|
||||
</h2>
|
||||
<div
|
||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
||||
<button @click="$emit('navigate', 'providers', p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
||||
<button @click="openProvider(p.id)" v-for="p in providers?.filter(p => !p.enabled)"
|
||||
:key="p.id"
|
||||
class="flex flex-col h-44 p-4 transition-colors duration-200 ease-[cubic-bezier(0.5,_1,_0.89,_1)] rounded-lg border border-[var(--color-border)] @hover:border-[var(--color-border-active)]">
|
||||
<div class="flex flex-col flex-grow">
|
||||
@@ -0,0 +1,29 @@
|
||||
const AVATAR_COLORS = [
|
||||
'#d12655',
|
||||
'#c42685',
|
||||
'#a334c2',
|
||||
'#853de0',
|
||||
'#6548db',
|
||||
'#485ddd',
|
||||
'#2670cf',
|
||||
'#087caa',
|
||||
'#008579',
|
||||
'#148348',
|
||||
'#7846c7',
|
||||
'#d13243',
|
||||
];
|
||||
|
||||
export const avatarColor = (seed: string): string => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
|
||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length]!;
|
||||
};
|
||||
|
||||
export const avatarInitial = (name: string | null | undefined): string => {
|
||||
const first = Array.from((name ?? '').trim())[0];
|
||||
|
||||
return first ? first.toUpperCase() : '?';
|
||||
};
|
||||
Reference in New Issue
Block a user