61c73f394d
Sidenav becomes a full-screen overlay on mobile with slide-in animation. Dialog goes full-screen on small screens. Settings dialog gets a mobile nav with hamburger menu. Touch targets and text sizes scaled up for mobile. Slider gets visual checked/unchecked colors. Sidebar auto-closes on mobile route navigation. Resize handle hidden on mobile.
190 lines
7.8 KiB
Vue
190 lines
7.8 KiB
Vue
<script setup lang="ts">
|
|
import { DialogType } from '~/composables/useDialog';
|
|
|
|
const { close: closeSidebar, open, sidebarWidth, resize, saveWidth } = useSidebar();
|
|
const route = useRoute();
|
|
|
|
const isResizing = ref(false);
|
|
const startX = ref(0);
|
|
const initialWidth = ref(0);
|
|
|
|
const isMouseOver = ref(false);
|
|
const isFocused = ref(false);
|
|
const lastInteraction = ref<'mouse' | 'keyboard' | null>(null);
|
|
|
|
const isHovering = computed(() =>
|
|
isMouseOver.value || (isFocused.value && lastInteraction.value === 'keyboard')
|
|
);
|
|
const sidenavRef = ref<HTMLElement | null>(null);
|
|
|
|
provideSidenavContext({
|
|
isHovered: isHovering,
|
|
sidebarWidth: sidebarWidth,
|
|
isOpen: open,
|
|
close: () => {
|
|
closeSidebar();
|
|
},
|
|
});
|
|
|
|
const trackInteraction = (interaction: 'mouse' | 'keyboard') => {
|
|
lastInteraction.value = interaction;
|
|
};
|
|
|
|
const onFocusOut = (e: FocusEvent) => {
|
|
const isMovingOutside = sidenavRef.value && !sidenavRef.value.contains(e.relatedTarget as Node);
|
|
if (isMovingOutside) {
|
|
isFocused.value = false;
|
|
}
|
|
};
|
|
|
|
const { openDialog } = useDialog();
|
|
|
|
const onResizeStart = (event: MouseEvent) => {
|
|
isResizing.value = true;
|
|
startX.value = event.clientX;
|
|
initialWidth.value = sidebarWidth.value;
|
|
document.body.style.cursor = 'col-resize';
|
|
document.body.style.userSelect = 'none';
|
|
};
|
|
|
|
const onResizeMove = (event: MouseEvent) => {
|
|
if (!isResizing.value) return;
|
|
if (resizeAnimationFrame) return;
|
|
|
|
resizeAnimationFrame = requestAnimationFrame(() => {
|
|
const deltaX = event.clientX - startX.value;
|
|
const newWidth = initialWidth.value + deltaX;
|
|
resize(newWidth);
|
|
resizeAnimationFrame = null;
|
|
});
|
|
};
|
|
|
|
const onResizeEnd = () => {
|
|
if (!isResizing.value) return;
|
|
|
|
isResizing.value = false;
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
saveWidth();
|
|
};
|
|
|
|
let resizeAnimationFrame: number | null = null;
|
|
|
|
const trackInteractionMouse = () => trackInteraction('mouse');
|
|
const trackInteractionKeyboard = (e: KeyboardEvent) => {
|
|
if (e.key === 'Tab') trackInteraction('keyboard');
|
|
};
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('mousemove', onResizeMove);
|
|
document.addEventListener('mouseup', onResizeEnd);
|
|
|
|
// NEW: Track global interactions
|
|
document.addEventListener('mousedown', trackInteractionMouse);
|
|
document.addEventListener('keydown', trackInteractionKeyboard);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
document.removeEventListener('mousemove', onResizeMove);
|
|
document.removeEventListener('mouseup', onResizeEnd);
|
|
|
|
// NEW: Cleanup listeners
|
|
document.removeEventListener('mousedown', trackInteractionMouse);
|
|
document.removeEventListener('keydown', trackInteractionKeyboard);
|
|
});
|
|
|
|
const navKind = computed(() => {
|
|
if (route.path === '/') return 'home';
|
|
if (route.path.startsWith('/agent/')) return 'agent';
|
|
return null;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="relative">
|
|
<aside ref="sidenavRef" :class="[
|
|
'mobile-full-width pr-2 md:pr-0 bg-[var(--bg-base)] h-full background-[var(--bg-base)] overflow-hidden will-change-width text-[var(--text-secondary)] select-none transition-[width,margin,transform] duration-250 ease-[cubic-bezier(0.5,_1,_0.89,_1)]',
|
|
open ? 'w-full' : 'w-0',
|
|
isResizing ? 'transition-none' : '',
|
|
// Mobile styles
|
|
'md:static md:z-auto',
|
|
open ? 'md:translate-x-0' : 'md:translate-x-0',
|
|
// Mobile overlay styles
|
|
'fixed inset-0 z-15 h-screen md:h-full',
|
|
open ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
|
'md:max-w-xs',
|
|
]" :style="open ? { width: `${sidebarWidth}px` } : {}" @mouseenter="isMouseOver = true"
|
|
@mouseleave="isMouseOver = false" @focusin="isFocused = true" @focusout="onFocusOut">
|
|
<div :style="{ width: `${sidebarWidth}px` }"
|
|
class="mobile-full-width pl-2 flex flex-col h-full justify-between">
|
|
<div class="flex flex-col h-full max-h-full overflow-y-hidden">
|
|
<!-- Header -->
|
|
<div
|
|
class="z-25 bg-[var(--bg-base)] relative flex flex-row gap-2 justify-between items-center shrink-0 pb-1.5 h-13 md:h-11 pt-2">
|
|
|
|
<SidenavHeader v-if="navKind === 'home'" />
|
|
<SidenavHeaderAgent v-else-if="navKind === 'agent'" />
|
|
|
|
<div class="flex items-center justify-end text-[var(--text-secondary)] gap-0.5">
|
|
<div :class="[
|
|
'w-10 flex-shrink-0 overflow-hidden rounded-lg transition-all duration-150 ease-[cubic-bezier(0.5,_1,_0.89,_1)] transform-origin-center-right md:flex',
|
|
isHovering ? 'md:w-8' : 'md:w-0',
|
|
]">
|
|
<Tooltip :hotkey="['ctrl', '[']">
|
|
<button aria-label="close sidebar" @click="closeSidebar" :class="[
|
|
'flex h-10 w-10 md:h-8 md:w-8 items-center justify-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent transition-inherit',
|
|
]">
|
|
<span
|
|
class="i-mynaui-panel-left-close opacity-100 scale-100 text-6 md:text-5 transition-all transform-origin-right-center"
|
|
:class="isHovering ? '' : 'md:opacity-0 md:scale-95'"></span>
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
<div v-if="navKind === 'agent'" class="flex-shrink-0 overflow-hidden rounded-lg">
|
|
<Tooltip :hotkey="['ctrl', 'alt', 'n']">
|
|
<NuxtLink aria-label="Start a new topic" :to="`/agent/${route.params.id}`" :class="[
|
|
'flex h-10 w-10 md:h-8 md:w-8 items-center justify-center @hover:bg-[var(--color-hover)] focus-visible:bg-[var(--color-hover)] bg-transparent text-inherit',
|
|
]">
|
|
<span class="i-mynaui-book-plus text-6 md:text-5"></span>
|
|
</NuxtLink>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main Menu -->
|
|
<SidenavNavHome v-if="navKind === 'home'" />
|
|
<SidenavNavAgent v-else-if="navKind === 'agent'" />
|
|
</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)]">
|
|
<span class="i-mynaui-cog-four text-6 md:text-5"></span>
|
|
</button>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div class="flex justify-end gap-1">
|
|
<ThemeSwitcher size="small" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</aside>
|
|
<!-- resize handle - hidden on mobile -->
|
|
<div @mousedown="onResizeStart" class="md:flex hidden absolute top-0 left-full bottom-0 p-1 cursor-ew-resize">
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
@media (max-width: 767px) {
|
|
.mobile-full-width {
|
|
width: 100% !important;
|
|
min-width: 100% !important;
|
|
}
|
|
}
|
|
</style> |