PronounsPage/pages/profile/[username].vue

540 lines
21 KiB
Vue

<script setup lang="ts">
import { useNuxtApp, useFetch } from 'nuxt/app';
import useConfig from '~/composables/useConfig.ts';
import useMainPronoun from '~/composables/useMainPronoun.ts';
import useSimpleHead from '~/composables/useSimpleHead.ts';
import type { LocaleDescription } from '~/locale/locales.ts';
import type { TermsEntryRaw } from '~/src/classes.ts';
import { longtimeCookieSetting } from '~/src/cookieSettings.ts';
import { loadPronounLibrary } from '~/src/data.ts';
import { getUrlForLocale } from '~/src/domain.ts';
import { buildFlags } from '~/src/flags.ts';
import { sleep } from '~/src/helpers.ts';
import opinions from '~/src/opinions.ts';
import { applyProfileVisibilityRules, type UserWithProfiles, type Profile } from '~/src/profile.ts';
definePageMeta({
translatedPaths: (config) => {
if (!config.profile.enabled) {
return [];
}
return ['/@:username', '/u/:username'];
},
});
const {
$translator: translator,
$translateForPronoun: translateForPronoun,
$user: visitor,
$isGranted: isGranted,
$locales: locales,
$isSafari: isSafari,
} = useNuxtApp();
const route = useRoute();
const router = useRouter();
const config = useConfig();
const pronounLibrary = await loadPronounLibrary(config);
const tokenCookie = useCookie('token', longtimeCookieSetting);
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
const usernameFromRoute = route.params.username as string;
const userAsyncData = useFetch<UserWithProfiles>(`/api/profile/get/${encodeURIComponent(usernameFromRoute)}?version=2&props=pronouns,flags,opinions&lprops[${config.locale}]=all`);
const profile = computed(() => {
if (!userAsyncData.data.value) {
return null;
}
for (const locale in userAsyncData.data.value.profiles) {
if (locale === config.locale) {
return applyProfileVisibilityRules(visitor(), userAsyncData.data.value.profiles[locale], false);
}
}
return null;
});
const username = computed(() => {
if (!userAsyncData.data.value) {
return usernameFromRoute;
}
if (userAsyncData.data.value.username !== usernameFromRoute && import.meta.client) {
history.pushState(
'',
document.title,
`/@${userAsyncData.data.value.username}`,
);
}
return userAsyncData.data.value.username;
});
const { mainPronoun } = useMainPronoun(pronounLibrary, profile, translator);
const flags = buildFlags(config.locale);
useSimpleHead({
title: `@${username.value}`,
description: computed(() => profile.value ? profile.value.description ?? null : null),
banner: `api/banner/@${username.value}.png`,
noindex: true,
keywords: computed(() => profile.value && profile.value.flags
? profile.value.flags.map((flag) => translateForPronoun(flags[flag].display, mainPronoun.value))
: undefined),
}, translator);
await userAsyncData;
const user = userAsyncData.data;
const terms = ref<TermsEntryRaw[]>([]);
const cardMenuVisible = ref(false);
const icsMenuVisible = ref(false);
const cardsEnabled = ref(true);
const contentWarningDismissed = ref(false);
const authenticators = ref<any[] | undefined>();
const showExpiredAuthenticators = ref(false);
const allProfiles = computed((): Record<string, LocaleDescription> => {
return Object.fromEntries(Object.entries(locales).filter(([locale, _]) => {
return user.value?.profiles[locale] !== undefined;
}));
});
const verifiedLinks = computed(() => {
if (!user.value) {
return [];
}
return [...new Set(Object.values(user.value.profiles).map((p) => Object.keys(p.verifiedLinks || {}))
.flat())];
});
const icsLink = computed(() => {
return `${getUrlForLocale(config.locale)}/api/queer-calendar-${config.locale}-@${username.value}.ics`;
});
onMounted(async () => {
if (config.terminology.enabled) {
if (isSafari()) {
await sleep(500); // spread out less important requests, it seems to cause issues on safari
}
terms.value = await $fetch('/api/terms');
}
if (user.value && (isGranted('*') || isGranted('community'))) {
authenticators.value = await $fetch(`/api/admin/authenticators/${user.value.id}`);
}
});
const generateCard = async (dark: boolean) => {
if (!profile.value) {
return;
}
await $fetch(`/api/profile/request-card?dark=${dark ? '1' : '0'}`, { method: 'POST' });
profile.value[dark ? 'cardDark' : 'card'] = '';
startCheckingForCard();
};
const cardCheckHandle = ref<ReturnType<typeof setInterval> | undefined>();
const startCheckingForCard = () => {
if (cardCheckHandle.value || !profile.value || profile.value.card || profile.value.cardDark) {
return;
}
cardCheckHandle.value = setInterval(checkForCard, 3000);
};
watch(profile, () => {
if (import.meta.client) {
startCheckingForCard();
}
});
const checkForCard = async () => {
if (!profile.value) {
return;
}
try {
const card = await $fetch('/api/profile/has-card');
if (card.card !== '' && card.cardDark !== '') {
profile.value.card = card.card;
profile.value.cardDark = card.cardDark;
clearInterval(cardCheckHandle.value);
}
} catch {
clearInterval(cardCheckHandle.value);
}
};
const selectMainPronouns = (profile: Partial<Profile>) => {
if (!profile.pronouns) {
return [];
}
const best = [];
for (const { value: pronoun, opinion } of profile.pronouns) {
const opinionValue = opinions[opinion]?.value;
if (opinionValue !== undefined) {
if (opinionValue >= 0) {
best.push(pronoun);
}
} else {
const customOpinion = profile.opinions?.[opinion];
if (customOpinion !== undefined &&
customOpinion.colour !== 'grey' &&
customOpinion.style !== 'small' &&
!['ban', 'slash'].includes(customOpinion.icon) &&
!(customOpinion.icon || '').endsWith('-slash')
) {
best.push(pronoun);
}
}
}
return best.slice(0, 3);
};
const impersonate = async () => {
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(username.value)}`);
impersonatorCookie.value = tokenCookie.value;
tokenCookie.value = token;
await router.push(`/${config.user.route}`);
setTimeout(() => window.location.reload(), 500);
};
const authenticatorIcon = (type: string) => {
switch (type) {
case 'email':
case 'changedEmail':
return 'envelope';
case 'indieauth':
return 'indieauth.png';
case 'mfa_secret':
case 'mfa_recovery':
return 'mobile';
default:
return `b:${type}`;
}
};
</script>
<template>
<Page v-if="user && profile">
<section v-if="($isGranted('users') || $isGranted('community')) && user.bannedReason">
<div class="alert alert-warning">
<p class="h4">
<Icon v="ban" />
{{ $t('ban.banned') }}
</p>
<p>{{ user.bannedReason }}</p>
<p class="mb-0 small">
<T>ban.appeal</T>
</p>
</div>
</section>
<AdPlaceholder :phkey="['header', null]" />
<div class="position-relative">
<div v-if="profile.sensitive && profile.sensitive.length && !contentWarningDismissed" class="content-warning text-center">
<ContentWarning
:warnings="profile.sensitive"
@dismiss="contentWarningDismissed = true"
/>
</div>
<!--
<MarkSus @hasSus="hasSus = true">
<Profile :user="user" :profile="profile" :terms="terms" :expand-links="hasSus" />
</MarkSus>
-->
<!-- <ClientOnly> -->
<Profile :user="user" :profile="profile" :terms="terms" />
<!-- <template #placeholder>
<div class="text-center" style="min-height: 400px">
<Spinner size="5rem" />
</div>
</template>
</ClientOnly> -->
<div v-if="profile.sensitive && profile.sensitive.length && contentWarningDismissed" class="text-center">
<ContentWarning
:warnings="profile.sensitive"
dismissed
class="small d-inline-block"
@blur="contentWarningDismissed = false"
/>
</div>
</div>
<Separator icon="heart" />
<section>
<ShopifyEmbed />
</section>
<CardsBanner />
<AdPlaceholder :phkey="['content-0', 'content-mobile-0']" />
<template #aside-left>
<ProfileShare :user="user" class="d-none d-xxl-block" :main-pronoun="mainPronoun" />
<AdPlaceholder :phkey="['aside-left', null]" class="d-none d-xxl-block" />
</template>
<template #aside-right>
<div class="row">
<div class="my-2 col-12 col-lg-4 col-xxl-12">
<div v-if="$user()?.username === user.username" class="list-group list-group-flare">
<div class="list-group-item pt-3">
<h5>
<Icon v="user" />
<T>profile.personal.header</T>
</h5>
<small><T>profile.personal.description</T></small>
</div>
<nuxt-link to="/editor" class="list-group-item list-group-item-action list-group-item-hoverable">
<Icon v="edit" />
<T>profile.edit</T>
</nuxt-link>
<template v-if="cardsEnabled">
<a v-if="!cardMenuVisible && !(profile.card === '' || profile.cardDark === '')" href="#" class="list-group-item list-group-item-action list-group-item-hoverable" @click.prevent="cardMenuVisible = true">
<Icon v="id-card" />
<T>profile.card.link</T>
</a>
<div v-else class="list-group-item">
<p class="small">
<Icon v="id-card" />
<T>profile.card.link</T><T>quotation.colon</T>
</p>
<small v-if="profile.card === '' || profile.cardDark === ''">
<Spinner />
<T>profile.card.generating</T>
</small>
<span v-else>
<a
v-if="profile.card"
:href="profile.card"
target="_blank"
rel="noopener"
class="btn btn-success btn-sm mx-1"
>
<Icon v="sun" />
<T>mode.light</T>
</a>
<a
v-if="profile.cardDark"
:href="profile.cardDark"
target="_blank"
rel="noopener"
class="btn btn-success btn-sm mx-1"
>
<Icon v="moon" />
<T>mode.dark</T>
</a>
<hr v-if="profile.card || profile.cardDark">
<small>
<T>profile.card.generate</T><T>quotation.colon</T><br>
<button class="btn btn-outline-success btn-sm" @click="generateCard(false)">
<Icon v="sun" />
<T>mode.light</T>
</button>
<button class="btn btn-outline-success btn-sm" @click="generateCard(true)">
<Icon v="moon" />
<T>mode.dark</T>
</button>
</small>
</span>
</div>
</template>
<template v-if="(profile.events?.length ?? 0) + (profile.customEvents?.length ?? 0) > 0">
<a
v-if="!icsMenuVisible"
href="#"
class="list-group-item list-group-item-action list-group-item-hoverable"
@click.prevent="icsMenuVisible = true"
>
<Icon v="calendar-plus" />
iCalendar
</a>
<div v-else class="list-group-item">
<p class="small">
<Icon v="calendar-plus" />
iCalendar
</p>
<CalendarIcs :url="icsLink" small />
</div>
</template>
</div>
<div v-if="$isGranted('*') || $isGranted('community')" class="list-group list-group-flare mt-3">
<div class="list-group-item pt-3">
<h5>
<Icon v="user-cog" />
<T>admin.header</T>
</h5>
</div>
<a
v-if="$isGranted('*')"
href="#"
class="list-group-item list-group-item-action list-group-item-hoverable small"
@click.prevent="impersonate()"
><Icon v="user-secret" /> Impersonate
</a>
<nuxt-link
v-if="$isGranted('*')"
:to="`/admin/audit-log/${user.username}/${user.id}`"
class="list-group-item list-group-item-action list-group-item-hoverable small"
>
<Icon v="file-search" /> Audit log
</nuxt-link>
<div class="list-group-item pt-3">
<h6>
<Icon v="key" /> Authenticators
</h6>
<Loading :value="authenticators">
<p>
<a v-if="!showExpiredAuthenticators" href="#" @click.prevent="showExpiredAuthenticators = true">
Include expired ones
</a>
</p>
<ul>
<template v-for="authenticator in authenticators">
<li
v-if="showExpiredAuthenticators || authenticator.validUntil === null"
:class="authenticator.validUntil === null ? '' : 'small text-muted'"
>
<Tooltip :text="authenticator.type">
<Icon :v="authenticatorIcon(authenticator.type)" />
</Tooltip>
{{ $datetime($ulidTime(authenticator.id)) }}
<pre><code>{{ authenticator.payload }}</code></pre>
</li>
</template>
</ul>
</Loading>
</div>
</div>
</div>
<AdPlaceholder :phkey="['aside-right', null]" class="d-block d-lg-none d-xxl-block" />
<div class="my-2 col-12 col-lg-4 col-xxl-12">
<div v-if="Object.keys(user.profiles).length > 1" class="list-group list-group-flare">
<div class="list-group-item pt-3">
<h5>
<Icon v="language" />
<T>profile.language.header</T>
</h5>
<small><T :params="{ username: user.username }">profile.language.description</T><T>quotation.colon</T></small>
</div>
<LocaleLink
v-for="(options, locale) in $locales"
v-show="user.profiles[locale] !== undefined"
:key="locale"
:locale="locale"
:link="`/@${user.username}`"
:class="['list-group-item list-group-item-action list-group-item-hoverable small', locale === config.locale ? 'list-group-item-active' : '']"
>
{{ options.name }}
<small v-if="options.extra" class="text-muted">({{ options.extra }})</small>
</LocaleLink>
</div>
</div>
<div class="my-2 col-12 col-lg-4 col-xxl-12">
<ProfileShare :user="user" class="d-xxl-none" :main-pronoun="mainPronoun" />
</div>
</div>
</template>
<template #below>
<Ban :user="user" :profile="profile" />
<div class="text-center my-4 small">
<AccessibilitySettings />
</div>
</template>
</Page>
<Page v-else-if="user?.username">
<div class="my-md-5 pt-md-2">
<h2 class="text-nowrap mb-3">
<Avatar :user="user" />
@{{ username }}
</h2>
<div v-if="Object.values(allProfiles).length" class="list-group">
<LocaleLink
v-for="(options, locale) in allProfiles"
:key="locale"
:locale="locale"
:link="`/@${username}`"
class="list-group-item list-group-item-action list-group-item-hoverable d-flex flex-column flex-md-row justify-content-between"
>
<div class="h3">
{{ options.name }}
<small v-if="options.extra" class="text-muted">({{ options.extra }})</small>
</div>
<div class="d-flex align-items-center">
<ForeignPronoun
v-for="pronoun in selectMainPronouns(user.profiles[locale])"
:key="pronoun"
:pronoun="pronoun"
:locale="locale"
/>
</div>
</LocaleLink>
</div>
<div v-else class="alert alert-info">
<p class="mb-0">
<Icon v="info-circle" />
<T>profile.empty</T>
</p>
</div>
<a v-for="link in verifiedLinks" :href="link" rel="me">&nbsp;</a>
<Ban :user="user" />
</div>
<template #aside-right>
<ProfileShare v-if="Object.keys(user.profiles).length" :user="user" show-qr-start class="aside-home" :main-pronoun="mainPronoun" />
</template>
</Page>
<Page v-else>
<NotFound />
</Page>
</template>
<style lang="scss" scoped>
@import "assets/variables";
$aside-margin: 2 * $spacer;
@include media-breakpoint-up('xxl') {
aside {
position: absolute;
top: 0;
left: calc(100% + #{$aside-margin});
width: min(300px, calc((100vw - #{$container-width}) / 2 - #{$aside-margin}));
}
.aside-home {
margin-top: 164px;
}
}
.list-group-flare > :first-child {
border-top: 3px solid $primary;
}
.content-warning {
position: absolute;
left: -2*$glass-blur;
top: -2*$glass-blur;
width: calc(100% + 4*#{$glass-blur});
height: calc(100% + 4*#{$glass-blur});
background: $gray-400;
z-index: 30;
.alert {
margin: 3*$spacer auto;
display: inline-block;
}
}
</style>