PronounsPage/routes/profileEditor.vue
2024-06-26 13:59:53 +02:00

712 lines
31 KiB
Vue

<template>
<Page v-if="$config.profile.editorEnabled">
<MustLogin v-if="!$user()" />
<div v-else>
<AdPlaceholder :phkey="['header', null]" />
<div class="mb-3 d-flex justify-content-between flex-column flex-md-row">
<h2 class="text-nowrap">
<Avatar :user="$user()" />
@{{ $user()?.username }}
</h2>
<div>
<nuxt-link :to="/* @ts-ignore */ `/@${$user().username}`" class="btn btn-outline-primary btn-sm">
<Icon v="id-card" />
<T>profile.show</T>
</nuxt-link>
</div>
</div>
<form :class="[saving ? 'saving' : '']" @submit.prevent="save">
<TabsNav
:tabs="['opinions', 'names', $config.pronouns.enabled ? 'pronouns' : undefined, 'description', 'flags', 'links', 'birthday', 'timezone', 'words', 'circle', 'calendar', 'sensitive', $isGranted() ? 'admin' : undefined]"
pills
showheaders
navclass="mb-3 border-bottom-0"
>
<template #admin-header>
<Icon v="user-cog" />
Team section
</template>
<template #admin>
<p class="small text-muted mb-0">
This will be shown on the “Team” page.
If you leave it empty, you won't show up there (for this language version).
You can use a different display name in different language versions.
Please only add yourself here, if you're actually working on <strong>this language version</strong>.
</p>
<div class="form-group">
<label for="teamName">Team page display name:</label>
<input v-model="teamName" class="form-control" name="teamName" maxlength="64">
<PropagateCheckbox v-if="otherProfiles > 0" field="teamName" :before="beforeChanges.teamName" :after="teamName" @change="propagateChanged" />
</div>
<hr>
<p class="small text-muted mb-0">
If you feel that you've contributed to this language version enough to get credited in the footer
(not saying how much that is, that's on you to decide 😉),
then add your name and areas here (in the local language!).
Please only add yourself here, if you're actually working on <strong>this language version</strong>.
The team as a whole will be credited in the footer either way.
</p>
<div class="form-group">
<label for="footerName">Footer display name:</label>
<input v-model="footerName" class="form-control" name="footerName" maxlength="64">
<PropagateCheckbox v-if="otherProfiles > 0" field="footerName" :before="beforeChanges.footerName" :after="footerName" @change="propagateChanged" />
</div>
<div class="form-group">
<label for="footerAreas">Areas responsible for / contributing to:</label>
<ListInput v-model="footerAreas" />
</div>
<template v-if="$te('contact.team.credentials')">
<hr>
<p class="small text-muted mb-0">
This will be displayed on the team page in the "Credentials" section.
You might want to put here your full legal name here, but it's not required
(you can leave this field empty).
</p>
<div class="form-group">
<label for="credentials">Credentials:</label>
<ListInput v-model="credentials" />
</div>
<div class="form-group">
<label for="credentials">Credentials level:</label>
<select v-model="credentialsLevel" class="form-select">
<option :value="null"></option>
<option :value="1">
Higher education, but irrelevant field
</option>
<option :value="2">
Bachelor (not completed yet)
</option>
<option :value="3">
Bachelor
</option>
<option :value="4">
Master (not completed yet)
</option>
<option :value="5">
Master
</option>
<option :value="6">
PhD (not completed yet)
</option>
<option :value="7">
PhD
</option>
</select>
</div>
<div class="form-group">
<label for="credentials">Name for credentials:</label>
<input v-model="credentialsName" class="form-control" placeholder="(not required)">
</div>
</template>
</template>
<template #opinions-header>
<Icon v="comment-smile" />
<T>profile.opinions.header</T>
</template>
<template #opinions>
<LegendOpinionListInput v-model="defaultOpinions" readonly class="mb-0" />
<LegendOpinionListInput v-model="opinions" :maxitems="10" />
</template>
<template #names-header>
<Icon v="signature" />
<T>profile.names</T>
</template>
<template #names>
<p v-if="$te('profile.namesInfo')" class="small text-muted">
<T>profile.namesInfo</T>
</p>
<OpinionListInput
v-model="names"
:prototype="{ value: '', opinion: 'meh', pronunciation: null }"
:custom-opinions="opinions"
:maxitems="128"
:maxlength="$config.profile.longNames ? 255 : 32"
>
<template #additional="s">
<PronunciationInput v-model="s.val.pronunciation" />
</template>
</OpinionListInput>
<InlineMarkdownInstructions v-model="markdown" />
<PropagateCheckbox v-if="otherProfiles > 0" field="names" :before="beforeChanges.names" :after="names" @change="propagateChanged" />
</template>
<template #pronouns-header>
<Icon v="tags" />
<T>profile.pronouns</T>
</template>
<template #pronouns>
<div v-if="$t('profile.pronounsInfo')" class="alert alert-info">
<p class="small mb-0">
<Icon v="info-circle" />
<T>profile.pronounsInfo</T>
</p>
</div>
<OpinionListInput v-model="pronouns" :validation="validatePronoun" :custom-opinions="opinions" :maxitems="128" :maxlength="192" />
</template>
<template #description-header>
<Icon v="comment-edit" />
<T>profile.description</T>
</template>
<template #description>
<textarea v-model="description" class="form-control form-control-sm" maxlength="1024" rows="8"></textarea>
<InlineMarkdownInstructions v-model="markdown" />
</template>
<template #flags-header>
<Icon v="flag" />
<T>profile.flags</T>
</template>
<template #flags>
<p class="small text-muted mb-0">
<T>profile.flagsInfo</T>
</p>
<ButtonList v-slot="s" v-model="flags" :options="allFlagsOptions">
<Flag
:name="s.desc.split('|')[0]"
:alt="s.desc.split('|')[1]"
:img="`/flags/${s.v}.png`"
:asterisk="allFlags[s.v].asterisk"
/>
</ButtonList>
<PropagateCheckbox v-if="otherProfiles > 0" field="flags" :before="beforeChanges.flags" :after="flags" @change="propagateChanged" />
<details class="form-group border rounded" :open="customFlags.length > 0">
<summary class="px-3 py-2">
<T>profile.flagsCustom</T>
</summary>
<div class="border-top">
<CustomFlagsWidget v-model="customFlags" sizes="flag" :maxitems="128" />
</div>
</details>
<PropagateCheckbox v-if="otherProfiles > 0" field="customFlags" :before="beforeChanges.customFlags" :after="customFlags" @change="propagateChanged" />
<Answer question="flags" small />
</template>
<template #links-header>
<Icon v="link" />
<T>profile.links</T>
</template>
<template #links>
<ListInput v-model="links" :maxitems="128">
<template #default="s">
<input
v-model="s.val"
type="url"
class="form-control"
required
@keyup="s.update(s.val)"
@paste="$nextTick(() => s.update(s.val))"
@change="s.update(s.val)"
>
</template>
<template #validation="s">
<p v-if="s.val && !isValidLink(s.val)" class="small text-danger">
<Icon v="exclamation-triangle" />
<span class="ml-1">{{ $t('crud.validation.invalidLink') }}</span>
</p>
</template>
</ListInput>
<PropagateCheckbox v-if="otherProfiles > 0" field="links" :before="beforeChanges.links" :after="links" @change="propagateChanged" />
<p class="small text-muted mb-0">
<Icon v="ad" />
<T>profile.linksRecommended</T>
<a v-for="provider in recommendedLinkProviders()" :href="provider.homepage" target="_blank" rel="noopener">
<Icon :v="provider.icon" :set="provider.iconSet || 'l'" />
{{ provider.name }}
</a>
<T>profile.linksRecommendedAfter</T>
😉
</p>
<p v-if="$te('profile.linksWarning')" class="small text-muted mt-2 mb-0">
<Icon v="exclamation-triangle" />
<T>profile.linksWarning</T>
</p>
</template>
<template #birthday-header>
<Icon v="birthday-cake" />
<T>profile.birthday</T>
</template>
<template #birthday>
<p class="small text-muted">
<T>profile.birthdayInfo</T>
</p>
<div class="input-group mb-3">
<ClientOnly>
<datepicker
v-model="birthday"
inline
:disabled-dates="disabledDates"
:open-date="disabledDates.from"
:initial-view="birthday !== null ? 'day' : 'year'"
/>
</ClientOnly>
</div>
<PropagateCheckbox v-if="otherProfiles > 0" field="birthday" :before="beforeChanges.birthday" :after="birthday" @change="propagateChanged" />
<button v-if="birthday !== null" type="button" class="btn btn-outline-danger btn-sm" @click="birthday = null">
<Icon v="times" />
<T>crud.remove</T>
</button>
</template>
<template #timezone-header>
<Icon v="clock" />
<T>profile.timezone.header</T>
</template>
<template #timezone>
<p class="small text-muted">
<T>profile.timezone.info</T>
</p>
<TimezoneSelect v-model="timezone" />
<PropagateCheckbox v-if="otherProfiles > 0" field="timezone" :before="beforeChanges.timezone" :after="timezone" @change="propagateChanged" />
</template>
<template #words-header>
<Icon v="scroll-old" />
<T>profile.words</T>
</template>
<template #words>
<template v-for="i in [0, 1, 2, 3]">
<h4 class="h5">
<T>profile.column</T> {{ i + 1 }}
</h4>
<input v-model="words[i].header" class="form-control form-control-sm mb-2" :placeholder="$t('profile.wordsColumnHeader')" maxlength="36">
<OpinionListInput v-model="words[i].values" group="words" :custom-opinions="opinions" :maxitems="128" />
</template>
<button type="button" class="btn btn-outline-warning btn-sm" @click.prevent="resetWords">
<T>profile.editor.defaults</T>
</button>
<InlineMarkdownInstructions v-model="markdown" />
</template>
<template #circle-header>
<Icon v="heart-circle" />
<T>profile.circles.header</T>
</template>
<template #circle>
<p class="small text-muted">
<T>profile.circles.info</T>
</p>
<CircleListInput v-model="circle" :maxitems="16" />
<CircleMentions />
</template>
<template #sensitive-header>
<Icon v="engine-warning" />
<T>profile.sensitive.header</T>
</template>
<template #sensitive>
<p class="small text-muted">
<T>profile.sensitive.info</T>
</p>
<ListInput v-model="sensitive" :maxlength="64" :maxitems="16" />
</template>
<template #calendar-header>
<Icon v="calendar" />
<T>profile.calendar.header</T>
</template>
<template #calendar>
<p class="small text-muted">
<T>profile.calendar.info</T>
</p>
<section class="my-5">
<p class="h5">
<T>profile.calendar.customEvents.header</T><T>quotation.colon</T>
</p>
<PersonalEventListInput v-model="customEvents" :maxitems="100" />
</section>
<section class="my-5">
<p class="h5">
<T>profile.calendar.publicEvents.header</T><T>quotation.colon</T>
</p>
<!-- @vue-ignore -->
<PersonalCalendar
:year="year"
:events="events"
class="my-3"
remove-button
:maxitems="100"
@delete="(d) => events = events.filter(e => e !== d)"
/>
<PropagateCheckbox v-if="otherProfiles > 0" field="events" :before="beforeChanges.events" :after="events" @change="propagateChanged" />
<!-- @vue-ignore -->
<CalendarEventsList :year="year" add-button @add="(e) => events.push(e)" />
</section>
</template>
</TabsNav>
<section>
<button class="btn btn-primary w-100" type="submit">
<Icon v="save" />
<T>profile.editor.save</T>
</button>
</section>
<AdPlaceholder :phkey="['content-1', 'content-mobile-1']" />
</form>
</div>
</Page>
</template>
<script lang="ts">
import { head, buildList, isValidLink } from '../src/helpers.ts';
import { pronouns, pronounLibrary } from '../src/data.ts';
import { buildPronoun } from '../src/buildPronoun.ts';
import { buildFlags } from '../src/flags.ts';
import link from '../plugins/link.ts';
import { birthdateRange, formatDate, parseDate } from '../src/birthdate.ts';
import opinions from '../src/opinions.ts';
import { calendar } from '../src/calendar/calendar.ts';
import type { Config } from '../locale/config.ts';
import type { Translator } from '../src/translator.ts';
import type { Pronoun } from '../src/classes.ts';
import type { Flag } from '../src/flags.ts';
import type { Opinion } from '../src/opinions.ts';
import type { OpinionFormValue, Profile, SaveProfilePayload, WordCategory } from '../src/profile.ts';
interface ProfileFormValue extends Omit<Profile, 'birthday' | 'linksMetadata' | 'verifiedLinks' | 'opinions' |
'card' | 'cardDark' | 'lastUpdate'> {
birthday: Date | null;
opinions: OpinionFormValue[];
}
const defaultWords = (config: Config): WordCategory[] => {
if (!config.profile.enabled || !config.profile.editorEnabled) {
return [];
}
return config.profile.defaultWords.map(({ header, values }) => {
return {
header,
values: values.map((v) => {
return { value: v.replace(/"/g, '\''), opinion: 'meh' };
}),
};
});
};
function coerceWords(words: WordCategory[]): WordCategory[] {
for (let i = 0; i < 4; i++) {
words[i] = words[i] || {
header: null,
values: [],
};
}
return words;
}
function fixArrayObject<T>(arrayObject: Record<string, T> | T[]): T[] {
return Array.isArray(arrayObject) ? arrayObject : Object.values(arrayObject);
}
const opinionsToForm = (
opinions: Record<string, Opinion>,
translator: Translator,
): OpinionFormValue[] => buildList(function*() {
for (const [key, options] of Object.entries(opinions)) {
yield {
key,
icon: options.icon,
description: options.description || translator.get(`profile.opinion.${key}`),
colour: options.colour || '',
style: options.style || '',
};
}
});
const buildProfile = (
profiles: Record<string, Profile>,
config: Config,
translator: Translator,
): ProfileFormValue => {
// card in this locale exists
for (const locale in profiles) {
if (!profiles.hasOwnProperty(locale)) {
continue;
}
if (locale === config.locale) {
const profile = profiles[locale];
return {
names: profile.names,
pronouns: profile.pronouns,
description: profile.description,
birthday: parseDate(profile.birthday),
timezone: profile.timezone,
links: profile.links,
flags: profile.flags,
customFlags: fixArrayObject(profile.customFlags),
words: coerceWords(profile.words),
teamName: profile.teamName,
footerName: profile.footerName,
footerAreas: profile.footerAreas,
credentials: profile.credentials,
credentialsLevel: profile.credentialsLevel,
credentialsName: profile.credentialsName,
opinions: opinionsToForm(profile.opinions || {}, translator),
circle: profile.circle,
sensitive: profile.sensitive,
markdown: profile.markdown,
events: profile.events,
customEvents: profile.customEvents,
};
}
}
// card in this locale doesn't exist yet, but we can copy some non-language-specific fields from another card
for (const locale in profiles) {
if (!profiles.hasOwnProperty(locale)) {
continue;
}
const profile = profiles[locale];
return {
names: profile.names,
pronouns: [],
description: '',
birthday: parseDate(profile.birthday),
timezone: profile.timezone,
links: profile.links,
flags: profile.flags.filter((f) => !f.startsWith('-')),
customFlags: fixArrayObject(profile.customFlags),
words: [...defaultWords(config)],
teamName: profile.teamName,
footerName: profile.footerName,
footerAreas: [],
credentials: [],
credentialsLevel: null,
credentialsName: null,
opinions: opinionsToForm(profile.opinions || {}, translator),
circle: profile.circle,
sensitive: [],
markdown: profile.markdown,
events: [],
customEvents: [],
};
}
// no cards in other languages available, start with a fresh one
return {
names: [],
pronouns: [],
description: '',
birthday: null,
timezone: null,
links: [],
flags: [],
customFlags: [],
words: [...defaultWords(config)],
teamName: '',
footerName: '',
footerAreas: [],
credentials: [],
credentialsLevel: null,
credentialsName: null,
opinions: [],
circle: [],
sensitive: [],
markdown: false,
events: [],
customEvents: [],
};
};
type AsyncData = ProfileFormValue & { beforeChanges: ProfileFormValue, otherProfiles: number } | Record<string, never>;
export default link.extend({
components: {
datepicker: () => {
if (!process.client) {
return null;
}
return import('vuejs-datepicker');
},
},
async asyncData({ app, store }): Promise<AsyncData> {
if (!store.state.user) {
return {};
}
const profiles = (await app.$axios.$get(`/profile/get/${encodeURIComponent(store.state.user.username)}?version=2&props=flags,pronouns,names,age,timezone,links,customFlags,team,opinions,circle&lprops[${app.$config.locale}]=all`, { headers: {
authorization: `Bearer ${store.state.token}`,
} })).profiles;
const profile = buildProfile(profiles, app.$config, app.$translator);
return {
...profile,
beforeChanges: JSON.parse(JSON.stringify(profile)),
otherProfiles: Object.keys(profiles).filter((locale) => locale !== app.$config.locale).length,
};
},
data() {
const bdayRange = birthdateRange(this.$config);
return {
saving: false,
disabledDates: {
to: bdayRange.min,
from: bdayRange.max,
},
propagate: [] as string[],
defaultOpinions: opinionsToForm(opinions, this.$translator),
isValidLink,
year: calendar.getCurrentYear(),
};
},
head() {
return head({
title: this.$t('profile.editor.header'),
}, this.$translator);
},
computed: {
mainPronoun(): Pronoun | null {
if (!this.$config.profile.editorEnabled || !this.$config.profile.flags?.defaultPronoun) {
return null;
}
let mainPronoun = buildPronoun(pronouns, this.$config.profile.flags?.defaultPronoun, this.$config, this.$translator);
let mainOpinion = -1;
for (const { value: pronoun, opinion } of this.pronouns) {
const opinionValue = opinions[opinion]?.value || 0;
if (opinionValue > mainOpinion) {
const p = this.normaliseAndBuildPronoun(pronoun);
if (p) {
mainPronoun = p;
mainOpinion = opinionValue;
}
}
}
return mainPronoun;
},
allFlags(): Record<string, Flag> {
return buildFlags(this.$config.locale);
},
allFlagsOptions(): Record<string, string> {
const entries = Object.entries(this.allFlags).map(([flagName, flag]) => {
return [flagName, `${this.$translateForPronoun(flag.display, this.mainPronoun)}|${flag.display}`];
});
entries.sort((a, b) => a[1].localeCompare(b[1]));
return Object.fromEntries(entries);
},
},
mounted() {
if (process.client && !this.$user()) {
window.sessionStorage.setItem('after-login', window.location.pathname);
this.$router.push(`/${this.$config.user.route}`);
}
},
methods: {
async save(): Promise<void> {
this.saving = true;
try {
await this.$post('/profile/save', {
username: this.$user()!.username,
opinions: this.opinions,
names: this.names,
pronouns: this.pronouns,
description: this.description,
birthday: formatDate(this.birthday),
timezone: this.timezone,
links: [...this.links],
flags: [...this.flags],
customFlags: [...fixArrayObject(this.customFlags)],
words: this.words,
circle: this.circle,
sensitive: this.sensitive,
markdown: this.markdown,
events: this.events,
customEvents: this.customEvents,
teamName: this.teamName,
footerName: this.footerName,
footerAreas: this.footerAreas,
credentials: this.credentials,
credentialsLevel: this.credentialsLevel,
credentialsName: this.credentialsName,
propagate: this.propagate,
} satisfies SaveProfilePayload);
this.$router.push(`/@${this.$user()!.username}`);
} finally {
this.saving = false;
}
},
normalisePronoun(pronoun: string): string | null {
try {
return decodeURIComponent(pronoun
.toLowerCase()
.trim()
.replace(new RegExp(`^${this.$base}`), '')
.replace(new RegExp(`^${this.$base.replace(/^https?:\/\//, '')}`), '')
.replace(new RegExp('^/'), ''));
} catch {
return null;
}
},
normaliseAndBuildPronoun(pronoun: string): Pronoun | null {
return buildPronoun(pronouns, this.normalisePronoun(pronoun), this.$config, this.$translator);
},
validatePronoun(pronoun: string): string | null {
const normalisedPronoun = this.normalisePronoun(pronoun);
if (!normalisedPronoun) {
return 'profile.pronounsNotFound';
}
return this.validateAnyPronoun(normalisedPronoun) ||
this.$config.pronouns.null && this.$config.pronouns.null.routes && this.$config.pronouns.null.routes.includes(normalisedPronoun) ||
this.$config.pronouns.mirror && this.$config.pronouns.mirror.route === normalisedPronoun ||
buildPronoun(pronouns, normalisedPronoun, this.$config, this.$translator)
? null
: 'profile.pronounsNotFound';
},
validateAnyPronoun(pronoun: string): boolean {
const prefix = `${this.$config.pronouns.any}:`;
return pronoun === this.$config.pronouns.any ||
pronoun.startsWith(prefix) && !!pronounLibrary.byKey()[pronoun.substring(prefix.length)];
},
async resetWords(): Promise<void> {
await this.$confirm();
this.words = [...defaultWords(this.$config)];
},
propagateChanged(field: string, checked: boolean): void {
this.propagate = this.propagate.filter((f) => f !== field);
if (checked) {
this.propagate.push(field);
}
},
},
});
</script>
<style lang="scss" scoped>
.avatar {
width: 100%;
max-width: 5rem;
max-height: 5rem;
}
.saving {
opacity: .5;
}
section.form-group {
margin-bottom: 5rem;
}
</style>