Files
gpt-load/web/src/app/AppShell.vue
T

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>