Files
mathew/frontend/app/components/AppHeader.vue
2026-09-30 01:49:35 +02:00

194 lines
8.9 KiB
Vue

<script setup lang="ts">
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
const { show: openSearch } = useSearch()
const { show: openSettings } = useSettings()
const { tabs, lastSlug } = useArticleTabs()
const { user, isAdmin, signOut } = useAuth()
const { show: openSignIn } = useSignInPopup()
const { show: openProfile } = useProfileMenu()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
/* The open articles live in the reading surface: the book ArticleBookStack
lays their pages side by side, older ones parked with only their spines
showing. The stack stays open all session, but the view of it is only drawn
in the reader — so this button is the way back: press it wherever you are
(the graph, the list, the editor) and every article you have open comes back
into view, stacked as you left it. */
const inReader = computed(() => wikiSlugFromPath(route.path) !== null)
function showOpenArticles() {
if (inReader.value) return // the pages are already spread out in view
/* turn back to the page that was last in view — every tab knows its own
address, language versions included */
const target =
tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1]
if (target) void router.push(tabHref(target))
}
/* the shortcut hint beside the search bar — "/" keeps focus on the home
page's inline box there, so the popup's universal key is Ctrl/⌘+K */
const kbdHint = ref('/')
onMounted(() => {
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
restoreSidebar()
})
/* the account row wears an initial in a circle, so it still reads as a person
once the collapsed rail has dropped the name beside it */
const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperCase())
</script>
<template>
<aside class="site-sidebar" :class="{ collapsed }">
<NuxtLink to="/" class="logo" :aria-label="t('sidebar.home')">
<span class="logo-mark">∑</span>
<span class="hide-collapsed">math<span class="logo-accent">ew</span></span>
</NuxtLink>
<nav class="sidebar-nav">
<button
type="button"
class="sidebar-search"
:aria-label="t('common.searchArticles')"
@click="openSearch()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.2-3.2" />
</svg>
<span class="sidebar-search-label hide-collapsed">{{ t('sidebar.search') }}</span>
<kbd class="kbd-hint hide-collapsed">{{ kbdHint }}</kbd>
</button>
<button
v-if="tabs.length"
type="button"
class="nav-link sidebar-open"
:class="{ 'is-current': inReader }"
:aria-label="t('sidebar.openAria', tabs.length)"
:title="t('sidebar.openTitle', tabs.length)"
@click="showOpenArticles()"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 2 2 7l10 5 10-5-10-5z" />
<path d="m2 12 10 5 10-5" />
<path d="m2 17 10 5 10-5" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.open') }}</span>
<span class="tabs-count hide-collapsed">{{ tabs.length }}</span>
</button>
<NuxtLink to="/articles" class="nav-link" :title="t('sidebar.articles')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.articles') }}</span>
</NuxtLink>
<NuxtLink to="/graph" class="nav-link" :title="t('sidebar.graph')">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.graph') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin"
class="nav-link"
:title="t('sidebar.adminHint')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
</NuxtLink>
<NuxtLink to="/new" class="btn btn-primary btn-sm" :title="t('sidebar.newArticle')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.newArticle') }}</span>
</NuxtLink>
</nav>
<div class="sidebar-footer">
<!-- who is using the wiki. Reading needs no account, so a visitor is given
the door in; a signed-in person sees who writes as themselves and can
open their profile — the picture, the account, the way out. -->
<div v-if="user" class="account-row">
<button
type="button"
class="account-open"
:title="t('sidebar.signedInAs', { name: user.username })"
@click="openProfile()"
>
<span class="account-avatar" aria-hidden="true">
<img v-if="user.avatar" :src="user.avatar" alt="" />
<template v-else>{{ initials }}</template>
</span>
<span class="hide-collapsed account-name">{{ user.username }}</span>
</button>
<button
type="button"
class="account-exit hide-collapsed"
:aria-label="t('common.signOut')"
:title="t('common.signOut')"
@click="signOut()"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</svg>
</button>
</div>
<button
v-else
type="button"
class="icon-btn"
:title="t('sidebar.signInHint')"
@click="openSignIn()"
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="8.5" cy="15.5" r="4" />
<path d="m11.6 12.4 8.4-8.4 2 2-2 2 1.5 1.5-2 2-1.5-1.5-1.9 1.9" />
</svg>
<span class="hide-collapsed">{{ t('auth.signin') }}</span>
</button>
<button
type="button"
class="icon-btn"
:aria-label="t('settings.title')"
:title="t('settings.title')"
@click="openSettings()"
>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<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 1 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 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.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 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.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 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.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 class="hide-collapsed">{{ t('settings.title') }}</span>
</button>
<button
type="button"
class="panel-icon-btn sidebar-collapse-btn"
:aria-label="collapsed ? t('sidebar.expand') : t('sidebar.collapse')"
:title="collapsed ? t('sidebar.expand') : t('sidebar.collapse')"
@click="toggleSidebar()"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.collapseLabel') }}</span>
</button>
</div>
</aside>
</template>