PronounsPage/routes/profile.vue
2024-09-12 10:11:25 +02:00

513 lines
21 KiB
Vue

<template>
<Page v-if="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.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.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() && $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 + profile.customEvents.length > 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 }}
</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 }}
</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>
<script>
import { defineComponent } from 'vue';
import { useNuxtApp, useFetch } from 'nuxt/app';
import { longtimeCookieSetting } from '../src/cookieSettings.ts';
import { buildFlags } from '../src/flags.ts';
import { sleep } from '../src/helpers.ts';
import opinions from '../src/opinions.ts';
import useConfig from '../composables/useConfig.ts';
import useMainPronoun from '../composables/useMainPronoun.ts';
import useSimpleHead from '../composables/useSimpleHead.ts';
export default defineComponent({
async setup() {
const { $translator: translator, $translateForPronoun: translateForPronoun } = useNuxtApp();
const route = useRoute();
const config = useConfig();
const tokenCookie = useCookie('token', longtimeCookieSetting);
const impersonatorCookie = useCookie('impersonator', longtimeCookieSetting);
const user = useFetch(`/api/profile/get/${encodeURIComponent(route.params.username)}?version=2&props=pronouns,flags,opinions&lprops[${config.locale}]=all`);
const profile = computed(() => {
for (const locale in (user.data.value ?? {}).profiles) {
if (locale === config.locale) {
return user.data.value.profiles[locale];
}
}
return null;
});
const username = computed(() => {
const base = route.params.username;
if (!profile.value) {
return base;
}
if (user.data.value.username !== base && process.client) {
history.pushState(
'',
document.title,
`/@${user.data.value.username}`,
);
}
return user.data.value.username;
});
const { mainPronoun } = useMainPronoun(profile.value, translator);
const flags = buildFlags(config.locale);
useSimpleHead({
title: `@${username.value}`,
description: profile.value ? profile.value.description : null,
banner: `api/banner/@${username.value}.png`,
noindex: true,
keywords: profile.value
? profile.value.flags.map((flag) => translateForPronoun(flags[flag].display, mainPronoun.value))
: undefined,
}, translator);
await user;
return {
config,
tokenCookie,
impersonatorCookie,
mainPronoun,
user: user.data,
username,
profile,
};
},
data() {
return {
terms: [],
cardCheckHandle: null,
cardMenuVisible: false,
icsMenuVisible: false,
cardsEnabled: true,
hasSus: false,
contentWarningDismissed: false,
authenticators: undefined,
showExpiredAuthenticators: false,
};
},
computed: {
allProfiles() {
return Object.fromEntries(Object.entries(this.$locales).filter(([locale, _]) => {
return this.user.profiles[locale] !== undefined;
}));
},
verifiedLinks() {
return [...new Set(Object.values(this.user.profiles).map((p) => Object.keys(p.verifiedLinks || {}))
.flat())];
},
icsLink() {
return `${this.$config.public.baseUrl}/api/queer-calendar-${this.config.locale}-@${this.username}.ics`;
},
},
watch: {
profile() {
if (process.client) {
this.startCheckingForCard();
}
},
},
async mounted() {
if (this.config.terminology.enabled) {
if (this.$isSafari()) {
await sleep(500); // spread out less important requests, it seems to cause issues on safari
}
this.terms = await $fetch('/api/terms');
}
if (this.$isGranted('*') || this.$isGranted('community')) {
this.authenticators = await $fetch(`/api/admin/authenticators/${this.user.id}`);
}
},
methods: {
async generateCard(dark) {
await this.$csrfFetch(`/api/profile/request-card?dark=${dark ? '1' : '0'}`, { method: 'POST' });
this.profile[dark ? 'cardDark' : 'card'] = '';
this.startCheckingForCard();
},
startCheckingForCard() {
if (this.cardCheckHandle || !this.profile || this.profile.card || this.profile.cardDark) {
return;
}
this.cardCheckHandle = setInterval(this.checkForCard, 3000);
},
async checkForCard() {
try {
const card = await $fetch('/api/profile/has-card');
if (card.card !== '' && card.cardDark !== '') {
this.profile.card = card.card;
this.profile.cardDark = card.cardDark;
clearInterval(this.cardCheckHandle);
}
} catch {
clearInterval(this.cardCheckHandle);
}
},
selectMainPronouns(profile) {
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);
},
async impersonate() {
const { token } = await $fetch(`/api/admin/impersonate/${encodeURIComponent(this.username)}`);
this.impersonatorCookie = this.tokenCookie;
this.tokenCookie = token;
await this.$router.push(`/${this.config.user.route}`);
setTimeout(() => window.location.reload(), 500);
},
authenticatorIcon(type) {
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>
<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*$glassBlur;
top: -2*$glassBlur;
width: calc(100% + 4*#{$glassBlur});
height: calc(100% + 4*#{$glassBlur});
background: $gray-400;
z-index: 30;
.alert {
margin: 3*$spacer auto;
display: inline-block;
}
}
</style>