mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-08-03 19:17:07 -04:00
540 lines
21 KiB
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"> </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>
|