feat: replace settings with a full page

This commit is contained in:
Zoe
2026-10-08 08:06:06 -05:00
parent 2b9f8d64d7
commit ff33e9c573
21 changed files with 503 additions and 245 deletions
-138
View File
@@ -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>
+2 -5
View File
@@ -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>
+8 -4
View File
@@ -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>
+8 -10
View File
@@ -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>
+16
View File
@@ -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>
+63
View File
@@ -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>
+17 -7
View File
@@ -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>
+29
View File
@@ -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>
+24
View File
@@ -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
View File
@@ -1,7 +1,6 @@
import { Err, Ok, type Result } from "~~/types/result";
export enum DialogType {
Settings = 'settings',
Textbox = 'textbox',
QuickSwitcher = 'quick-switcher',
Confirm = 'confirm',
+42
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div class="py-4 text-sm text-[var(--text-secondary)]">
Nothing to configure here yet.
</div>
</template>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
definePageMeta({
redirect: '/settings/profile',
});
</script>
<template>
<div></div>
</template>
+240
View File
@@ -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>
@@ -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,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">
+29
View File
@@ -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() : '?';
};