da7910f934
feat(chat): improve resilience and collapsible sidebar
692 lines
18 KiB
Vue
692 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { useI18n } from "vue-i18n";
|
||
import { NButton, useMessage } from "naive-ui";
|
||
import { useAppStore } from "@/stores/hermes/app";
|
||
import ModelSelector from "./ModelSelector.vue";
|
||
import ProfileSelector from "./ProfileSelector.vue";
|
||
import LanguageSwitch from "./LanguageSwitch.vue";
|
||
import ThemeSwitch from "./ThemeSwitch.vue";
|
||
import danceVideoLight from "@/assets/dance-light.mp4";
|
||
import danceVideoDark from "@/assets/dance-dark.mp4";
|
||
|
||
import { useTheme } from "@/composables/useTheme";
|
||
|
||
const { t } = useI18n();
|
||
const { isDark } = useTheme();
|
||
const message = useMessage();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const appStore = useAppStore();
|
||
const selectedKey = computed(() => route.name as string);
|
||
|
||
function handleNav(key: string) {
|
||
router.push({ name: key });
|
||
}
|
||
|
||
async function handleUpdate() {
|
||
const ok = await appStore.doUpdate();
|
||
if (ok) {
|
||
message.success(t('sidebar.updateSuccess'), { duration: 5000 });
|
||
} else {
|
||
message.error(t('sidebar.updateFailed'));
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<aside class="sidebar" :class="{ open: appStore.sidebarOpen, collapsed: appStore.sidebarCollapsed }">
|
||
<div class="sidebar-logo">
|
||
<button
|
||
type="button"
|
||
class="logo-main"
|
||
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : 'Hermes'"
|
||
@click="router.push('/hermes/chat')"
|
||
>
|
||
<img src="/logo.png" alt="Hermes" class="logo-img" />
|
||
<span class="logo-text">Hermes</span>
|
||
</button>
|
||
<video
|
||
class="logo-dance"
|
||
:src="isDark ? danceVideoDark : danceVideoLight"
|
||
autoplay
|
||
loop
|
||
muted
|
||
playsinline
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="sidebar-collapse-btn"
|
||
:title="appStore.sidebarCollapsed ? t('sidebar.expand') : t('sidebar.collapse')"
|
||
@click="appStore.toggleSidebarCollapsed"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<polyline v-if="appStore.sidebarCollapsed" points="9 18 15 12 9 6" />
|
||
<polyline v-else points="15 18 9 12 15 6" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<nav class="sidebar-nav">
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.chat' }"
|
||
:title="t('sidebar.chat')"
|
||
@click="handleNav('hermes.chat')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path
|
||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||
/>
|
||
</svg>
|
||
<span>{{ t("sidebar.chat") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.jobs' }"
|
||
:title="t('sidebar.jobs')"
|
||
@click="handleNav('hermes.jobs')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||
<line x1="16" y1="2" x2="16" y2="6" />
|
||
<line x1="8" y1="2" x2="8" y2="6" />
|
||
<line x1="3" y1="10" x2="21" y2="10" />
|
||
</svg>
|
||
<span>{{ t("sidebar.jobs") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.models' }"
|
||
:title="t('sidebar.models')"
|
||
@click="handleNav('hermes.models')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path d="M12 1v4" />
|
||
<path d="M12 19v4" />
|
||
<path d="M1 12h4" />
|
||
<path d="M19 12h4" />
|
||
<path d="M4.22 4.22l2.83 2.83" />
|
||
<path d="M16.95 16.95l2.83 2.83" />
|
||
<path d="M4.22 19.78l2.83-2.83" />
|
||
<path d="M16.95 7.05l2.83-2.83" />
|
||
</svg>
|
||
<span>{{ t("sidebar.models") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.channels' }"
|
||
:title="t('sidebar.channels')"
|
||
@click="handleNav('hermes.channels')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||
</svg>
|
||
<span>{{ t("sidebar.channels") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.skills' }"
|
||
:title="t('sidebar.skills')"
|
||
@click="handleNav('hermes.skills')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<polygon points="12 2 2 7 12 12 22 7 12 2" />
|
||
<polyline points="2 17 12 22 22 17" />
|
||
<polyline points="2 12 12 17 22 12" />
|
||
</svg>
|
||
<span>{{ t("sidebar.skills") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.memory' }"
|
||
:title="t('sidebar.memory')"
|
||
@click="handleNav('hermes.memory')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="M9 18h6" />
|
||
<path d="M10 22h4" />
|
||
<path d="M12 2a7 7 0 0 0-4 12.7V17h8v-2.3A7 7 0 0 0 12 2z" />
|
||
</svg>
|
||
<span>{{ t("sidebar.memory") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.logs' }"
|
||
:title="t('sidebar.logs')"
|
||
@click="handleNav('hermes.logs')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path
|
||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||
/>
|
||
<polyline points="14 2 14 8 20 8" />
|
||
<line x1="16" y1="13" x2="8" y2="13" />
|
||
<line x1="16" y1="17" x2="8" y2="17" />
|
||
<polyline points="10 9 9 9 8 9" />
|
||
</svg>
|
||
<span>{{ t("sidebar.logs") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.usage' }"
|
||
:title="t('sidebar.usage')"
|
||
@click="handleNav('hermes.usage')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<rect x="3" y="12" width="4" height="9" rx="1" />
|
||
<rect x="10" y="7" width="4" height="14" rx="1" />
|
||
<rect x="17" y="3" width="4" height="18" rx="1" />
|
||
</svg>
|
||
<span>{{ t("sidebar.usage") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.profiles' }"
|
||
@click="handleNav('hermes.profiles')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||
<circle cx="12" cy="7" r="4" />
|
||
</svg>
|
||
<span>{{ t("sidebar.profiles") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.terminal' }"
|
||
:title="t('sidebar.terminal')"
|
||
@click="handleNav('hermes.terminal')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<polyline points="4 17 10 11 4 5" />
|
||
<line x1="12" y1="19" x2="20" y2="19" />
|
||
</svg>
|
||
<span>{{ t("sidebar.terminal") }}</span>
|
||
</button>
|
||
|
||
<button
|
||
class="nav-item"
|
||
:class="{ active: selectedKey === 'hermes.settings' }"
|
||
:title="t('sidebar.settings')"
|
||
@click="handleNav('hermes.settings')"
|
||
>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
stroke-width="1.5"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
>
|
||
<circle cx="12" cy="12" r="3" />
|
||
<path
|
||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||
/>
|
||
</svg>
|
||
<span>{{ t("sidebar.settings") }}</span>
|
||
</button>
|
||
</nav>
|
||
|
||
<ProfileSelector />
|
||
<ModelSelector />
|
||
|
||
<div class="sidebar-footer">
|
||
<div class="status-row">
|
||
<div
|
||
class="status-indicator"
|
||
:class="{
|
||
connected: appStore.connected,
|
||
disconnected: !appStore.connected,
|
||
}"
|
||
>
|
||
<span class="status-dot"></span>
|
||
<span class="status-text">{{
|
||
appStore.connected
|
||
? t("sidebar.connected")
|
||
: t("sidebar.disconnected")
|
||
}}</span>
|
||
</div>
|
||
<LanguageSwitch />
|
||
</div>
|
||
<div class="version-info">
|
||
<a class="github-link" href="https://github.com/EKKOLearnAI/hermes-web-ui" target="_blank" rel="noopener noreferrer" title="GitHub">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
|
||
</a>
|
||
<span>Hermes Web UI v{{ appStore.serverVersion || "0.1.0" }}</span>
|
||
<ThemeSwitch />
|
||
</div>
|
||
<NButton v-if="appStore.updateAvailable" type="primary" size="tiny" block class="update-btn" :loading="appStore.updating" @click="handleUpdate">
|
||
{{ appStore.updating ? t('sidebar.updating') : t('sidebar.updateVersion', { version: appStore.latestVersion }) }}
|
||
</NButton>
|
||
</div>
|
||
</aside>
|
||
</template>
|
||
|
||
<style scoped lang="scss">
|
||
@use "@/styles/variables" as *;
|
||
|
||
.sidebar {
|
||
width: $sidebar-width;
|
||
height: calc(100 * var(--vh));
|
||
background-color: $bg-sidebar;
|
||
border-right: 1px solid $border-color;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0 12px 20px;
|
||
flex-shrink: 0;
|
||
transition: width $transition-normal;
|
||
}
|
||
|
||
.logo-img {
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 0;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.sidebar-logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 20px 12px;
|
||
margin: 0 -12px;
|
||
color: $text-primary;
|
||
background-color: $bg-card;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||
|
||
.dark & {
|
||
background-color: #393939;
|
||
}
|
||
position: relative;
|
||
overflow: hidden;
|
||
|
||
.logo-main {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: none;
|
||
background: none;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
color: inherit;
|
||
text-align: left;
|
||
}
|
||
|
||
.logo-text {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.logo-dance {
|
||
position: absolute;
|
||
// Give the 36-wide collapse button + 12px sidebar padding breathing room.
|
||
right: 54px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
height: 100px;
|
||
border-radius: $radius-md;
|
||
object-fit: contain;
|
||
flex-shrink: 0;
|
||
width: auto;
|
||
pointer-events: none;
|
||
}
|
||
}
|
||
|
||
.sidebar-collapse-btn {
|
||
flex-shrink: 0;
|
||
// 36×36 meets the 44dp Material / 44pt Apple touch-target floor after
|
||
// typical zoom-out (e.g. Chrome mobile at 80% → ~29 physical px, still
|
||
// finger-friendly). The 14×14 SVG inside keeps the chevron visually small.
|
||
width: 36px;
|
||
height: 36px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid $border-color;
|
||
background: $bg-card;
|
||
border-radius: $radius-sm;
|
||
color: $text-secondary;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
position: relative;
|
||
z-index: 2;
|
||
transition: all $transition-fast;
|
||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0.08);
|
||
|
||
// Invisible padding via ::before extends the hit box a further 6px on each
|
||
// side without affecting layout — makes the button easy to tap even with
|
||
// imprecise touch input.
|
||
&::before {
|
||
content: '';
|
||
position: absolute;
|
||
inset: -6px;
|
||
}
|
||
|
||
svg {
|
||
width: 14px;
|
||
height: 14px;
|
||
}
|
||
|
||
&:hover {
|
||
color: $text-primary;
|
||
border-color: $accent-muted;
|
||
background: $bg-card-hover;
|
||
}
|
||
|
||
&:active {
|
||
background: rgba(0, 0, 0, 0.06);
|
||
transform: scale(0.94);
|
||
}
|
||
}
|
||
|
||
.sidebar-nav {
|
||
flex: 1;
|
||
display: flex;
|
||
padding-top: 12px;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
overflow-y: auto;
|
||
min-height: 0;
|
||
scrollbar-width: none;
|
||
|
||
&::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
border: none;
|
||
background: none;
|
||
color: $text-secondary;
|
||
font-size: 14px;
|
||
border-radius: $radius-sm;
|
||
cursor: pointer;
|
||
transition: all $transition-fast;
|
||
width: 100%;
|
||
text-align: left;
|
||
|
||
&:hover {
|
||
background-color: rgba(var(--accent-primary-rgb), 0.06);
|
||
color: $text-primary;
|
||
}
|
||
|
||
&.active {
|
||
background-color: rgba(var(--accent-primary-rgb), 0.12);
|
||
color: $accent-primary;
|
||
}
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding-top: 16px;
|
||
border-top: 1px solid $border-color;
|
||
}
|
||
|
||
.status-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 8px 12px;
|
||
}
|
||
|
||
.status-indicator {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 12px;
|
||
|
||
.status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
&.connected .status-dot {
|
||
background-color: $success;
|
||
box-shadow: 0 0 6px rgba(var(--success-rgb), 0.5);
|
||
}
|
||
|
||
&.disconnected .status-dot {
|
||
background-color: $error;
|
||
}
|
||
|
||
.status-text {
|
||
color: $text-secondary;
|
||
}
|
||
}
|
||
|
||
.version-info {
|
||
padding: 2px 12px 8px;
|
||
font-size: 11px;
|
||
color: $text-muted;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.github-link {
|
||
color: $text-muted;
|
||
display: flex;
|
||
align-items: center;
|
||
transition: color 0.2s;
|
||
|
||
&:hover {
|
||
color: $text-primary;
|
||
}
|
||
}
|
||
|
||
.update-btn {
|
||
margin: 4px 0 0;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
// Desktop-only collapsed ("icon rail") state. Mobile continues to use the
|
||
// slide-in open/close behaviour below.
|
||
@media (min-width: #{$breakpoint-mobile + 1px}) {
|
||
.sidebar.collapsed {
|
||
width: $sidebar-collapsed-width;
|
||
padding: 0 6px 16px;
|
||
|
||
.sidebar-logo {
|
||
padding: 16px 6px;
|
||
margin: 0 -6px;
|
||
gap: 4px;
|
||
justify-content: center;
|
||
|
||
.logo-main {
|
||
flex: 0 0 auto;
|
||
justify-content: center;
|
||
}
|
||
|
||
.logo-text,
|
||
.logo-dance {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
.nav-item {
|
||
justify-content: center;
|
||
padding: 12px 0;
|
||
gap: 0;
|
||
|
||
span {
|
||
display: none;
|
||
}
|
||
}
|
||
|
||
:deep(.model-selector) {
|
||
display: none;
|
||
}
|
||
|
||
.sidebar-footer {
|
||
padding-top: 12px;
|
||
}
|
||
|
||
.status-row {
|
||
padding: 8px 0;
|
||
justify-content: center;
|
||
|
||
:deep(.input-sm),
|
||
.status-text {
|
||
display: none;
|
||
}
|
||
|
||
.status-indicator {
|
||
gap: 0;
|
||
}
|
||
}
|
||
|
||
.version-info {
|
||
display: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
@media (max-width: $breakpoint-mobile) {
|
||
.logo-dance {
|
||
display: none;
|
||
}
|
||
|
||
// Desktop-only collapse toggle — mobile relies on the slide-in open state.
|
||
.sidebar-collapse-btn {
|
||
display: none;
|
||
}
|
||
|
||
.status-row {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
|
||
.sidebar {
|
||
position: fixed;
|
||
left: 0;
|
||
top: 0;
|
||
z-index: 1000;
|
||
transform: translateX(-100%);
|
||
transition: transform $transition-normal;
|
||
|
||
&.open {
|
||
transform: translateX(0);
|
||
}
|
||
|
||
// Override global utility — sidebar is always 240px wide
|
||
.input-sm {
|
||
width: 90px;
|
||
}
|
||
}
|
||
}
|
||
</style>
|