Merge branch 'opinions'

This commit is contained in:
Andrea Vos 2022-10-12 18:08:51 +02:00
commit c3175be874
28 changed files with 880 additions and 437 deletions

View File

@ -7,8 +7,6 @@
} }
body[data-theme="dark"] { body[data-theme="dark"] {
$primary-dark: #ff95bb;
background: initial !important; background: initial !important;
background-color: $dark !important; background-color: $dark !important;
color: $light; color: $light;
@ -332,4 +330,12 @@ body[data-theme="dark"] {
background-color: darken($code-color, 30%); background-color: darken($code-color, 30%);
border: 1px solid lighten($code-color, 30%); border: 1px solid lighten($code-color, 30%);
} }
&:not(.reduced-colours) {
@each $name, $value in $colours {
.colour-#{$name} {
color: map-get($value, 'dark') !important;
}
}
}
} }

View File

@ -1,6 +1,6 @@
@use "sass:list"; @use "sass:list";
@import url('https://fonts.googleapis.com/css2?family=Noto+Emoji&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Noto+Emoji:wght@700&display=swap');
@if list.index($fonts, 'Quicksand') { @if list.index($fonts, 'Quicksand') {
/* quicksand-regular - latin-ext_latin */ /* quicksand-regular - latin-ext_latin */

View File

@ -246,3 +246,18 @@ form[disabled] {
border-inline-start: 3px solid $primary; border-inline-start: 3px solid $primary;
padding-inline-start: calc(#{$list-group-item-padding-x} - 2px); padding-inline-start: calc(#{$list-group-item-padding-x} - 2px);
} }
.bold {
font-weight: bold;
}
.italics {
font-style: italic;
}
body:not(.reduced-colours) {
@each $name, $value in $colours {
.colour-#{$name} {
color: map-get($value, 'light') !important;
}
}
}

View File

@ -33,3 +33,14 @@ $square-button-size: 2.2rem;
@import "~bootstrap/scss/utilities"; @import "~bootstrap/scss/utilities";
@import '~@fortawesome/fontawesome-pro/scss/variables'; @import '~@fortawesome/fontawesome-pro/scss/variables';
$primary-dark: #ff95bb;
$colours: (
'pink': ('light': $primary, 'dark': $primary-dark),
'red': ('light': $red, 'dark': $red-200),
'orange': ('light': $orange-600, 'dark': $orange-300),
'green': ('light': $green, 'dark': $green-300),
'blue': ('light': $blue-700, 'dark': $blue-200),
'grey': ('light': $gray-600, 'dark': $gray-300),
);

View File

@ -54,7 +54,7 @@ export default {
}, },
computed: { computed: {
enabled() { enabled() {
return this.config.ads?.enabled; return this.config.ads?.enabled && process.env.NODE_ENV !== 'development';
}, },
visible() { visible() {
return this.enabled && this.consent === undefined; return this.enabled && this.consent === undefined;

View File

@ -0,0 +1,63 @@
<template>
<div class="bg-light border rounded">
<input class="form-control mb-1" v-model="filter" :placeholder="$t('crud.search')" ref="filter"/>
<ul class="list-unstyled icons-list p-2 text-center">
<li v-for="icon in visibleIcons"
class="list-inline-item">
<button class="btn btn-outline-dark border-0 my-2" @click.prevent="$emit('change', icon.name)">
<Icon :v="icon.name"/>
</button>
</li>
<li v-if="!showAll && visibleIcons.length >= displayLimit" class="list-inline-item">
<button class="btn btn-outline-dark border-0 my-2" @click.prevent="showAll = true">
<T>crud.loadAll</T>
</button>
</li>
</ul>
</div>
</template>
<script>
import icons from '../src/icons';
export default {
props: {
styles: { 'default': () => ['light'] },
skipIcons: { 'default': () => [] },
},
data() {
return {
icons,
filter: '',
showAll: false,
displayLimit: 27,
}
},
mounted() {
this.$refs.filter.focus();
},
computed: {
visibleIcons() {
return this.icons.filter(this.matches).slice(0, this.showAll ? undefined : this.displayLimit);
}
},
methods: {
matches(icon) {
return !this.skipIcons.includes(icon.name)
&& icon.styles.filter(v => this.styles.includes(v)).length > 0
&& (
this.filter === ''
|| icon.searchTerms.filter(t => t.includes(this.filter.toLowerCase())).length > 0
)
;
},
},
}
</script>
<style lang="scss" scoped>
.icons-list {
height: 200px;
overflow-y: auto;
}
</style>

View File

@ -0,0 +1,108 @@
<template>
<ListInput v-model="v" :prototype="prototype()" :group="group" :readonly="readonly" :maxlength="maxlength">
<template v-slot="s">
<button type="button" :class="['btn', readonly ? 'btn-light border' : 'btn-outline-secondary', showIconSelector === s.i ? 'btn-secondary text-white border' : '']" :disabled="readonly"
@click="showIconSelector = showIconSelector === s.i ? false : s.i">
<Icon :v="s.val.icon"/>
</button>
<input v-model="s.val.description" class="form-control" :readonly="readonly"
@keyup="s.update(s.val)" @paste="$nextTick(() => s.update(s.val))" @change="s.update(s.val)"
required maxlength="24"
:placeholder="$t('profile.opinions.description')"
/>
<select :class="['form-control', s.val.colour ? 'colour-' + s.val.colour : 'text-muted']" v-model="s.val.colour" @change="s.update(s.val)" :disabled="readonly">
<option v-for="colour in colours" :value="colour">{{$t(`profile.opinions.colours.${colour || '_'}`)}}</option>
</select>
<select :class="['form-control', s.val.style || 'text-muted']" v-model="s.val.style" @change="s.update(s.val)" :disabled="readonly">
<option v-for="st in styles" :value="st">{{$t(`profile.opinions.styles.${st || '_'}`)}}</option>
</select>
<IconSelector v-if="showIconSelector === s.i" class="hanging shadow shadow-lg border"
:skipIcons="skipIcons"
@change="s.update({...s.val, icon: $event}); showIconSelector = false"/>
</template>
<template v-slot:validation="s">
<p v-if="validation(s.val)" class="small text-danger">
<Icon v="exclamation-triangle"/>
<span class="ml-1">{{$t(validation(s.val))}}</span>
</p>
</template>
</ListInput>
</template>
<script>
import { colours, styles } from '../src/styling';
import opinions from '../src/opinions';
export default {
props: {
value: {},
group: {},
readonly: { type: Boolean },
maxlength: { 'default': null },
},
data() {
return {
v: this.value,
showIconSelector: false,
colours,
styles,
skipIcons: [...Object.values(opinions).map(op => op.icon), 'ad', 'helicopter'],
}
},
watch: {
v() { this.$emit('input', this.v); },
value(v) { this.v = v; }
},
methods: {
prototype() {
return {icon: '', description: '', colour: '', style: ''};
},
setIcon(icon) {
this.v.icon = icon;
this.showIconSelector = false;
this.$emit('input', this.v);
},
validation(v) {
if (JSON.stringify(v) === JSON.stringify(this.prototype())) {
return null;
}
if (!v.icon) {
return 'profile.opinions.validation.missingIcon';
}
if (!v.description) {
return 'profile.opinions.validation.missingDescription';
}
if (this.v.filter(el => el.icon === v.icon).length > 1) {
return 'profile.opinions.validation.duplicateIcon';
}
if (this.v.filter(el => el.description === v.description).length > 1) {
return 'profile.opinions.validation.duplicateDescription';
}
return null;
}
},
}
</script>
<style lang="scss" scoped>
@import "assets/variables";
// TODO
select > option.small {
font-size: $small-font-size !important;
}
.hanging {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-width: 500px;
z-index: 5000;
}
</style>

View File

@ -3,22 +3,24 @@
<li v-for="(v, i) in iVal" ref="items"> <li v-for="(v, i) in iVal" ref="items">
<div> <div>
<div class="input-group input-group-sm mb-1"> <div class="input-group input-group-sm mb-1">
<button class="btn btn-light border handle" type="button" :aria-label="$t('table.sort')"> <button :class="['btn', 'btn-light border', readonly ? '' : 'handle']" type="button" :aria-label="$t('table.sort')" :disabled="readonly">
<Icon v="bars"/> <Icon v="bars"/>
</button> </button>
<slot v-bind:val="iVal[i]" v-bind:update="curry(update)(i)"> <slot v-bind:val="iVal[i]" v-bind:update="curry(update)(i)" v-bind:i="i">
<input v-model="iVal[i]" type="text" class="form-control" required/> <input v-model="iVal[i]" type="text" class="form-control" required :readonly="readonly"/>
</slot> </slot>
<button class="btn btn-outline-danger" type="button" @click.prevent="remove(i)" :aria-label="$t('crud.remove')"> <button :class="['btn', readonly ? 'btn-light border' : 'btn-outline-danger']" type="button" @click.prevent="remove(i)" :aria-label="$t('crud.remove')" :disabled="readonly">
<Icon v="times"/> <Icon v="times"/>
</button> </button>
</div> </div>
<slot name="validation" v-bind:val="iVal[i]"></slot> <slot name="validation" v-bind:val="iVal[i]" v-bind:i="i"></slot>
</div> </div>
</li> </li>
<li slot="footer"> <li slot="footer">
<button class="btn btn-outline-success w-100 btn-sm" type="button" @click.prevent="add" :aria-label="$t('crud.add')"> <button v-if="!readonly && (maxlength === null || iVal.length < maxlength)"
class="btn btn-outline-success w-100 btn-sm" type="button"
@click.prevent="add" :aria-label="$t('crud.add')">
<Icon v="plus"/> <Icon v="plus"/>
</button> </button>
</li> </li>
@ -37,6 +39,8 @@
value: {}, value: {},
prototype: { 'default': '' }, prototype: { 'default': '' },
group: {}, group: {},
readonly: { type: Boolean },
maxlength: { 'default': null },
}, },
data() { data() {
return { return {

View File

@ -1,52 +1,48 @@
<template> <template>
<span v-if="op" :class="[ op.style, `colour-${op.colour}`]">
<Tooltip :text="op.description">
<Icon :v="op.icon"/>
</Tooltip>
<Twemoji> <Twemoji>
<span> <nuxt-link v-if="link" :to="link" :class="`colour-${op.colour}`"><Spelling :escape="escape" :text="word"/></nuxt-link>
<strong v-if="opinion === 'yes'">
<Tooltip :text="$t('profile.opinion.yes')">
<Icon v="heart" set="s"/>
</Tooltip>
<nuxt-link v-if="link" :to="link"><Spelling :escape="escape" :text="word"/></nuxt-link>
<span v-else><Spelling :escape="escape" :text="word"/></span> <span v-else><Spelling :escape="escape" :text="word"/></span>
</strong>
<span v-else-if="opinion === 'jokingly'">
<Tooltip :text="$t('profile.opinion.jokingly')">
<Icon v="grin-tongue"/>
</Tooltip>
<nuxt-link v-if="link" :to="link"><Spelling :escape="escape" :text="word"/></nuxt-link>
<span v-else><Spelling :escape="escape" :text="word"/></span>
</span>
<span v-else-if="opinion === 'close'">
<Tooltip :text="$t('profile.opinion.close')">
<Icon v="user-friends"/>
</Tooltip>
<nuxt-link v-if="link" :to="link"><Spelling :escape="escape" :text="word"/></nuxt-link>
<span v-else><Spelling :escape="escape" :text="word"/></span>
</span>
<span v-else-if="opinion === 'meh'">
<Tooltip :text="$t('profile.opinion.meh')">
<Icon v="thumbs-up"/>
</Tooltip>
<nuxt-link v-if="link" :to="link"><Spelling :escape="escape" :text="word"/></nuxt-link>
<span v-else><Spelling :escape="escape" :text="word"/></span>
</span>
<span v-else-if="opinion === 'no'" class="text-muted small">
<Tooltip :text="$t('profile.opinion.no')">
<Icon v="thumbs-down"/>
</Tooltip>
<nuxt-link v-if="link" :to="link"><Spelling :escape="escape" :text="word"/></nuxt-link>
<span v-else><Spelling :escape="escape" :text="word"/></span>
</span>
</span>
</Twemoji> </Twemoji>
</span>
</template> </template>
<script> <script>
import opinions from '../src/opinions';
export default { export default {
props: { props: {
word: { required: true }, word: { required: true },
opinion: { required: true }, opinion: { required: true },
link: {}, link: {},
escape: { type: Boolean, 'default': () => true }, escape: { type: Boolean, 'default': () => true },
customOpinions: { 'default': () => { return {} }},
},
data() {
return {
op: this.findOpinion(),
};
},
methods: {
findOpinion() {
if (opinions.hasOwnProperty(this.opinion)) {
return {
...opinions[this.opinion],
description: this.$t(`profile.opinion.${this.opinion}`),
};
}
for (let op of Object.values(this.customOpinions)) {
if (op.icon === this.opinion) {
return op;
}
}
return null;
}, },
} }
}
</script> </script>

View File

@ -1,29 +1,36 @@
<template> <template>
<div>
<ul class="list-inline small text-muted text-center mx-4"> <ul class="list-inline small text-muted text-center mx-4">
<li class="list-inline-item"> <li v-for="(opinion, key) in opinions" class="list-inline-item">
<Icon v="heart"/> <Icon :v="opinion.icon"/>
= =
<T>profile.opinion.yes</T> <T>profile.opinion.{{key}}</T>
</li>
<li class="list-inline-item">
<Icon v="grin-tongue"/>
=
<T>profile.opinion.jokingly</T>
</li>
<li class="list-inline-item">
<Icon v="user-friends"/>
=
<T>profile.opinion.close</T>
</li>
<li class="list-inline-item">
<Icon v="thumbs-up"/>
=
<T>profile.opinion.meh</T>
</li>
<li class="list-inline-item">
<Icon v="thumbs-down"/>
=
<T>profile.opinion.no</T>
</li> </li>
</ul> </ul>
<ul v-if="Object.keys(custom).length > 0" class="list-inline small text-muted text-center mx-4">
<li class="list-inline-item">
<T>profile.opinions.custom</T>
</li>
<li v-for="(opinion, key) in custom" class="list-inline-item">
<Icon :v="opinion.icon"/>
=
{{opinion.description}}
</li>
</ul>
</div>
</template> </template>
<script>
import opinions from '../src/opinions';
export default {
props: {
custom: { 'default': () => { return {} }},
},
data() {
return {
opinions: opinions,
}
},
}
</script>

View File

@ -1,62 +1,55 @@
<template> <template>
<ListInput v-model="v" :prototype="{value: '', opinion: 'meh'}" :group="group"> <ListInput v-model="v" :prototype="{value: '', opinion: 'meh'}" :group="group">
<template v-slot="s"> <template v-slot="s">
<button type="button" :class="['btn', s.val.opinion === 'yes' ? 'btn-primary' : 'btn-outline-secondary', 'btn-thin']" <button type="button" :class="['btn', 'btn-outline-secondary', showOpinionSelector === s.i ? 'btn-secondary text-white border' : (validate(s.val) ? 'btn-outline-danger' : '')]"
:aria-label="$t('profile.opinion.yes')" @click="showOpinionSelector = showOpinionSelector === s.i ? false : s.i">
@click="s.update({...s.val, value: s.val.value, opinion: 'yes'})"> <Icon :v="getIcon(s.val.opinion)"/>
<Tooltip :text="$t('profile.opinion.yes')">
<Icon v="heart"/>
</Tooltip>
</button> </button>
<button type="button" :class="['btn', s.val.opinion === 'jokingly' ? 'btn-primary' : 'btn-outline-secondary', 'btn-thin']" <input v-model="s.val.value" :class="['form-control', 'mw-input', validate(s.val) ? 'border-danger' : '']" @keyup="s.update(s.val)" required/>
:aria-label="$t('profile.opinion.jokingly')"
@click="s.update({...s.val, value: s.val.value, opinion: 'jokingly'})"> <div v-if="showOpinionSelector === s.i" class="bg-light border rounded hanging shadow shadow-lg">
<Tooltip :text="$t('profile.opinion.jokingly')"> <ul class="list-unstyled icons-list p-1 text-center mb-0">
<Icon v="grin-tongue"/> <li v-for="(opinion, key) in opinions"
</Tooltip> class="list-inline-item">
<button :class="['btn', key === s.val.opinion ? 'btn-dark' : 'btn-outline-dark', 'border-0 my-2']" @click.prevent="s.val.opinion = key; showOpinionSelector = false">
<Icon :v="opinion.icon"/>
</button> </button>
<button type="button" :class="['btn', s.val.opinion === 'close' ? 'btn-primary' : 'btn-outline-secondary', 'btn-thin']" </li>
:aria-label="$t('profile.opinion.close')" </ul>
@click="s.update({...s.val, value: s.val.value, opinion: 'close'})"> <ul v-if="customOpinions.length" class="list-unstyled icons-list p-1 text-center mb-0">
<Tooltip :text="$t('profile.opinion.close')"> <li v-for="opinion in customOpinions"
<Icon v="user-friends"/> class="list-inline-item">
</Tooltip> <button :class="['btn', opinion.icon === s.val.opinion ? 'btn-dark' : 'btn-outline-dark', 'border-0 my-2']" @click.prevent="s.val.opinion = opinion.icon; showOpinionSelector = false">
<Icon :v="opinion.icon"/>
</button> </button>
<button type="button" :class="['btn', s.val.opinion === 'meh' ? 'btn-primary' : 'btn-outline-secondary', 'btn-thin']" </li>
:aria-label="$t('profile.opinion.meh')" </ul>
@click="s.update({...s.val, value: s.val.value, opinion: 'meh'})"> </div>
<Tooltip :text="$t('profile.opinion.meh')">
<Icon v="thumbs-up"/>
</Tooltip>
</button>
<button type="button" :class="['btn', s.val.opinion === 'no' ? 'btn-primary' : 'btn-outline-secondary', 'btn-thin']"
:aria-label="$t('profile.opinion.no')"
@click="s.update({...s.val, value: s.val.value, opinion: 'no'})">
<Tooltip :text="$t('profile.opinion.no')">
<Icon v="thumbs-down"/>
</Tooltip>
</button>
<input v-model="s.val.value" :class="['form-control', 'mw-input', invalid(s.val) ? 'border-danger' : '']" @keyup="s.update(s.val)" required/>
</template> </template>
<template v-slot:validation="s"> <template v-slot:validation="s">
<p v-if="invalid(s.val)" class="small text-danger"> <p v-if="validate(s.val)" class="small text-danger">
<Icon v="exclamation-triangle"/> <Icon v="exclamation-triangle"/>
<span class="ml-1">{{$t(validation(s.val.value))}}</span> <span class="ml-1">{{$t(validate(s.val))}}</span>
</p> </p>
</template> </template>
</ListInput> </ListInput>
</template> </template>
<script> <script>
import opinions from '../src/opinions';
export default { export default {
props: { props: {
value: {}, value: {},
group: {}, group: {},
validation: {}, validation: {},
customOpinions: { 'default': () => { return [] }},
}, },
data() { data() {
return { return {
v: this.value, v: this.value,
showOpinionSelector: false,
opinions,
} }
}, },
watch: { watch: {
@ -64,20 +57,40 @@
value(v) { this.v = v; } value(v) { this.v = v; }
}, },
methods: { methods: {
invalid(val) { validate(val) {
return this.validation && val.value && this.validation(val.value); if (!this.getIcon(val.opinion)) {
return 'profile.opinions.validation.invalidOpinion';
}
if (!val.value) { return null; }
return this.validation && this.validation(val.value);
},
getIcon(opinion) {
if (opinions.hasOwnProperty(opinion)) {
return opinions[opinion].icon;
}
for (let op of this.customOpinions) {
if (op.icon === opinion) {
return opinion;
}
}
return '';
}, },
}, },
} }
</script> </script>
<style lang="scss"> <style lang="scss" scoped>
@import "assets/variables"; @import "assets/variables";
@include media-breakpoint-down('sm', $grid-breakpoints) { .hanging {
.btn-thin { position: absolute;
padding-left: map-get($spacers, 1) !important; top: 100%;
padding-right: map-get($spacers, 1) !important; left: 0;
} width: 100%;
max-width: 300px;
z-index: 5000;
} }
</style> </style>

View File

@ -64,7 +64,7 @@
</h3> </h3>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-for="{value: name, opinion} in profile.names"><Opinion :word="convertName(name)" :opinion="opinion" :escape="false"/></li> <li v-for="{value: name, opinion} in profile.names"><Opinion :word="convertName(name)" :opinion="opinion" :escape="false" :customOpinions="profile.opinions"/></li>
</ul> </ul>
</div> </div>
<div v-if="profile.pronouns.length" :class="['col-6', mainRowCount === 3 ? 'col-lg-4' : 'col-lg-6']"> <div v-if="profile.pronouns.length" :class="['col-6', mainRowCount === 3 ? 'col-lg-4' : 'col-lg-6']">
@ -75,7 +75,7 @@
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-for="{link, pronoun, opinion} in pronounOpinions"> <li v-for="{link, pronoun, opinion} in pronounOpinions">
<Opinion :word="typeof pronoun === 'string' ? pronoun : pronoun.name(glue)" :opinion="opinion" :link="`/${link}`"/> <Opinion :word="typeof pronoun === 'string' ? pronoun : pronoun.name(glue)" :opinion="opinion" :link="`/${link}`" :customOpinions="profile.opinions"/>
</li> </li>
</ul> </ul>
</div> </div>
@ -103,14 +103,14 @@
<div v-for="column in profile.words" v-if="column.values.length" class="col-6 col-lg-3"> <div v-for="column in profile.words" v-if="column.values.length" class="col-6 col-lg-3">
<h4 v-if="column.header" class="h6">{{ column.header }}</h4> <h4 v-if="column.header" class="h6">{{ column.header }}</h4>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li v-for="{value: word, opinion} in column.values"><Opinion :word="word" :opinion="opinion"/></li> <li v-for="{value: word, opinion} in column.values"><Opinion :word="word" :opinion="opinion" :customOpinions="profile.opinions"/></li>
</ul> </ul>
</div> </div>
</div> </div>
</section> </section>
<section> <section>
<OpinionLegend/> <OpinionLegend :custom="profile.opinions"/>
</section> </section>
</div> </div>
</template> </template>

View File

@ -0,0 +1,27 @@
<template>
<label class="form-check form-switch d-inline-block">
<input class="form-check-input" type="checkbox" role="switch" v-model="reducedColours">
<T>mode.reducedColours</T>
</label>
</template>
<script>
export default {
data() {
return {
reducedColours: false,
}
},
mounted() {
if (!process.client) { return; }
this.reducedColours = localStorage.getItem('reducedColours') === 'true';
},
watch: {
reducedColours(v) {
document.body.classList.toggle('reduced-colours', v);
localStorage.setItem('reducedColours', v);
},
}
}
</script>

View File

@ -24,6 +24,7 @@
top: -2.2rem; top: -2.2rem;
left: -50%; left: -50%;
font-weight: normal; font-weight: normal;
font-style: normal;
font-size: .85rem; font-size: .85rem;
white-space: nowrap; white-space: nowrap;
} }

View File

@ -609,6 +609,29 @@ profile:
or (under construction) by a <code>rel="me"</code> tag pointing back to the card. or (under construction) by a <code>rel="me"</code> tag pointing back to the card.
Our links also include a <code>rel="me"</code> tag, so that external websites can verify your card the other way round too. Our links also include a <code>rel="me"</code> tag, so that external websites can verify your card the other way round too.
column: 'Column' column: 'Column'
opinions:
header: 'Legend/opinions'
description: 'Description…'
colours:
_: '(Font colour…)'
pink: 'Pink'
red: 'Red'
orange: 'Orange'
green: 'Green'
blue: 'Blue'
grey: 'Grey'
styles:
_: '(Style…)'
bold: 'Bold'
italics: 'Italics'
small: 'Small'
validation:
missingIcon: 'Icon is required'
missingDescription: 'Description is required'
duplicateIcon: 'Icon must be unique'
duplicateDescription: 'Description must be unique'
invalidOpinion: 'Selected icon was not found in the legend above'
custom: 'custom, added by the user:'
header: 'Cards' header: 'Cards'
list: 'Your cards' list: 'Your cards'
@ -660,6 +683,7 @@ crud:
loginRequired: '{/account=Log in} to submit an entry' loginRequired: '{/account=Log in} to submit an entry'
copy: 'Copy link' copy: 'Copy link'
download: 'Download' download: 'Download'
loadAll: 'Load all…'
footer: footer:
license: > license: >
@ -895,6 +919,7 @@ mode:
light: 'Light mode' light: 'Light mode'
automatic: 'Automatic' automatic: 'Automatic'
dark: 'Dark mode' dark: 'Dark mode'
reducedColours: 'Reduced colours'
ban: ban:
reason: 'Ban reason' reason: 'Ban reason'

View File

@ -702,6 +702,29 @@ profile:
or (under construction) by a <code>rel="me"</code> tag pointing back to the card. or (under construction) by a <code>rel="me"</code> tag pointing back to the card.
Our links also include a <code>rel="me"</code> tag, so that external websites can verify your card the other way round too. Our links also include a <code>rel="me"</code> tag, so that external websites can verify your card the other way round too.
column: 'Column' column: 'Column'
opinions:
header: 'Legend/opinions'
description: 'Description…'
colours:
_: '(Font colour…)'
pink: 'Pink'
red: 'Red'
orange: 'Orange'
green: 'Green'
blue: 'Blue'
grey: 'Grey'
styles:
_: '(Style…)'
bold: 'Bold'
italics: 'Italics'
small: 'Small'
validation:
missingIcon: 'Icon is required'
missingDescription: 'Description is required'
duplicateIcon: 'Icon must be unique'
duplicateDescription: 'Description must be unique'
invalidOpinion: 'Selected icon was not found in the legend above'
custom: 'custom, added by the user:'
header: 'Cards' header: 'Cards'
list: 'Your cards' list: 'Your cards'
@ -756,6 +779,7 @@ crud:
loginRequired: '{/account=Log in} to submit an entry' loginRequired: '{/account=Log in} to submit an entry'
copy: 'Copy link' copy: 'Copy link'
download: 'Download' download: 'Download'
loadAll: 'Load all…'
footer: footer:
license: > license: >
@ -1007,6 +1031,7 @@ mode:
light: 'Light mode' light: 'Light mode'
automatic: 'Automatic' automatic: 'Automatic'
dark: 'Dark mode' dark: 'Dark mode'
reducedColours: 'Reduced colours'
ban: ban:
reason: 'Ban reason' reason: 'Ban reason'

View File

@ -52,4 +52,25 @@ export default [
'user.qr.download', 'user.qr.download',
'footer.stats.month', 'footer.stats.month',
'profile.wordsColumnHeader', 'profile.wordsColumnHeader',
'profile.opinions.header',
'profile.opinions.description',
'profile.opinions.colours._',
'profile.opinions.colours.pink',
'profile.opinions.colours.red',
'profile.opinions.colours.orange',
'profile.opinions.colours.green',
'profile.opinions.colours.blue',
'profile.opinions.colours.grey',
'profile.opinions.styles._',
'profile.opinions.styles.bold',
'profile.opinions.styles.italics',
'profile.opinions.styles.small',
'profile.opinions.validation.missingIcon',
'profile.opinions.validation.missingDescription',
'profile.opinions.validation.duplicateIcon',
'profile.opinions.validation.duplicateDescription',
'profile.opinions.validation.invalidOpinion',
'profile.opinions.custom',
'mode.reducedColours',
'crud.loadAll',
]; ];

View File

@ -1343,6 +1343,29 @@ profile:
albo (ficzer w budowie) poprzez umieszczenie tagu <code>rel="me"</code> wskazującego z powrotem na wizytówkę. albo (ficzer w budowie) poprzez umieszczenie tagu <code>rel="me"</code> wskazującego z powrotem na wizytówkę.
Nasze linki również umieszczają <code>rel="me"</code>, aby zewnętrzne strony mogły potwierdzić wizytówkę również w odwrotną stronę. Nasze linki również umieszczają <code>rel="me"</code>, aby zewnętrzne strony mogły potwierdzić wizytówkę również w odwrotną stronę.
column: 'Kolumna' column: 'Kolumna'
opinions:
header: 'Legenda/opinie'
description: 'Opis…'
colours:
_: '(Kolor fontu…)'
pink: 'Różowy'
red: 'Czerwony'
orange: 'Pomarańczowy'
green: 'Zielony'
blue: 'Niebieski'
grey: 'Szary'
styles:
_: '(Styl tekstu…)'
bold: 'Pogrubiony'
italics: 'Kursywa'
small: 'Mały'
validation:
missingIcon: 'Ikona jest wymagana'
missingDescription: 'Opis jest wymagany'
duplicateIcon: 'Ikony muszą być unikalne'
duplicateDescription: 'Opishy muszą być unikalne'
invalidOpinion: 'Wybrana ikona nie jest dostępna w legendzie powyżej'
custom: 'dodane ręcznie:'
header: 'Wizytówki' header: 'Wizytówki'
list: 'Twoje wizytówki' list: 'Twoje wizytówki'
@ -1463,6 +1486,7 @@ crud:
loginRequired: '{/konto=Zaloguj się}, aby zgłosić wpis' loginRequired: '{/konto=Zaloguj się}, aby zgłosić wpis'
copy: 'Skopiuj link' copy: 'Skopiuj link'
download: 'Ściągnij' download: 'Ściągnij'
loadAll: 'Załaduj wszystko…'
footer: footer:
license: > license: >
@ -1679,6 +1703,7 @@ mode:
light: 'Tryb dzienny' light: 'Tryb dzienny'
automatic: 'Auto' automatic: 'Auto'
dark: 'Tryb nocny' dark: 'Tryb nocny'
reducedColours: 'Zredukowane kolory'
ban: ban:
reason: 'Powód blokady' reason: 'Powód blokady'

View File

@ -0,0 +1,6 @@
-- Up
ALTER TABLE profiles ADD COLUMN opinions TEXT NOT NULL DEFAULT '{}';
-- Down

View File

@ -202,10 +202,15 @@ export default {
config.module.rules.push({ config.module.rules.push({
test: /\.md$/, test: /\.md$/,
use: ['html-loader', 'markdown-loader'] use: ['html-loader', 'markdown-loader']
});
config.module.rules.push({
test: /\.ya?ml$/,
use: 'yaml-loader',
}) })
}, },
}, },
env: { env: {
ENV: process.env.ENV,
BASE_URL: process.env.BASE_URL, BASE_URL: process.env.BASE_URL,
HOME_URL: process.env.HOME_URL || 'https://pronouns.page', HOME_URL: process.env.HOME_URL || 'https://pronouns.page',
TITLE: title, TITLE: title,

View File

@ -65,6 +65,7 @@
"vuedraggable": "^2.24.3", "vuedraggable": "^2.24.3",
"vuejs-datepicker": "^1.6.2", "vuejs-datepicker": "^1.6.2",
"webpack": "^5.0", "webpack": "^5.0",
"yaml-loader": "^0.8.0",
"zh_cn_zh_tw": "^1.0.7" "zh_cn_zh_tw": "^1.0.7"
}, },
"devDependencies": { "devDependencies": {

View File

@ -135,6 +135,9 @@
<Separator icon="heart"/> <Separator icon="heart"/>
<Support/> <Support/>
<div class="text-center my-4 small">
<ReducedColoursSwitch/>
</div>
</template> </template>
</Page> </Page>
<Page v-else-if="user.username"> <Page v-else-if="user.username">

View File

@ -92,7 +92,12 @@
</div> </div>
<section> <section>
<OpinionLegend/> <h3 class="h4">
<Icon v="comment-smile"/>
<T>profile.opinions.header</T>
</h3>
<LegendOpinionListInput v-model="defaultOpinions" readonly class="mb-0"/>
<LegendOpinionListInput v-model="opinions" :maxlength="5"/>
</section> </section>
<section class="form-group"> <section class="form-group">
@ -103,7 +108,7 @@
<p v-if="$te('profile.namesInfo')" class="small text-muted"> <p v-if="$te('profile.namesInfo')" class="small text-muted">
<T>profile.namesInfo</T> <T>profile.namesInfo</T>
</p> </p>
<OpinionListInput v-model="names"/> <OpinionListInput v-model="names" :customOpinions="opinions"/>
<PropagateCheckbox field="names" :before="beforeChanges.names" :after="names" v-if="otherProfiles > 0" @change="propagateChanged"/> <PropagateCheckbox field="names" :before="beforeChanges.names" :after="names" v-if="otherProfiles > 0" @change="propagateChanged"/>
</section> </section>
@ -120,7 +125,7 @@
<T>profile.pronounsInfo</T> <T>profile.pronounsInfo</T>
</p> </p>
</div> </div>
<OpinionListInput v-model="pronouns" :validation="validatePronoun"/> <OpinionListInput v-model="pronouns" :validation="validatePronoun" :customOpinions="opinions"/>
</section> </section>
<AdPlaceholder phkey="main-1"/> <AdPlaceholder phkey="main-1"/>
@ -229,7 +234,7 @@
<T>profile.column</T> {{i + 1}} <T>profile.column</T> {{i + 1}}
</h4> </h4>
<input v-model="words[i].header" class="form-control form-control-sm mb-2" :placeholder="$t('profile.wordsColumnHeader')" maxlength="36"/> <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"/> <OpinionListInput v-model="words[i].values" group="words" :customOpinions="opinions"/>
</template> </template>
</section> </section>
@ -254,6 +259,7 @@
import link from '../plugins/link'; import link from '../plugins/link';
import {minBirthdate, maxBirthdate, formatDate} from '../src/birthdate'; import {minBirthdate, maxBirthdate, formatDate} from '../src/birthdate';
import opinions from '../src/opinions'; import opinions from '../src/opinions';
import t from '../src/translator';``
const defaultWords = config.profile.defaultWords.map(({header, values}) => { const defaultWords = config.profile.defaultWords.map(({header, values}) => {
return { return {
@ -276,6 +282,18 @@
return Array.isArray(arrayObject) ? arrayObject : Object.values(arrayObject); return Array.isArray(arrayObject) ? arrayObject : Object.values(arrayObject);
} }
const opinionsToForm = (opinions) => buildList(function*() {
for (let [key, options] of Object.entries(opinions)) {
yield {
key,
icon: options.icon,
description: options.description || t.get(`profile.opinion.${key}`),
colour: options.colour || '',
style: options.style || '',
};
}
})
const buildProfile = (profiles, currentLocale) => { const buildProfile = (profiles, currentLocale) => {
for (let locale in profiles) { for (let locale in profiles) {
if (!profiles.hasOwnProperty(locale)) { if (!profiles.hasOwnProperty(locale)) {
@ -298,6 +316,7 @@
credentials: profile.credentials, credentials: profile.credentials,
credentialsLevel: profile.credentialsLevel, credentialsLevel: profile.credentialsLevel,
credentialsName: profile.credentialsName, credentialsName: profile.credentialsName,
opinions: opinionsToForm(profile.opinions || {}),
}; };
} }
} }
@ -322,6 +341,7 @@
credentials: [], credentials: [],
credentialsLevel: null, credentialsLevel: null,
credentialsName: null, credentialsName: null,
opinions: opinionsToForm(profile.opinions || {}),
}; };
} }
@ -340,6 +360,7 @@
credentials: [], credentials: [],
credentialsLevel: null, credentialsLevel: null,
credentialsName: null, credentialsName: null,
opinions: [],
}; };
}; };
@ -354,6 +375,7 @@
}, },
propagate: [], propagate: [],
flagsAsterisk: process.env.FLAGS_ASTERISK, flagsAsterisk: process.env.FLAGS_ASTERISK,
defaultOpinions: opinionsToForm(opinions),
}; };
}, },
async asyncData({ app, store }) { async asyncData({ app, store }) {
@ -384,6 +406,7 @@
this.saving = true; this.saving = true;
try { try {
await this.$post(`/profile/save`, { await this.$post(`/profile/save`, {
opinions: this.opinions,
names: this.names, names: this.names,
pronouns: this.pronouns, pronouns: this.pronouns,
description: this.description, description: this.description,

View File

@ -9,6 +9,7 @@ import fs from 'fs';
import { minBirthdate, maxBirthdate, formatDate, parseDate } from '../../src/birthdate'; import { minBirthdate, maxBirthdate, formatDate, parseDate } from '../../src/birthdate';
import {socialProviders} from "../../src/socialProviders"; import {socialProviders} from "../../src/socialProviders";
import {downgradeToV1, upgradeToV2} from "../profileV2"; import {downgradeToV1, upgradeToV2} from "../profileV2";
import { colours, styles } from '../../src/styling';
const normalise = s => s.trim().toLowerCase(); const normalise = s => s.trim().toLowerCase();
@ -66,6 +67,7 @@ const fetchProfiles = async (db, username, self) => {
for (let profile of profiles) { for (let profile of profiles) {
const links = JSON.parse(profile.links); const links = JSON.parse(profile.links);
p[profile.locale] = { p[profile.locale] = {
opinions: JSON.parse(profile.opinions),
names: JSON.parse(profile.names), names: JSON.parse(profile.names),
pronouns: JSON.parse(profile.pronouns), pronouns: JSON.parse(profile.pronouns),
description: profile.description, description: profile.description,
@ -102,6 +104,7 @@ function* isSuspicious(profile) {
JSON.stringify(profile.pronouns), JSON.stringify(profile.pronouns),
JSON.stringify(profile.names), JSON.stringify(profile.names),
JSON.stringify(profile.words), JSON.stringify(profile.words),
JSON.stringify(profile.opinions),
]) { ]) {
s = s.toLowerCase().replace(/\s+/g, ' '); s = s.toLowerCase().replace(/\s+/g, ' ');
for (let sus of susRegexes) { for (let sus of susRegexes) {
@ -173,6 +176,22 @@ const sanitiseBirthday = (bd) => {
return formatDate(bd); return formatDate(bd);
} }
const cleanupOpinions = (opinions) => {
const cleanOpinions = {}
let i = 0;
for (let opinion of opinions) {
if (!opinion.icon || !opinion.description || i >= 5) { continue; }
cleanOpinions[opinion.icon] = {
icon: opinion.icon,
description: opinion.description.substring(0, 24),
colour: opinion.colour && colours.includes(opinion.colour) ? opinion.colour : undefined,
style: opinion.style && styles.includes(opinion.style) ? opinion.style : undefined,
}
i++;
}
return cleanOpinions;
}
router.post('/profile/save', handleErrorAsync(async (req, res) => { router.post('/profile/save', handleErrorAsync(async (req, res) => {
if (!req.user) { if (!req.user) {
return res.status(401).json({error: 'Unauthorised'}); return res.status(401).json({error: 'Unauthorised'});
@ -188,11 +207,14 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => {
req.body.customFlags = Object.values(req.body.customFlags); req.body.customFlags = Object.values(req.body.customFlags);
} }
const opinions = cleanupOpinions(req.body.opinions);
// TODO just make it a transaction... // TODO just make it a transaction...
const ids = (await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user.id} AND locale = ${global.config.locale}`)).map(row => row.id); const ids = (await req.db.all(SQL`SELECT * FROM profiles WHERE userId = ${req.user.id} AND locale = ${global.config.locale}`)).map(row => row.id);
if (ids.length) { if (ids.length) {
await req.db.get(SQL`UPDATE profiles await req.db.get(SQL`UPDATE profiles
SET SET
opinions = ${JSON.stringify(opinions)},
names = ${JSON.stringify(req.body.names)}, names = ${JSON.stringify(req.body.names)},
pronouns = ${JSON.stringify(req.body.pronouns)}, pronouns = ${JSON.stringify(req.body.pronouns)},
description = ${req.body.description}, description = ${req.body.description},
@ -212,8 +234,8 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => {
WHERE id = ${ids[0]} WHERE id = ${ids[0]}
`); `);
} else { } else {
await req.db.get(SQL`INSERT INTO profiles (id, userId, locale, names, pronouns, description, birthday, links, flags, customFlags, words, active, teamName, footerName, footerAreas) await req.db.get(SQL`INSERT INTO profiles (id, userId, locale, opinions, names, pronouns, description, birthday, links, flags, customFlags, words, active, teamName, footerName, footerAreas)
VALUES (${ulid()}, ${req.user.id}, ${global.config.locale}, ${JSON.stringify(req.body.names)}, ${JSON.stringify(req.body.pronouns)}, VALUES (${ulid()}, ${req.user.id}, ${global.config.locale}, ${JSON.stringify(opinions)}, ${JSON.stringify(req.body.names)}, ${JSON.stringify(req.body.pronouns)},
${req.body.description}, ${sanitiseBirthday(req.body.birthday || null)}, ${JSON.stringify(req.body.links.filter(x => !!x))}, ${JSON.stringify(req.body.flags)}, ${JSON.stringify(req.body.customFlags)}, ${req.body.description}, ${sanitiseBirthday(req.body.birthday || null)}, ${JSON.stringify(req.body.links.filter(x => !!x))}, ${JSON.stringify(req.body.flags)}, ${JSON.stringify(req.body.customFlags)},
${JSON.stringify(req.body.words)}, 1, ${JSON.stringify(req.body.words)}, 1,
${req.isGranted() ? req.body.teamName || null : ''}, ${req.isGranted() ? req.body.teamName || null : ''},

15
src/icons.js Normal file
View File

@ -0,0 +1,15 @@
import iconsMetadata from '@fortawesome/fontawesome-pro/metadata/icons.yml';
const icons = [];
for (let [iconName, iconMetadata] of Object.entries(iconsMetadata)) {
icons.push({
name: iconName,
styles: iconMetadata.styles,
searchTerms: [
...iconMetadata.search.terms.map(t => (t + '').toLowerCase()),
iconName.toLowerCase(),
iconMetadata.label.toLowerCase(),
],
});
}
export default icons;

View File

@ -1,7 +1,7 @@
export default { export default {
yes: { value: 3, bold: true }, yes: { value: 3, icon: 's:heart', emoji: '❤️', colour: 'pink', style: 'bold' },
jokingly: { value: 1 }, jokingly: { value: 1, icon: 'grin-tongue', emoji: '😜', colour: 'orange' },
close: { value: 1 }, close: { value: 1, icon: 'user-friends', emoji: '🫂', colour: 'red' },
meh: { value: 0 }, meh: { value: 0, icon: 'thumbs-up', emoji: '👍' },
no: { value: -3, small: true, color: 'muted' }, no: { value: -3, icon: 'thumbs-down', emoji: '👎', colour: 'grey', style: 'small' },
}; };

2
src/styling.js Normal file
View File

@ -0,0 +1,2 @@
export const colours = ['', 'pink', 'red', 'orange', 'green', 'blue', 'grey'];
export const styles = ['', 'bold', 'italics', 'small'];

View File

@ -5714,6 +5714,11 @@ isstream@~0.1.2:
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
javascript-stringify@^2.0.1:
version "2.1.0"
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
jest-worker@^26.5.0, jest-worker@^26.6.2: jest-worker@^26.5.0, jest-worker@^26.6.2:
version "26.6.2" version "26.6.2"
resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed"
@ -10870,11 +10875,25 @@ yallist@^4.0.0:
resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
yaml-loader@^0.8.0:
version "0.8.0"
resolved "https://registry.yarnpkg.com/yaml-loader/-/yaml-loader-0.8.0.tgz#c839325e3fdee082b3768b2a21fe34fde5d96f61"
integrity sha512-LjeKnTzVBKWiQBeE2L9ssl6WprqaUIxCSNs5tle8PaDydgu3wVFXTbMfsvF2MSErpy9TDVa092n4q6adYwJaWg==
dependencies:
javascript-stringify "^2.0.1"
loader-utils "^2.0.0"
yaml "^2.0.0"
yaml@^1.10.0: yaml@^1.10.0:
version "1.10.0" version "1.10.0"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e"
integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg==
yaml@^2.0.0:
version "2.1.3"
resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.1.3.tgz#9b3a4c8aff9821b696275c79a8bee8399d945207"
integrity sha512-AacA8nRULjKMX2DvWvOAdBZMOfQlypSFkjcOcu9FalllIDJ1kvlREzcdIZmidQUqqeMv7jorHjq2HlLv/+c2lg==
yargs-parser@^11.1.1: yargs-parser@^11.1.1:
version "11.1.1" version "11.1.1"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4"