368 lines
9.7 KiB
Vue
368 lines
9.7 KiB
Vue
<script setup lang="ts">
|
|
import { Check, KeyRound, LockKeyhole } from '@lucide/vue'
|
|
import { computed, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { isNavigationFailure, RouterLink, useRoute, useRouter } from 'vue-router'
|
|
|
|
import {
|
|
accessKeysLocation,
|
|
groupsLocation,
|
|
homeLocation,
|
|
importLocation,
|
|
loginLocation,
|
|
monitorLocation,
|
|
modelsLocation,
|
|
pageRouteNames,
|
|
settingsLocation,
|
|
} from '@/app/route-locations'
|
|
import { useUnsavedChangesController } from '@/app/unsaved-changes'
|
|
import BrandMark from '@/components/brand/BrandMark.vue'
|
|
import { useAuthSession } from '@/features/auth/auth-session'
|
|
import { useImportRecovery } from '@/features/import/import-recovery'
|
|
import PreferencesControl from '@/features/preferences/PreferencesControl.vue'
|
|
import { useTheme } from '@/features/preferences/theme'
|
|
import { supportedLocales, type AppLocale } from '@/i18n'
|
|
import { useAppI18n } from '@/i18n/context'
|
|
|
|
const session = useAuthSession()
|
|
const recovery = useImportRecovery()
|
|
const unsavedChanges = useUnsavedChangesController()
|
|
const appI18n = useAppI18n()
|
|
const theme = useTheme()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { locale, t } = useI18n()
|
|
|
|
const isAccessKey = computed(() => session.state.principalType === 'access_key')
|
|
const navigation = computed(() => {
|
|
const shared = [
|
|
{ key: 'home', to: homeLocation(), label: t('shell.home') },
|
|
{ key: 'models', to: modelsLocation(), label: t('shell.models') },
|
|
{ key: 'monitor', to: monitorLocation(), label: t('shell.monitor') },
|
|
]
|
|
if (isAccessKey.value) return shared
|
|
return [
|
|
shared[0]!,
|
|
{ key: 'groups', to: groupsLocation(), label: t('shell.groups') },
|
|
shared[1]!,
|
|
{
|
|
key: 'access-keys',
|
|
to: accessKeysLocation(),
|
|
label: t('shell.accessKeys'),
|
|
},
|
|
shared[2]!,
|
|
{ key: 'settings', to: settingsLocation(), label: t('shell.settings') },
|
|
]
|
|
})
|
|
const currentLocale = computed(() => locale.value as AppLocale)
|
|
|
|
function isPrimaryActive(key: string): boolean {
|
|
return route.meta.primaryNav === key
|
|
}
|
|
|
|
function setLocale(value: string): void {
|
|
if (supportedLocales.includes(value as AppLocale)) {
|
|
void appI18n.setLocale(value as AppLocale)
|
|
}
|
|
}
|
|
|
|
async function logout(): Promise<void> {
|
|
const bypassDirtyImport = route.name === pageRouteNames.import
|
|
if (bypassDirtyImport) {
|
|
recovery.clear()
|
|
unsavedChanges.bypassNext()
|
|
session.clear()
|
|
try {
|
|
await router.replace(loginLocation())
|
|
} finally {
|
|
unsavedChanges.consumeBypass()
|
|
}
|
|
return
|
|
}
|
|
|
|
const failure = await router.replace(loginLocation())
|
|
if (isNavigationFailure(failure)) return
|
|
recovery.clear()
|
|
session.clear()
|
|
}
|
|
|
|
watch(
|
|
[() => route.meta.titleKey, locale],
|
|
() => {
|
|
const titleKey = route.meta.titleKey
|
|
document.title = titleKey ? `${t(titleKey)} · ${t('common.appName')}` : t('common.appName')
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div class="app-shell">
|
|
<a class="skip-link" href="#main-content">{{ t('shell.skip') }}</a>
|
|
<header class="app-topbar">
|
|
<div class="app-topbar__inner">
|
|
<RouterLink
|
|
class="brand"
|
|
:to="homeLocation()"
|
|
:aria-label="`${t('common.appName')} · ${t('shell.home')}`"
|
|
>
|
|
<BrandMark :size="24" />
|
|
<span>{{ t('common.appName') }}</span>
|
|
</RouterLink>
|
|
|
|
<nav class="desktop-nav" :aria-label="t('shell.primaryNavigation')">
|
|
<RouterLink
|
|
v-for="item in navigation"
|
|
:key="item.key"
|
|
class="nav-link"
|
|
:class="{ 'nav-link--active': isPrimaryActive(item.key) }"
|
|
:to="item.to"
|
|
:aria-current="isPrimaryActive(item.key) ? 'page' : undefined"
|
|
>
|
|
{{ item.label }}
|
|
</RouterLink>
|
|
</nav>
|
|
|
|
<span
|
|
v-if="isAccessKey"
|
|
class="access-scope-badge"
|
|
:title="t('shell.accessKeyReadOnlyDescription')"
|
|
>
|
|
<LockKeyhole :size="13" aria-hidden="true" />
|
|
<span>{{ t('shell.accessKeyReadOnly') }}</span>
|
|
</span>
|
|
|
|
<div class="shell-actions">
|
|
<RouterLink
|
|
v-if="!isAccessKey"
|
|
class="button-link import-action"
|
|
:to="importLocation()"
|
|
:aria-label="t('shell.import')"
|
|
>
|
|
<KeyRound :size="15" aria-hidden="true" />
|
|
<span class="import-action__label">{{ t('shell.import') }}</span>
|
|
</RouterLink>
|
|
<PreferencesControl
|
|
compact
|
|
show-sign-out
|
|
:locale="currentLocale"
|
|
:trigger-label="t('shell.menu')"
|
|
:theme="theme.theme.value"
|
|
@update:locale="setLocale"
|
|
@update:theme="theme.setTheme"
|
|
@sign-out="logout"
|
|
>
|
|
<template #mobile-navigation="{ close }">
|
|
<nav class="mobile-nav" :aria-label="t('shell.primaryNavigation')">
|
|
<p class="mobile-nav__label">{{ t('shell.primaryNavigation') }}</p>
|
|
<RouterLink
|
|
v-for="item in navigation"
|
|
:key="item.key"
|
|
class="mobile-nav__link"
|
|
:class="{ 'mobile-nav__link--active': isPrimaryActive(item.key) }"
|
|
:to="item.to"
|
|
:aria-current="isPrimaryActive(item.key) ? 'page' : undefined"
|
|
@click="close"
|
|
>
|
|
<span>{{ item.label }}</span>
|
|
<Check v-if="isPrimaryActive(item.key)" :size="15" aria-hidden="true" />
|
|
</RouterLink>
|
|
</nav>
|
|
</template>
|
|
</PreferencesControl>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main id="main-content" class="app-content" tabindex="-1">
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.app-shell {
|
|
min-height: 100vh;
|
|
}
|
|
.app-topbar {
|
|
position: sticky;
|
|
z-index: var(--z-sticky);
|
|
top: 0;
|
|
display: flex;
|
|
height: var(--topbar-height);
|
|
align-items: center;
|
|
gap: 28px;
|
|
border-bottom: 1px solid var(--color-border-subtle);
|
|
background: var(--color-surface);
|
|
padding: 0 var(--topbar-padding-inline);
|
|
}
|
|
.app-topbar__inner {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 100%;
|
|
align-items: center;
|
|
gap: 28px;
|
|
}
|
|
.brand {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
font-family: var(--font-serif);
|
|
font-size: var(--title-section);
|
|
font-weight: 400;
|
|
letter-spacing: -0.01em;
|
|
white-space: nowrap;
|
|
}
|
|
.access-scope-badge {
|
|
display: inline-flex;
|
|
min-width: 0;
|
|
align-items: center;
|
|
gap: 5px;
|
|
border: 1px solid var(--color-border-control);
|
|
border-radius: var(--radius-tag);
|
|
background: var(--color-surface-sunken);
|
|
color: var(--color-text-muted);
|
|
padding: 3px 7px;
|
|
font-size: var(--text-label-xs);
|
|
font-weight: 560;
|
|
line-height: 1.2;
|
|
white-space: nowrap;
|
|
}
|
|
.desktop-nav {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-5);
|
|
}
|
|
.nav-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
border-bottom: 1.5px solid transparent;
|
|
color: var(--color-text-muted);
|
|
padding: 3px 0;
|
|
font-size: 13px;
|
|
font-weight: 400;
|
|
transition:
|
|
color var(--duration-fast) var(--easing-standard),
|
|
border-color var(--duration-fast) var(--easing-standard);
|
|
}
|
|
.nav-link:hover,
|
|
.nav-link.router-link-active,
|
|
.nav-link--active {
|
|
color: var(--color-text);
|
|
}
|
|
.nav-link.router-link-active,
|
|
.nav-link--active {
|
|
border-bottom-color: var(--color-text);
|
|
font-weight: 560;
|
|
}
|
|
.shell-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--space-2);
|
|
margin-left: auto;
|
|
}
|
|
.button-link {
|
|
gap: var(--space-2);
|
|
}
|
|
.import-action {
|
|
height: var(--control-compact);
|
|
min-height: var(--control-compact);
|
|
gap: 6px;
|
|
border: 1px solid var(--color-action);
|
|
background: var(--color-action);
|
|
color: var(--color-action-ink);
|
|
padding: 0 9px;
|
|
font-size: var(--text-meta);
|
|
font-weight: 560;
|
|
}
|
|
.import-action__label {
|
|
letter-spacing: 0.01em;
|
|
}
|
|
.app-content {
|
|
min-height: calc(100vh - var(--topbar-height));
|
|
}
|
|
.mobile-nav {
|
|
display: grid;
|
|
gap: var(--space-1);
|
|
}
|
|
.mobile-nav__label {
|
|
margin: 0;
|
|
color: var(--color-text-faint);
|
|
padding: 0 2px 2px;
|
|
font-size: var(--text-label-xs);
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
}
|
|
.mobile-nav__link {
|
|
display: flex;
|
|
min-height: var(--touch-target);
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius-control);
|
|
color: var(--color-text-muted);
|
|
padding: 0 10px;
|
|
font-size: var(--text-sm);
|
|
transition:
|
|
color var(--duration-fast) var(--easing-standard),
|
|
border-color var(--duration-fast) var(--easing-standard),
|
|
background-color var(--duration-fast) var(--easing-standard);
|
|
}
|
|
.mobile-nav__link:hover {
|
|
background: var(--color-surface-sunken);
|
|
color: var(--color-text);
|
|
}
|
|
.mobile-nav__link:focus-visible {
|
|
outline: 2px solid var(--color-focus);
|
|
outline-offset: 1px;
|
|
}
|
|
.mobile-nav__link--active {
|
|
border-color: var(--color-border-control);
|
|
background: var(--color-surface-sunken);
|
|
color: var(--color-text);
|
|
font-weight: 560;
|
|
}
|
|
@media (min-width: 1360px) {
|
|
.app-topbar {
|
|
padding-inline: var(--stage-padding-inline);
|
|
}
|
|
.app-topbar__inner {
|
|
position: relative;
|
|
width: min(100%, var(--page-max));
|
|
margin: 0 auto;
|
|
}
|
|
.brand {
|
|
position: absolute;
|
|
top: 50%;
|
|
right: calc(100% + var(--space-4));
|
|
transform: translateY(-50%);
|
|
}
|
|
}
|
|
@media (max-width: 860px) {
|
|
.app-topbar {
|
|
gap: var(--space-2);
|
|
padding-inline: var(--space-4);
|
|
}
|
|
.app-topbar__inner {
|
|
gap: var(--space-2);
|
|
}
|
|
.desktop-nav {
|
|
display: none;
|
|
}
|
|
.import-action {
|
|
width: var(--touch-target);
|
|
min-height: var(--touch-target);
|
|
padding: 0;
|
|
}
|
|
.import-action__label {
|
|
display: none;
|
|
}
|
|
.shell-actions :deep(.preferences-trigger) {
|
|
width: var(--touch-target);
|
|
height: var(--touch-target);
|
|
}
|
|
}
|
|
</style>
|