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">
|
<script setup lang="ts">
|
||||||
import Textbox from './Textbox.vue';
|
import Textbox from './Textbox.vue';
|
||||||
import Settings from './Settings.vue';
|
|
||||||
import QuickSwitcher from './QuickSwitcher.vue';
|
import QuickSwitcher from './QuickSwitcher.vue';
|
||||||
|
|
||||||
import { DialogType } from '~/composables/useDialog';
|
import { DialogType } from '~/composables/useDialog';
|
||||||
@@ -11,7 +10,7 @@ const { addShortcut } = useKeyboardShortcuts();
|
|||||||
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
|
const unbindSettingsShortcut = addShortcut(['ctrl', ','], (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
openDialog(DialogType.Settings);
|
navigateTo('/settings');
|
||||||
});
|
});
|
||||||
|
|
||||||
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
|
const unbindQuickSwitcherShortcut = addShortcut(['ctrl', 'k'], (event) => {
|
||||||
@@ -57,9 +56,7 @@ onUnmounted(() => {
|
|||||||
<div
|
<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">
|
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>
|
<KeepAlive>
|
||||||
<Settings v-if="page === DialogType.Settings" :page="data.page" :params="data.params" />
|
<Textbox v-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
|
||||||
|
|
||||||
<Textbox v-else-if="page === DialogType.Textbox" v-bind="data" @confirm="confirm" @cancel="close" />
|
|
||||||
|
|
||||||
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
<QuickSwitcher v-else-if="page === DialogType.QuickSwitcher" @close="close" />
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
import { useFloating, offset, flip, shift, autoUpdate, size, hide } from '@floating-ui/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 type { Model, ModelWithProvider, Provider, ProviderWithModels } from '~/composables/useModels';
|
||||||
import { sortByReleaseDate } from '~/utils/sort';
|
import { sortByReleaseDate } from '~/utils/sort';
|
||||||
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
import RowVirtualizerFixed from './RowVirtualizerFixed.vue';
|
||||||
|
|
||||||
const { openDialog } = useDialog();
|
|
||||||
const { allModels } = await useModels();
|
const { allModels } = await useModels();
|
||||||
const { addShortcut } = useKeyboardShortcuts();
|
const { addShortcut } = useKeyboardShortcuts();
|
||||||
|
|
||||||
@@ -17,6 +15,12 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const selectedModel = defineModel<ModelWithProvider | null>();
|
const selectedModel = defineModel<ModelWithProvider | null>();
|
||||||
|
|
||||||
|
const openProviderSettings = async (providerId?: string) => {
|
||||||
|
closeDropdown();
|
||||||
|
|
||||||
|
await navigateTo(providerId ? `/settings/providers/${providerId}` : '/settings/providers');
|
||||||
|
};
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const isOpen = ref(false);
|
||||||
const dropdownButton = ref<HTMLElement | null>(null);
|
const dropdownButton = ref<HTMLElement | null>(null);
|
||||||
const dropdownContent = 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">
|
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 }}
|
{{ item.provider.name }}
|
||||||
<button
|
<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)]">
|
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>
|
<span class="i-mynaui-cog-four text-4"></span>
|
||||||
</button>
|
</button>
|
||||||
@@ -295,7 +299,7 @@ onUnmounted(() => {
|
|||||||
<div class="p-1 border-t border-[var(--color-border)]">
|
<div class="p-1 border-t border-[var(--color-border)]">
|
||||||
<button
|
<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"
|
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 class="i-mynaui-cog-four text-4.5"></span>
|
||||||
<span>Manage Providers</span>
|
<span>Manage Providers</span>
|
||||||
<span class="i-mynaui-arrow-right text-4 ml-auto"></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">
|
<script setup lang="ts">
|
||||||
import { DialogType } from '~/composables/useDialog';
|
|
||||||
|
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
|
|
||||||
// to prevent the user details from going blank for a
|
// to prevent the user details from going blank for a
|
||||||
@@ -16,10 +14,14 @@ watch(user, () => {
|
|||||||
cachedUser.value = user.value;
|
cachedUser.value = user.value;
|
||||||
})
|
})
|
||||||
|
|
||||||
const { openDialog } = useDialog();
|
|
||||||
|
|
||||||
const { isHovered } = useSidenavContext();
|
const { isHovered } = useSidenavContext();
|
||||||
|
|
||||||
|
const openSettings = async () => {
|
||||||
|
dropdownOpen.value = false;
|
||||||
|
|
||||||
|
await navigateTo('/settings');
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await signOut();
|
await signOut();
|
||||||
|
|
||||||
@@ -31,11 +33,7 @@ const handleLogout = async () => {
|
|||||||
<header class="flex items-center justify-between overflow-hidden">
|
<header class="flex items-center justify-between overflow-hidden">
|
||||||
<button @click="dropdownOpen = !dropdownOpen"
|
<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)]">
|
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
|
<UserAvatar :user="cachedUser" class="w-9 h-9 md:w-7 md:h-7 rounded-lg md:text-sm" />
|
||||||
: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>
|
|
||||||
<span
|
<span
|
||||||
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
class="md:text-sm font-medium text-ellipsis overflow-hidden text-[var(--text-primary)] whitespace-nowrap">
|
||||||
{{ cachedUser?.name }}
|
{{ cachedUser?.name }}
|
||||||
@@ -55,7 +53,7 @@ const handleLogout = async () => {
|
|||||||
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
leave-from-class="opacity-100 scale-100" leave-to-class="opacity-0 scale-95">
|
||||||
<div v-if="dropdownOpen" v-click-outside="() => dropdownOpen = false"
|
<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">
|
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)]">
|
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 class="i-mynaui-cog-four text-4.5"></span>
|
||||||
<span>Settings</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">
|
<script setup lang="ts">
|
||||||
import { DialogType } from '~/composables/useDialog';
|
|
||||||
|
|
||||||
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
@@ -37,8 +35,6 @@ const onFocusOut = (e: FocusEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { openDialog } = useDialog();
|
|
||||||
|
|
||||||
const onResizeStart = (event: MouseEvent) => {
|
const onResizeStart = (event: MouseEvent) => {
|
||||||
isResizing.value = true;
|
isResizing.value = true;
|
||||||
startX.value = event.clientX;
|
startX.value = event.clientX;
|
||||||
@@ -97,8 +93,18 @@ const navKind = computed(() => {
|
|||||||
if (route.path === '/') return 'home';
|
if (route.path === '/') return 'home';
|
||||||
if (route.path.startsWith('/agent/')) return 'agent';
|
if (route.path.startsWith('/agent/')) return 'agent';
|
||||||
if (route.path === '/resources') return 'resources';
|
if (route.path === '/resources') return 'resources';
|
||||||
|
if (route.path.startsWith('/settings')) return 'settings';
|
||||||
return null;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -126,6 +132,7 @@ const navKind = computed(() => {
|
|||||||
<SidenavHeader v-if="navKind === 'home'" />
|
<SidenavHeader v-if="navKind === 'home'" />
|
||||||
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
|
<SidenavHeaderResources v-else-if="navKind === 'resources'" />
|
||||||
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
<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="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
||||||
<div :class="[
|
<div :class="[
|
||||||
@@ -158,15 +165,18 @@ const navKind = computed(() => {
|
|||||||
<SidenavNavHome v-if="navKind === 'home'" />
|
<SidenavNavHome v-if="navKind === 'home'" />
|
||||||
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
||||||
<SidenavNavResources v-else-if="navKind === 'resources'" />
|
<SidenavNavResources v-else-if="navKind === 'resources'" />
|
||||||
|
<SidenavNavSettings v-else-if="navKind === 'settings'" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
|
<div class="flex justify-between pt-2 z-25 bg-[var(--bg-base)] pb-2">
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<Tooltip :hotkey="['ctrl', ',']">
|
<Tooltip :hotkey="['ctrl', ',']">
|
||||||
<button @click="openDialog(DialogType.Settings)"
|
<NuxtLink to="/settings" aria-label="Settings" :class="[
|
||||||
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)]">
|
'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>
|
<span class="i-mynaui-cog-four text-6 md:text-5"></span>
|
||||||
</button>
|
</NuxtLink>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</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',
|
SignInFailed = 'SIGN_IN_FAILED',
|
||||||
SignUpFailed = 'SIGN_UP_FAILED',
|
SignUpFailed = 'SIGN_UP_FAILED',
|
||||||
SignOutFailed = 'SIGN_OUT_FAILED',
|
SignOutFailed = 'SIGN_OUT_FAILED',
|
||||||
|
UpdateFailed = 'UPDATE_FAILED',
|
||||||
NetworkError = 'NETWORK_ERROR',
|
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 {
|
return {
|
||||||
client: authClient,
|
client: authClient,
|
||||||
session,
|
session,
|
||||||
@@ -174,6 +197,7 @@ export const useAuth = () => {
|
|||||||
signIn,
|
signIn,
|
||||||
signUp,
|
signUp,
|
||||||
signOut,
|
signOut,
|
||||||
|
updateProfile,
|
||||||
fetchSession,
|
fetchSession,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Err, Ok, type Result } from "~~/types/result";
|
import { Err, Ok, type Result } from "~~/types/result";
|
||||||
|
|
||||||
export enum DialogType {
|
export enum DialogType {
|
||||||
Settings = 'settings',
|
|
||||||
Textbox = 'textbox',
|
Textbox = 'textbox',
|
||||||
QuickSwitcher = 'quick-switcher',
|
QuickSwitcher = 'quick-switcher',
|
||||||
Confirm = 'confirm',
|
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);
|
const hinting = parseInt((e.target as HTMLInputElement).value);
|
||||||
updateSettings({ appearance: { hinting } });
|
updateSettings({ appearance: { hinting } });
|
||||||
};
|
};
|
||||||
|
|
||||||
defineEmits(['navigate']);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -33,8 +33,6 @@ const getModel = (id: string | null | undefined) => {
|
|||||||
|
|
||||||
return allModels.value.find(m => m.id === id);
|
return allModels.value.find(m => m.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
defineEmits(['navigate']);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 { sortByReleaseDate } from '~/utils/sort';
|
||||||
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
import { encryptData, decrypt, uint8ArrayToBase64, base64ToUint8Array } from '~/utils/crypto';
|
||||||
import { type Model } from '~/types/model';
|
import { type Model } from '~/types/model';
|
||||||
import ModelItem from './ModelItem.vue';
|
import ModelItem from '~/components/Settings/ModelItem.vue';
|
||||||
import RowVirtualizerDynamic from '../RowVirtualizerDynamic.vue';
|
import RowVirtualizerDynamic from '~/components/RowVirtualizerDynamic.vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const route = useRoute();
|
||||||
params?: string;
|
const providerId = computed(() => route.params.id as string | undefined);
|
||||||
}>();
|
|
||||||
|
|
||||||
const { providers, updateProvider, createModel, updateModel } = await useModels();
|
const { providers, updateProvider, createModel, updateModel } = await useModels();
|
||||||
|
|
||||||
const scrollContainerRef = ref<HTMLDivElement | null>(null);
|
const scrollContainerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const provider = computed(() => {
|
const provider = computed(() => {
|
||||||
if (props.params === undefined) return null;
|
if (providerId.value === undefined) return null;
|
||||||
return providers.value!.find(p => p.id === props.params);
|
return providers.value!.find(p => p.id === providerId.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(provider, async () => {
|
watch(provider, async () => {
|
||||||
@@ -29,8 +28,8 @@ const apiKey = ref('');
|
|||||||
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
|
const apiProxyUrl = ref(provider.value?.config.apiProxyUrl ?? '');
|
||||||
const modelSearch = ref('');
|
const modelSearch = ref('');
|
||||||
|
|
||||||
watch(() => props.params, () => {
|
watch(() => providerId.value, () => {
|
||||||
if (props.params === undefined) return;
|
if (providerId.value === undefined) return;
|
||||||
apiKey.value = provider.value?.config.apiKey ?? '';
|
apiKey.value = provider.value?.config.apiKey ?? '';
|
||||||
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
|
apiProxyUrl.value = provider.value?.config.apiProxyUrl ?? '';
|
||||||
modelSearch.value = '';
|
modelSearch.value = '';
|
||||||
@@ -373,8 +372,6 @@ const formatMoney = (value: string) => {
|
|||||||
const isFormValid = computed(() => {
|
const isFormValid = computed(() => {
|
||||||
return formData.value.name.trim() && formData.value.externalId.trim();
|
return formData.value.name.trim() && formData.value.externalId.trim();
|
||||||
});
|
});
|
||||||
|
|
||||||
defineEmits(['navigate']);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
+3
-7
@@ -3,10 +3,6 @@ import { providerIcons } from '~/utils/model-mapping';
|
|||||||
|
|
||||||
const { providers, updateProvider } = await useModels();
|
const { providers, updateProvider } = await useModels();
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
params?: string;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
if (providers.value === undefined) throw new Error('Providers not loaded');
|
if (providers.value === undefined) throw new Error('Providers not loaded');
|
||||||
|
|
||||||
const toggleProvider = async (id: string) => {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -31,7 +27,7 @@ defineEmits(['navigate']);
|
|||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
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"
|
: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)]">
|
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">
|
<div class="flex flex-col flex-grow">
|
||||||
@@ -55,7 +51,7 @@ defineEmits(['navigate']);
|
|||||||
</h2>
|
</h2>
|
||||||
<div
|
<div
|
||||||
class="grid gap-4 grid-cols-[repeat(auto-fill,_minmax(max(240px,_calc((100%_-_16px_*_(3_-_1))_/_3)),_1fr))]">
|
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"
|
: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)]">
|
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">
|
<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