659 lines
27 KiB
Vue

<template>
<div v-if="config.header" class="mb-lg-4">
<header @mouseleave="hoverItem = null">
<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>
<span class="nav-item btn btn-sm" @click="searchDialogue?.open()">
<Icon v="search" :size="1.6" />
<br>
<T>search.header</T>
<ClientOnly>
<kbd class="bg-light text-dark border">{{ isMac ? '⌘K' : 'Ctrl+K' }}</kbd>
</ClientOnly>
</span>
<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
:class="['btn btn-outline-secondary', hamburgerActive ? 'text-bg-secondary' : 'text-bg-light']"
@click.stop="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>
<span class="btn btn-sm" @click="searchDialogue?.open()">
<Icon v="search" />
<T>search.header</T>
</span>
</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' && new Date() < 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' && new Date() < 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' && new Date() < 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-local/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-local/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-local/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' && new Date() < 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-local/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-local/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>
<script lang="ts">
import { DateTime } from 'luxon';
import { mapState } from 'pinia';
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';
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;
};
export default defineComponent({
components: { NounsNav: useLocaleComponent('nouns', 'NounsNav') },
setup() {
const hoverItem = ref<HeaderLink | null>(null);
return {
config: useConfig(),
hoverItem,
searchDialogue: useTemplateRef<typeof SearchDialogue>('searchDialogue'),
};
},
data() {
return {
hamburgerActive: false,
hamburgerShown: false,
censusDismissed: false,
forbidden,
};
},
computed: {
...mapState(useMainStore, [
'user',
'darkMode',
]),
links(): HeaderLink[] {
// remember to modify ~/server/api/search.get.ts, page kind too
const links: HeaderLink[] = [];
links.push({
header: true,
name: 'index',
icon: 'home',
text: this.$t('home.header'),
textLong: this.$t('home.link'),
});
if (this.config.pronouns.enabled) {
const extra = ['all'];
const prefixes = [...this.config.pronouns.sentence ? this.config.pronouns.sentence.prefixes : [], ''];
for (const prefix of prefixes) {
extra.push(`${prefix}/${this.config.pronouns.any}`);
extra.push(`${prefix}/${this.config.pronouns.any}:`);
if (this.config.pronouns.null && this.config.pronouns.null.routes) {
for (const route of this.config.pronouns.null.routes) {
extra.push(`${prefix}/${route}`);
}
}
if (this.config.pronouns.mirror) {
extra.push(`${prefix}/${this.config.pronouns.mirror.route}`);
}
if (this.config.pronouns.ask) {
for (const route of this.config.pronouns.ask.routes) {
extra.push(`pronouns-${route}`);
}
}
}
links.push({
name: 'pronouns',
icon: 'tags',
text: this.$t('pronouns.header'),
textLong: this.$t('pronouns.headerLong').replace(/(<([^>]+)>)/ig, ''),
extra,
});
}
if (this.config.nouns.enabled) {
const extras = [];
for (const subroute of this.config.nouns.subroutes || []) {
extras.push(`/${subroute}`);
}
links.push({
name: 'nouns',
icon: 'book',
text: this.$t('nouns.header'),
textLong: this.$t('nouns.headerLong'),
extra: extras,
});
}
if (this.config.sources.enabled) {
links.push({
name: 'sources',
icon: 'books',
text: this.$t('sources.header'),
textLong: this.$t('sources.headerLong'),
});
}
if (this.config.faq.enabled && !this.config.links.split) {
links.push({
name: 'faq',
icon: 'map-marker-question',
text: this.$t('faq.header'),
textLong: this.$t('faq.headerLong'),
});
}
if (this.config.links.enabled) {
links.push({
name: 'links',
icon: 'bookmark',
text: this.$t('links.header'),
textLong: this.$t('links.headerLong'),
extra: [
this.config.links.academicRoute ? `/${this.config.links.academicRoute}` : '',
this.config.links.translinguisticsRoute ? `/${this.config.links.translinguisticsRoute}` : '',
this.config.links.splitBlog ? '' : 'blog',
this.config.links.splitBlog ? '' : 'blogEntry',
this.config.links.splitBlog ? '' : 'blogEntryShortcut',
this.config.links.mediaRoute ? `/${this.config.links.mediaRoute}` : '',
this.config.links.split ? 'faq' : '',
this.config.english && this.config.english.enabled ? 'english' : '',
this.config.links.zine && this.config.links.zine.enabled ? `/${this.config.links.zine.route}` : '',
],
});
if (this.config.links.blog && this.config.links.splitBlog) {
links.push({
name: 'blog',
icon: 'pen-nib',
text: this.$t('links.blog'),
extra: ['blogEntry', 'blogEntryShortcut'],
});
}
}
if (this.config.terminology.enabled && this.config.terminology.published ||
this.config.calendar && this.config.calendar.enabled ||
this.config.census && this.config.census.enabled ||
this.config.inclusive && this.config.inclusive.enabled ||
this.config.people && this.config.people.enabled ||
this.config.contact && this.config.contact.team && this.config.contact.team.enabled
) {
const extra = [
this.config.terminology.enabled && this.config.terminology.published ? 'terminology' : '',
this.config.calendar?.enabled ? 'calendar' : '',
this.config.calendar?.enabled ? 'calendarDay' : '',
this.config.census.enabled ? 'census' : '',
this.config.inclusive.enabled ? 'inclusive' : '',
this.config.names.enabled && this.config.names.published ? 'names' : '',
this.config.people.enabled ? 'people' : '',
this.config.contact.enabled ? 'team' : '',
this.config.workshops?.enabled ? 'workshops' : '',
];
if (this.config.community) {
links.push({
link: `/${encodeURIComponent(this.config.community.route)}`,
icon: 'users',
text: this.$t('community.header'),
textLong: this.$t('community.headerLong'),
extra,
});
} else if (this.config.calendar && this.config.calendar.enabled) {
links.push({
name: 'calendar',
icon: 'calendar-star',
text: this.$t('calendar.header'),
textLong: this.$t('calendar.headerLong'),
extra,
});
}
}
if (this.config.contact.enabled) {
links.push({
name: 'contact',
icon: 'comment-alt-smile',
text: this.$t('contact.header'),
});
}
links.push({
link: 'https://shop.pronouns.page',
icon: 'shopping-bag',
text: this.$t('contact.groups.shop'),
desktop: false,
});
if (this.config.user.enabled) {
const user = this.$user();
links.push({
name: 'user',
avatar: this.$user(),
icon: 'user',
text: this.user ? `@${this.user.username}` : this.$t('user.header'),
textLong: this.user ? `@${this.user.username}` : this.$t('user.headerLong'),
extra: ['/editor', user ? `/@${user.username}` : null],
});
if (this.$isGranted('panel')) {
links.push({
name: 'admin',
icon: 'user-cog',
text: this.$t('admin.header'),
textLong: this.$t('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;
},
showCensus(): boolean {
if (!import.meta.client) {
return false;
}
const finished = !!parseInt(window.localStorage.getItem(`census-${this.config.census.edition}-finished`) || '0');
const dismissed = !!parseInt(window.localStorage.getItem(`census-${this.config.census.edition}-dismissed`) || '0');
const alreadyIn = this.$route.name === 'census';
const isHomepage = this.$route.name === 'index';
if (!this.config.census.enabled || !isHomepage && (finished || dismissed) || this.censusDismissed || alreadyIn) {
return false;
}
const start = DateTime.fromISO(this.config.census.start).toLocal();
const end = DateTime.fromISO(this.config.census.end).toLocal();
const now = DateTime.utc().setZone(this.config.format?.timezone ?? 'utc');
return now >= start && now <= end;
},
},
mounted() {
document.addEventListener('click', this.documentClicked);
this.updateShown();
window.addEventListener('scroll', this.updateShown);
},
unmounted() {
document.removeEventListener('click', this.documentClicked);
document.removeEventListener('scroll', this.updateShown);
},
methods: {
buildRoute(link: HeaderLink): RouteLocationRaw {
if (link.name !== undefined) {
return { name: link.name };
}
return link.link;
},
isActiveRoute(link: HeaderLink) {
return this.$route.name === link.name ||
link.extra && this.$route.name && link.extra.includes((this.$route.name as string).split(':')[0]) ||
(link.extra || []).includes(decodeURIComponent(this.$route.path)) ||
(link.extra || []).filter((x) => x && (
decodeURIComponent(this.$route.path).startsWith(`${x}/`) ||
decodeURIComponent(this.$route.path).startsWith(`${x}:`))).length;
},
documentClicked(): void {
if (this.hamburgerActive) {
this.hamburgerActive = false;
}
},
updateShown(): void {
const st = document.body.scrollTop || document.querySelector('html')!.scrollTop;
this.hamburgerShown = st > 300;
},
dismissCensus(): void {
window.localStorage.setItem(`census-${this.config.census.edition}-dismissed`, '1');
this.censusDismissed = true;
},
},
});
</script>
<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>