785 lines
33 KiB
Vue

<script setup lang="ts">
import datepicker from '@vuepic/vue-datepicker';
import { useFetch, useNuxtApp } from 'nuxt/app';
import useConfig from '~/composables/useConfig.ts';
import useDark from '~/composables/useDark.ts';
import useDialogue from '~/composables/useDialogue.ts';
import useLinkUtils from '~/composables/useLinkUtils.ts';
import useMainPronoun from '~/composables/useMainPronoun.ts';
import useSimpleHead from '~/composables/useSimpleHead.ts';
import type { Config } from '~/locale/config.ts';
import { birthdateRange, formatDate, parseDate } from '~/src/birthdate.ts';
import { buildPronounUsage } from '~/src/buildPronoun.ts';
import { loadCalendar, loadPronounLibrary } from '~/src/data.ts';
import { getUrlForLocale } from '~/src/domain.ts';
import { buildList, isValidLink } from '~/src/helpers.ts';
import { addPronounInjectionKey } from '~/src/injectionKeys.ts';
import opinions from '~/src/opinions.ts';
import type { Opinion } from '~/src/opinions.ts';
import { ProfileVisibility } from '~/src/profile.ts';
import type { OpinionFormValue, Profile, SaveProfilePayload, UserWithProfiles, WordCategory } from '~/src/profile.ts';
import type { Translator } from '~/src/translator.ts';
import { useMainStore } from '~/store/index.ts';
interface ProfileFormData extends Omit<Profile, 'birthday' | 'linksMetadata' | 'verifiedLinks' | 'opinions' |
'card' | 'cardDark' | 'lastUpdate' | 'id' | 'access'> {
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,
): ProfileFormData => {
// card in this locale exists
for (const locale in profiles) {
if (!Object.hasOwn(profiles, 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,
visibility: profile.visibility,
};
}
}
// 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 (!Object.hasOwn(profiles, 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: [],
visibility: profile.visibility,
};
}
// 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: [],
visibility: ProfileVisibility.Public,
};
};
definePageMeta({
translatedPaths: (config) => {
if (!config.profile.enabled || !config.profile.editorEnabled) {
return [];
}
return ['/editor'];
},
});
const { $translator: translator } = useNuxtApp();
const config = useConfig();
const dialogue = useDialogue();
const { user, token } = storeToRefs(useMainStore());
const pronounLibrary = await loadPronounLibrary(config);
useSimpleHead({
title: translator.translate('profile.editor.header'),
}, translator);
const { isDark } = useDark();
const { recommendedLinkProviders } = useLinkUtils();
provide(addPronounInjectionKey, (pronoun) => {
formData.value.pronouns.push({ value: pronoun, opinion: 'meh' });
});
let profilesData;
if (user.value) {
profilesData = (await useFetch<UserWithProfiles>(`/api/profile/get/${encodeURIComponent(user.value.username)}`, {
params: {
version: 2,
props: 'flags,pronouns,names,age,timezone,links,customFlags,team,opinions,circle',
[`lprops[${config.locale}]`]: 'all',
},
headers: {
authorization: `Bearer ${token.value}`,
},
})).data;
} else {
profilesData = ref({ profiles: {} });
}
const formData = ref(buildProfile(profilesData.value!.profiles, config, translator));
const otherProfiles = Object.keys(profilesData.value!.profiles)
.filter((locale) => locale !== config.locale).length;
const beforeChanges = JSON.parse(JSON.stringify(formData.value));
const { min: minBirthdate, max: maxBirthdate } = birthdateRange(config);
const visibilityIcons = {
[ProfileVisibility.Public]: ['globe-africa'],
[ProfileVisibility.InternalBots]: ['user-shield', 'user-robot'],
[ProfileVisibility.Internal]: ['user-shield'],
} as Record<ProfileVisibility, string[]>;
const year = (await loadCalendar()).getCurrentYear();
const { mainPronoun } = useMainPronoun(pronounLibrary, formData, translator);
const saving = ref(false);
const propagate = ref([] as string[]);
const defaultOpinions = ref(opinionsToForm(opinions, translator));
const router = useRouter();
onMounted(() => {
if (import.meta.client && !user && config.user.route) {
window.sessionStorage.setItem('after-login', window.location.pathname);
router.push(`/${encodeURIComponent(config.user.route)}`);
}
});
const save = async (): Promise<void> => {
if (!user.value) {
return;
}
saving.value = true;
try {
await dialogue.postWithAlertOnError('/api/profile/save', {
username: user.value.username,
opinions: formData.value.opinions,
names: formData.value.names,
pronouns: formData.value.pronouns,
description: formData.value.description,
birthday: formatDate(formData.value.birthday),
timezone: formData.value.timezone,
links: [...formData.value.links],
flags: [...formData.value.flags],
customFlags: [...fixArrayObject(formData.value.customFlags)],
words: formData.value.words,
circle: formData.value.circle,
sensitive: formData.value.sensitive,
markdown: formData.value.markdown,
events: formData.value.events,
customEvents: formData.value.customEvents,
teamName: formData.value.teamName,
footerName: formData.value.footerName,
footerAreas: formData.value.footerAreas,
credentials: formData.value.credentials,
credentialsLevel: formData.value.credentialsLevel,
credentialsName: formData.value.credentialsName,
visibility: formData.value.visibility,
propagate: propagate.value,
} satisfies SaveProfilePayload);
await router.push(`/@${user.value.username}`);
} finally {
saving.value = false;
}
};
const normalisePronoun = (pronoun: string): string | null => {
const baseUrl = getUrlForLocale(config.locale);
try {
return decodeURIComponent(pronoun
.toLowerCase()
.trim()
.replace(new RegExp(`^${baseUrl}`), '')
.replace(new RegExp(`^${baseUrl.replace(/^https?:\/\//, '')}`), '')
.replace(new RegExp('^/'), ''));
} catch {
return null;
}
};
const validatePronoun = (pronoun: string): string | null => {
const normalisedPronoun = normalisePronoun(pronoun);
if (!normalisedPronoun) {
return 'profile.pronounsNotFound';
}
return buildPronounUsage(pronounLibrary, normalisedPronoun, config, translator) !== null
? null
: 'profile.pronounsNotFound';
};
const resetWords = async (): Promise<void> => {
await dialogue.confirm();
formData.value.words = [...defaultWords(config)];
};
const propagateChanged = (field: string, checked: boolean): void => {
propagate.value = propagate.value.filter((f) => f !== field);
if (checked) {
propagate.value.push(field);
}
};
</script>
<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="`/@${user.username}`" class="btn btn-outline-primary btn-sm">
<Icon v="id-card" />
<T>profile.show</T>
</nuxt-link>
</div>
</div>
<PersistentForm v-model="formData" uid="profile" :class="[saving ? 'saving' : '']" @submit.prevent="save">
<TabsNav
:tabs="[
'opinions',
'names',
config.pronouns.enabled ? 'pronouns' : undefined,
'description',
'flags',
'links',
'birthday',
'timezone',
'words',
'circle',
'calendar',
'sensitive',
'visibility',
$isGranted() ? 'admin' : undefined,
]"
pills
showheaders
navclass="mb-3 border-bottom-0"
anchors
>
<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="formData.teamName" class="form-control" name="teamName" maxlength="64">
<PropagateCheckbox v-if="otherProfiles > 0" field="teamName" :before="beforeChanges.teamName" :after="formData.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="formData.footerName" class="form-control" name="footerName" maxlength="64">
<PropagateCheckbox v-if="otherProfiles > 0" field="footerName" :before="beforeChanges.footerName" :after="formData.footerName" @change="propagateChanged" />
</div>
<div class="form-group">
<label for="footerAreas">Areas responsible for / contributing to:</label>
<ListInput v-model="formData.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="formData.credentials" />
</div>
<div class="form-group">
<label for="credentials">Credentials level:</label>
<select v-model="formData.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="formData.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="formData.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="formData.names"
:prototype="{ value: '', opinion: 'meh', pronunciation: null }"
:custom-opinions="formData.opinions"
:maxitems="128"
:maxlength="config.profile.longNames ? 255 : 32"
>
<template #additional="s">
<PronunciationInput v-model="s.val.pronunciation" />
</template>
</OpinionListInput>
<InlineMarkdownInstructions v-model="formData.markdown" />
<PropagateCheckbox v-if="otherProfiles > 0" field="names" :before="beforeChanges.names" :after="formData.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="formData.pronouns" :validation="validatePronoun" :custom-opinions="formData.opinions" :maxitems="128" :maxlength="192" />
<PronounsIndex />
</template>
<template #description-header>
<Icon v="comment-edit" />
<T>profile.description</T>
</template>
<template #description>
<textarea v-model="formData.description" class="form-control form-control-sm" maxlength="1024" rows="8"></textarea>
<InlineMarkdownInstructions v-model="formData.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>
<FlagList v-model="formData.flags" :main-pronoun="mainPronoun" />
<PropagateCheckbox v-if="otherProfiles > 0" field="flags" :before="beforeChanges.flags" :after="formData.flags" @change="propagateChanged" />
<details class="form-group border rounded" :open="formData.customFlags.length > 0">
<summary class="px-3 py-2">
<T>profile.flagsCustom</T>
</summary>
<div class="border-top">
<CustomFlagsWidget v-model="formData.customFlags" sizes="flag" :maxitems="128" />
</div>
</details>
<PropagateCheckbox v-if="otherProfiles > 0" field="customFlags" :before="beforeChanges.customFlags" :after="formData.customFlags" @change="propagateChanged" />
<Answer question="flags" small />
</template>
<template #links-header>
<Icon v="link" />
<T>profile.links</T>
</template>
<template #links>
<ListInput v-model="formData.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="formData.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">
<datepicker
v-model="formData.birthday"
inline
auto-apply
:enable-time-picker="false"
:flow="formData.birthday !== null ? [] : ['year', 'month', 'calendar']"
:start-date="maxBirthdate"
:min-date="minBirthdate"
:max-date="maxBirthdate"
prevent-min-max-navigation
no-today
vertical
:dark="isDark"
:locale="config.locale"
/>
</div>
<PropagateCheckbox v-if="otherProfiles > 0" field="birthday" :before="beforeChanges.birthday" :after="formData.birthday" @change="propagateChanged" />
<button v-if="formData.birthday !== null" type="button" class="btn btn-outline-danger btn-sm" @click="formData.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="formData.timezone" />
<PropagateCheckbox v-if="otherProfiles > 0" field="timezone" :before="beforeChanges.timezone" :after="formData.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="formData.words[i].header" class="form-control form-control-sm mb-2" :placeholder="$t('profile.wordsColumnHeader')" maxlength="36">
<OpinionListInput v-model="formData.words[i].values" group="words" :custom-opinions="formData.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="formData.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="formData.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="formData.sensitive" :maxlength="64" :maxitems="16" />
</template>
<template #visibility-header>
<Icon v="user-secret" />
<T>profile.visibility.header</T>
</template>
<template #visibility>
<ul class="list-unstyled ms-2">
<li v-for="[k, v] in Object.entries(ProfileVisibility).filter(([_, k]) => Number.isInteger(k))" class="mb-2">
<label class="form-check-label">
<input v-model="formData.visibility" type="radio" :value="v" class="form-check-input">
<Icon v-for="icon in visibilityIcons[v as ProfileVisibility]" :v="icon" class="ms-2 " />
<T>profile.visibility.options.{{ k }}.header</T>
<br>
<small class="text-muted"><T>profile.visibility.options.{{ k }}.description</T></small>
</label>
</li>
</ul>
<p class="text-muted small">
<Icon v="info-circle" />
<T>profile.visibility.always</T>
</p>
<PropagateCheckbox v-if="otherProfiles > 0" field="visibility" :before="beforeChanges.visibility" :after="formData.visibility" @change="propagateChanged" />
</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="formData.customEvents" :maxitems="100" />
</section>
<section class="my-5">
<p class="h5">
<T>profile.calendar.publicEvents.header</T><T>quotation.colon</T>
</p>
<PersonalCalendar
:year="year!"
:events="formData.events"
class="my-3"
remove-button
:maxitems="100"
@delete="(d: string) => formData.events = formData.events.filter(e => e !== d)"
/>
<PropagateCheckbox v-if="otherProfiles > 0" field="events" :before="beforeChanges.events" :after="formData.events" @change="propagateChanged" />
<CalendarEventsList
:year="year!"
add-button
@add="(event: string) => formData.events.push(event)"
/>
</section>
</template>
</TabsNav>
<section>
<button class="btn btn-primary w-100" type="submit">
<Icon v="save" />
<T>profile.editor.save</T>
</button>
</section>
</PersistentForm>
</div>
<AdPlaceholder :phkey="['content-1', 'content-mobile-1']" />
</Page>
</template>
<style lang="scss">
@import "assets/variables";
@import "~bootstrap/scss/variables";
@import '~@vuepic/vue-datepicker/dist/main';
.dp__theme_light, .dp__theme_dark {
--dp-primary-color: #{$primary};
--dp-border-color: var(--bs-border-color);
--dp-border-color-hover: var(--bs-border-color);
--dp-border-color-focus: #{$input-focus-border-color};
--dp-font-family: var(--bs-body-font-family);
.input-group-sm & {
--dp-font-size: 0.875rem;
width: auto;
}
}
.input-group-sm .dp__input {
border-radius: var(--bs-border-radius-sm);
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
.dp__input_wrap:has(> .dp__input_focus) {
z-index: 5;
}
.dp__input_focus {
box-shadow: $input-focus-box-shadow;
}
.dp__theme_dark {
--dp-background-color: #333;
}
</style>
<style lang="scss" scoped>
.avatar {
width: 100%;
max-width: 5rem;
max-height: 5rem;
}
.saving {
opacity: .5;
}
section.form-group {
margin-bottom: 5rem;
}
</style>