mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-08 15:00:37 -04:00
513 lines
21 KiB
Vue
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"> </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>
|