Files
mathew/frontend/app/components/AppHeader.vue
Aran Roig e36fef7290
All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
Calc2
2026-10-02 02:14:29 +02:00

239 lines
11 KiB
Vue

<script setup lang="ts">
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
const { show: openSearch } = useSearch()
const { show: openSettings } = useSettings()
const { tabs, lastIdentity } = useArticleTabs()
const { user, isAdmin, signOut } = useAuth()
const { pending: waitingRequests, refresh: refreshPending } = usePendingRequests()
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, a missing article) and every article you
have open comes back into view, stacked as you left it. In view only when the
route names one of the open pages: a missing article reads like the reader
but holds none of them, so the button still turns back from there. */
const inReader = computed(() => tabAt(tabs.value, 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.identity === lastIdentity.value) ?? tabs.value[tabs.value.length - 1]
if (target) void router.push(tabHref(target))
}
/* How many requests are waiting is an admin's business: ask once the session
is known to be one — and when a session stops being one, the door goes blank
with the links — and again on every navigation, so the number on the door is
no older than the page under it. While an admin decides at the desk, the
desk itself keeps the count exact (see usePendingRequests). */
watch(
isAdmin,
() => {
if (import.meta.client) void refreshPending()
},
{ immediate: true }
)
watch(
() => route.path,
() => {
if (import.meta.client && isAdmin.value) void refreshPending()
}
)
/* the shortcut hint beside the search bar — refined on the client to the
device's chord ("/" opens the popup too, just not while a field holds
the caret) */
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 to="/calculator" class="nav-link" :title="t('sidebar.calculatorHint')">
<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 3v17a1 1 0 0 0 1 1h17" />
<path d="M4.5 16.5c2.6 0 3.4-10.5 6.8-10.5s3.9 7 8.2 7" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.calculator') }}</span>
</NuxtLink>
<NuxtLink
v-if="isAdmin"
to="/admin"
end
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
v-if="isAdmin"
to="/admin/proposals"
class="nav-link sidebar-requests"
:title="waitingRequests ? t('sidebar.requestsWaiting', waitingRequests) : t('sidebar.requestsHint')"
>
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="m9 15 2 2 4-4" />
</svg>
<span class="hide-collapsed">{{ t('sidebar.requests') }}</span>
<span v-if="waitingRequests" class="requests-count">{{ waitingRequests }}</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>