662 lines
26 KiB
Vue

<script setup lang="ts">
import { DateTime } from 'luxon';
import type { RouteLocationRaw } from 'vue-router';
import useConfig from '../composables/useConfig.ts';
import forbidden from '../src/forbidden.ts';
import type { User } from '../src/user.ts';
import { useMainStore } from '../store/index.ts';
import type SearchDialogue from '~/components/search/SearchDialogue.vue';
import { newDate } from '~/src/helpers.ts';
type HeaderTo = { link: string; name?: never } | { name: string; link?: never };
type HeaderLink = HeaderTo & {
header?: boolean;
avatar?: User | null;
icon: string;
text: string;
textLong?: string;
extra?: (string | null)[];
desktop?: boolean;
mobile?: boolean;
};
const NounsNav = useLocaleComponent('nouns', 'NounsNav');
const { $translator: translator, $isGranted: isGranted } = useNuxtApp();
const config = useConfig();
const hoverItem = ref<HeaderLink | null>(null);
const searchDialogue = useTemplateRef<typeof SearchDialogue>('searchDialogue');
const hamburgerActive = ref(false);
const hamburgerShown = ref(false);
const censusDismissed = ref(false);
const today = newDate();
const { user } = storeToRefs(useMainStore());
const links = computed((): HeaderLink[] => {
// remember to modify ~/server/api/search.get.ts, page kind too
const links: HeaderLink[] = [];
links.push({
header: true,
name: 'index',
icon: 'home',
text: translator.translate('home.header'),
textLong: translator.translate('home.link'),
});
if (config.pronouns.enabled) {
const extra = [];
const prefixes = [...config.pronouns.sentence ? config.pronouns.sentence.prefixes : [], ''];
for (const prefix of prefixes) {
extra.push(`${prefix}/${config.pronouns.any}`);
extra.push(`${prefix}/${config.pronouns.any}:`);
if (config.pronouns.null && config.pronouns.null.routes) {
for (const route of config.pronouns.null.routes) {
extra.push(`${prefix}/${route}`);
}
}
if (config.pronouns.mirror) {
extra.push(`${prefix}/${config.pronouns.mirror.route}`);
}
if (config.pronouns.ask) {
for (const route of config.pronouns.ask.routes) {
extra.push(`pronouns-${route}`);
}
}
}
links.push({
name: 'pronouns',
icon: 'tags',
text: translator.translate('pronouns.header'),
textLong: translator.translate('pronouns.headerLong').replace(/(<([^>]+)>)/ig, ''),
extra,
});
}
if (config.nouns.enabled) {
const extras = [];
for (const subroute of config.nouns.subroutes || []) {
extras.push(`/${subroute}`);
}
links.push({
name: 'nouns',
icon: 'book',
text: translator.translate('nouns.header'),
textLong: translator.translate('nouns.headerLong'),
extra: extras,
});
}
if (config.sources.enabled) {
links.push({
name: 'sources',
icon: 'books',
text: translator.translate('sources.header'),
textLong: translator.translate('sources.headerLong'),
});
}
if (config.faq.enabled && !config.links.split) {
links.push({
name: 'faq',
icon: 'map-marker-question',
text: translator.translate('faq.header'),
textLong: translator.translate('faq.headerLong'),
});
}
if (config.links.enabled) {
links.push({
name: 'links',
icon: 'bookmark',
text: translator.translate('links.header'),
textLong: translator.translate('links.headerLong'),
extra: [
config.links.academicRoute ? `/${config.links.academicRoute}` : '',
config.links.translinguisticsRoute ? `/${config.links.translinguisticsRoute}` : '',
config.links.splitBlog ? '' : 'blog',
config.links.splitBlog ? '' : 'blogEntry',
config.links.splitBlog ? '' : 'blogEntryShortcut',
config.links.mediaRoute ? `/${config.links.mediaRoute}` : '',
config.links.split ? 'faq' : '',
config.english && config.english.enabled ? 'english' : '',
config.links.zine && config.links.zine.enabled ? `/${config.links.zine.route}` : '',
],
});
if (config.links.blog && config.links.splitBlog) {
links.push({
name: 'blog',
icon: 'pen-nib',
text: translator.translate('links.blog'),
extra: ['blogEntry', 'blogEntryShortcut'],
});
}
}
if ((config.terminology.enabled && config.terminology.published) ||
config.calendar?.enabled ||
config.census?.enabled ||
config.inclusive?.enabled ||
config.people?.enabled ||
(config.contact && config.contact.team?.enabled)
) {
const extra = [
config.terminology.enabled && config.terminology.published ? 'terminology' : '',
config.calendar?.enabled ? 'calendar' : '',
config.calendar?.enabled ? 'calendarDay' : '',
config.census.enabled ? 'census' : '',
config.inclusive.enabled ? 'inclusive' : '',
config.names.enabled && config.names.published ? 'names' : '',
config.people.enabled ? 'people' : '',
config.contact.enabled ? 'team' : '',
config.workshops?.enabled ? 'workshops' : '',
];
if (config.community) {
links.push({
link: `/${encodeURIComponent(config.community.route)}`,
icon: 'users',
text: translator.translate('community.header'),
textLong: translator.translate('community.headerLong'),
extra,
});
} else if (config.calendar && config.calendar.enabled) {
links.push({
name: 'calendar',
icon: 'calendar-star',
text: translator.translate('calendar.header'),
textLong: translator.translate('calendar.headerLong'),
extra,
});
}
}
if (config.contact.enabled) {
links.push({
name: 'contact',
icon: 'comment-alt-smile',
text: translator.translate('contact.header'),
});
}
links.push({
link: 'https://shop.pronouns.page',
icon: 'shopping-bag',
text: translator.translate('contact.groups.shop'),
desktop: false,
});
if (config.user.enabled) {
links.push({
name: 'user',
avatar: user.value,
icon: 'user',
text: user.value ? `@${user.value.username}` : translator.translate('user.header'),
textLong: user.value ? `@${user.value.username}` : translator.translate('user.headerLong'),
extra: ['/editor', user.value ? `/@${user.value.username}` : null],
});
if (isGranted('panel') || isGranted('external')) {
links.push({
name: 'admin',
icon: 'user-cog',
text: translator.translate('admin.header'),
textLong: translator.translate('admin.header'),
extra: [
'/admin/users',
'/admin/profiles',
'/admin/timesheets',
'/admin/moderation',
'/admin/abuse-reports',
'/admin/pending-bans',
'/admin/translations/missing',
'/admin/translations/awaiting',
],
});
}
}
return links;
});
const route = useRoute();
const showCensus = computed((): boolean => {
if (!import.meta.client) {
return false;
}
const finished = !!parseInt(window.localStorage.getItem(`census-${config.census.edition}-finished`) || '0');
const dismissed = !!parseInt(window.localStorage.getItem(`census-${config.census.edition}-dismissed`) || '0');
const alreadyIn = route.name === 'census';
const isHomepage = route.name === 'index';
if (!config.census.enabled || !isHomepage && (finished || dismissed) || censusDismissed.value || alreadyIn) {
return false;
}
const start = DateTime.fromISO(config.census.start).toLocal();
const end = DateTime.fromISO(config.census.end).toLocal();
const now = DateTime.utc().setZone(config.format?.timezone ?? 'utc');
return now >= start && now <= end;
});
const buildRoute = (link: HeaderLink): RouteLocationRaw => {
if (link.name !== undefined) {
return { name: link.name };
}
return link.link;
};
const isActiveRoute = (link: HeaderLink) => {
return (route.name !== undefined && route.name === link.name) ||
(route.meta.headerCategory !== undefined && route.meta.headerCategory === link.name) ||
(link.extra && route.name && link.extra.includes((route.name as string).split(':')[0])) ||
(link.extra || []).includes(decodeURIComponent(route.path)) ||
(link.extra || []).filter((x) => x && (
decodeURIComponent(route.path).startsWith(`${x}/`) ||
decodeURIComponent(route.path).startsWith(`${x}:`))).length;
};
onMounted(() => {
document.addEventListener('click', documentClicked);
updateShown();
window.addEventListener('scroll', updateShown);
});
onUnmounted(() => {
document.removeEventListener('click', documentClicked);
document.removeEventListener('scroll', updateShown);
});
const documentClicked = (): void => {
if (hamburgerActive.value) {
hamburgerActive.value = false;
}
};
const updateShown = (): void => {
const st = document.body.scrollTop || document.querySelector('html')!.scrollTop;
hamburgerShown.value = st > 300;
};
const dismissCensus = (): void => {
window.localStorage.setItem(`census-${config.census.edition}-dismissed`, '1');
censusDismissed.value = true;
};
</script>
<template>
<div v-if="config.header" class="mb-lg-4">
<header @mouseleave="hoverItem = null">
<SkipLink />
<div class="d-none d-lg-flex justify-content-between align-items-center flex-row nav-custom btn-group mb-0">
<template v-for="link in links">
<PotentiallyExternalLink
v-if="link.desktop === undefined || link.desktop === true"
:key="link.name ?? link.link"
:to="buildRoute(link)"
:class="`nav-item btn btn-sm ${link.header ? 'nav-header' : ''} ${isActiveRoute(link) ? 'active' : ''} ${link.header ? 'flex-grow-0' : ''}`"
@mouseenter="hoverItem = link"
>
<h1 v-if="link.header" class="text-nowrap">
<Logo flag />
<span class="higher"><T>title</T></span>
</h1>
<template v-else>
<Avatar v-if="link.avatar" :user="link.avatar" dsize="1.6rem" />
<Icon v-else :v="link.icon" :size="1.6" />
<br>
<Spelling class="text-nowrap" :text="link.text" />
</template>
</PotentiallyExternalLink>
</template>
<button type="button" class="nav-item nav-header btn btn-sm position-relative" @click="searchDialogue?.open()">
<span style="position: absolute; right: 0.1em; top: 0.1em; opacity: 0.7; z-index: -1">
<ClientOnly>
<kbd class="bg-light text-dark border">{{ isMac ? '⌘K' : 'Ctrl+K' }}</kbd>
</ClientOnly>
</span>
<Icon v="search" :size="1.6" />
<br>
<T>search.header</T>
</button>
<div class="nav-item flex-grow-0" @mouseenter="hoverItem = null">
<VersionDropdown end />
</div>
</div>
<div class="d-block-force d-lg-none p-4">
<div class="text-center mb-3">
<nuxt-link to="/">
<h1 class="text-nowrap">
<Logo flag class="me-2" /><span class="higher"><T>title</T></span>
</h1>
</nuxt-link>
<AdPlaceholder :phkey="[null, 'header']" />
<VersionDropdown />
</div>
<div class="btn-group-vertical d-flex nav-custom mb-2">
<template v-for="link in links">
<PotentiallyExternalLink
v-if="link.mobile === undefined || link.mobile === true"
:key="link.name ?? link.link"
:to="buildRoute(link)"
:class="`btn btn-sm ${isActiveRoute(link) ? 'active' : ''}`"
>
<Avatar v-if="link.avatar" :user="link.avatar" dsize="1rem" />
<Icon v-else :v="link.icon" />
<Spelling :text="link.textLong || link.text" />
</PotentiallyExternalLink>
</template>
<span class="btn btn-sm" @click="searchDialogue?.open()">
<Icon v="search" />
<T>search.header</T>
</span>
</div>
</div>
<div :class="['hamburger-menu']" :style="`opacity: ${hamburgerShown ? 1 : 0}`">
<button
type="button"
:class="['btn btn-outline-secondary', hamburgerActive ? 'text-bg-secondary' : 'text-bg-light']"
@click="hamburgerActive = !hamburgerActive"
>
<Icon v="bars" />
</button>
<div :class="['bg border p-3 shadow', hamburgerActive ? '' : 'd-none']">
<div class="btn-group-vertical d-flex nav-custom nav-custom-start mb-2">
<template v-for="link in links">
<PotentiallyExternalLink
v-if="link.mobile === undefined || link.mobile === true"
:key="link.name ?? link.link"
:to="buildRoute(link)"
:class="`btn btn-sm ${isActiveRoute(link) ? 'active' : ''}`"
>
<Avatar v-if="link.avatar" :user="link.avatar" dsize="1rem" />
<Icon v-else :v="link.icon" />
<Spelling :text="link.textLong || link.text" />
</PotentiallyExternalLink>
</template>
<button type="button" class="btn btn-sm" @click="searchDialogue?.open()">
<Icon v="search" />
<T>search.header</T>
</button>
</div>
</div>
</div>
<NounsNav
v-if="hoverItem?.name === 'nouns'"
class="mb-0 container py-5 hide-if-empty"
/>
<LinksNav
v-if="hoverItem?.name === 'links'"
class="mb-0 container py-5 hide-if-empty"
/>
<CommunityNav
v-if="config.community && hoverItem?.link === `/${encodeURIComponent(config.community.route)}`"
class="mb-0 container py-5 hide-if-empty"
/>
<AccountSwitch
v-if="hoverItem?.name === 'user'"
class="container py-5 hide-if-empty"
:minimum-count="1"
/>
</header>
<div
v-if="config.locale === 'zh' &&
today < new Date(2022, 0, 1, 0, 0, 0) &&
$route.path === '/'"
class="container"
>
<div class="alert alert-info my-3">
<p class="h4">
<Icon v="people-carry" />
Help needed!
</p>
<p class="mb-0">
If you speak Mandarin
(preferably traditional spelling, since simplified can be generated out of it, but not the other way around)
and you'd like to help us with translating new features of zh.pronouns.page,
please send us an email to <a href="mailto:contact@pronouns.page" target="_blank" rel="noopener">contact@pronouns.page</a>
or DM us on Twitter <a href="https://twitter.com/PronounsPage" target="_blank" rel="noopener">@PronounsPage</a>
😉
</p>
</div>
</div>
<div
v-if="config.locale === 'pl' &&
today < new Date(2022, 3, 12, 19, 0, 0) &&
$route.path === '/'"
class="container"
>
<div class="alert alert-info my-3">
<p>
<Icon v="calendar" />
We <strong>wtorek, 12 kwietnia, o 18:00</strong>
Sybil i Andrea będą gościć w Katedrze Performatyki Uniwersytetu Jagiellońskiego
w rozmowie „W stronę niebinarności. Nowe relacje w języku”.
Zapraszamy do oglądania lajwa!
</p>
<p class="mb-0 text-center">
<a href="https://www.facebook.com/events/711884409825918" target="_blank" rel="noopener" class="btn btn-primary">
<Icon v="play" />
Live/event
</a>
</p>
</div>
</div>
<div
v-if="config.locale === 'en' &&
today < new Date(2024, 8, 1, 0, 0, 0) &&
$route.path === '/'"
class="container"
>
<a href="https://discord.pronouns.page" target="_blank" rel="noopener">
<img :src="`/img/${config.locale}/blog/community-discord-banner.png`" class="w-100 shadow" alt="We've launched a community server! discord.pronouns.page">
</a>
</div>
<div v-if="$locales[config.locale].published === false" class="alert alert-warning mb-0 mx-5 text-center">
<Icon v="exclamation-triangle" />
This language version is still under construction!
</div>
<div v-if="showCensus" class="container position-relative">
<nuxt-link :to="{ name: 'census' }">
<img
:src="`/img/${config.locale}/census/census-banner-horizontal.png`"
alt="Na tle flagi osób niebinarnych (cztery poziome pasy: żółty, biały, fioletowy, czarny) tekst: Trwa trzecia edycja największego badania języka osób niebinarnych! Jakich form używamy? Jak unikamy upłciowionego języka? Jak nasz język rozwija się i zmienia? Wypełnij naszą ankietę, by pomóc nam to zbadać! Niebinarny Spis Powszechny 2023; zaimki.pl/spis"
class="w-100 shadow d-none d-md-block"
>
<img
:src="`/img/${config.locale}/census/census-banner-horizontal-mobile.png`"
alt="Na tle flagi osób niebinarnych (cztery poziome pasy: żółty, biały, fioletowy, czarny) tekst: Trwa trzecia edycja największego badania języka osób niebinarnych! Jakich form używamy? Jak unikamy upłciowionego języka? Jak nasz język rozwija się i zmienia? Wypełnij naszą ankietę, by pomóc nam to zbadać! Niebinarny Spis Powszechny 2023; zaimki.pl/spis"
class="w-100 shadow d-md-none"
>
</nuxt-link>
<a
href="#"
class="position-absolute p-2 bg-primary text-white btn-dismiss"
@click.prevent="dismissCensus"
>
<Icon v="times" />
</a>
</div>
<div
v-if="config.locale === 'pl' &&
today < new Date(2023, 4, 1, 0, 0, 0) &&
['/', '/spis'].includes($route.path)"
class="container position-relative"
>
<nuxt-link to="/blog/spis-2023">
<img
:src="`/img/${config.locale}/census/census-banner-report-horizontal.png`"
alt="Na tle flagi osób niebinarnych (cztery poziome pasy: żółty, biały, fioletowy, czarny) tekst: Przedstawiamy raport z trzeciej edycji największego badania języka osób niebinarnych! Niebinarny Spis Powszechny 2023; zaimki.pl/spis"
class="w-100 shadow d-none d-md-block"
>
<img
:src="`/img/${config.locale}/census/census-banner-report-horizontal-mobile.png`"
alt="Na tle flagi osób niebinarnych (cztery poziome pasy: żółty, biały, fioletowy, czarny) tekst: Niebinarny Spis Powszechny 2023; Przeczytaj raport z badania! zaimki.pl/spis"
class="w-100 shadow d-md-none"
>
</nuxt-link>
</div>
<!-- <div class="container">
<SafariWarning dismissable />
</div> -->
<div v-if="$user() && $user()!.bannedReason" class="alert alert-danger mb-0 container">
<p class="h4 mb-2">
<Icon v="ban" />
<T>ban.header</T>
</p>
<p>
<T>ban.reason</T><T>quotation.colon</T>
{{ $user()!.bannedReason }}
</p>
<p>
<T>ban.termsIntro</T><T>quotation.colon</T>
</p>
<blockquote class="small">
<T>terms.content.content.violations</T>
<template v-for="(violation, i) in forbidden">
<T :class="[$user()!.bannedTerms?.includes(violation) ? 'fw-bold' : '']">terms.content.content.violationsExamples.{{ violation }}</T><template v-if="i !== forbidden.length - 1">
,
</template>
</template>.
</blockquote>
</div>
</div>
<header v-else class="mb-4">
<div class="container">
<h1 class="text-nowrap p-4 mb-0">
<nuxt-link to="/">
<Logo flag class="me-2" /><span class="higher"><T>title</T></span>
</nuxt-link>
</h1>
</div>
</header>
<ClientOnly>
<Teleport to="#teleports">
<SearchDialogue ref="searchDialogue" />
</Teleport>
</ClientOnly>
</template>
<style lang="scss" scoped>
@import "assets/variables";
header {
padding: 0;
width: 100%;
}
@include media-breakpoint-down('lg', $grid-breakpoints) {
h1 {
font-size: 2rem;
}
.nav-custom {
:deep(.btn) {
border: none;
border-inline-start: 1px solid $gray-500;
border-radius: 0;
&:hover, &:focus, &.active {
border-inline-start: 3px solid $primary;
padding-inline-start: calc(#{$btn-padding-x-sm} - 2px);
color: $primary;
}
text-align: left;
}
}
.hamburger-menu {
position: fixed;
top: 0;
left: $spacer;
z-index: 1030;
transition: all .5s ease-in-out;
.bg {
background-color: rgba($white, .9)
}
}
}
.nav-custom-start {
.btn {
border: none;
border-inline-start: 1px solid $gray-500;
border-radius: 0;
&:hover, &:focus, &.active {
border-inline-start: 3px solid $primary;
padding-inline-start: calc(#{$btn-padding-x-sm} - 2px);
color: $primary;
}
text-align: left;
}
}
@include media-breakpoint-up('lg', $grid-breakpoints) {
header {
position: fixed;
z-index: 99;
top: 0;
left: 0;
background-color: rgba($white, .9);
box-shadow: $box-shadow;
}
.nav-custom:not(.nav-custom-start) {
:deep(.nav-item) {
border: none;
border-bottom: 1px solid $gray-500;
border-radius: 0;
&.btn {
&:hover, &:focus, &.active {
border-bottom: 3px solid $primary;
padding-bottom: calc(#{$btn-padding-y-sm} - 2px);
color: $primary;
}
}
height: $header-height;
padding-top: 1.2rem;
&.nav-header {
padding-top: 0.6rem;
}
margin-top: 3px;
&:first-child, &:last-child {
padding-left: 1rem;
padding-right: 1rem;
}
}
}
.hamburger-menu {
display: none;
}
}
h1 {
text-decoration: none;
margin-bottom: .5em;
.higher {
position: relative;
top: -0.1em;
}
}
.btn-dismiss {
top: 0;
right: 0;
opacity: 0.5;
transition: opacity .25s ease-in-out;
&:hover {
opacity: 1;
}
}
.hide-if-empty:empty {
display: none !important;
}
</style>