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