#220 [profile] card images

This commit is contained in:
Avris 2021-07-10 16:46:29 +02:00
parent ba5df7e400
commit 9eaf3f8046
25 changed files with 1169 additions and 250 deletions

1
.gitignore vendored
View File

@ -13,6 +13,7 @@
/stats.json
/cache
/static/card
# Created by .ignore support plugin (hsz.mobi)
### Node template

View File

@ -61,15 +61,6 @@ body {
max-width: min(90vw, 920px);
}
@include media-breakpoint-up('lg', $grid-breakpoints) {
body {
margin-top: $header-margin;
}
.sticky-top {
top: $header-height - 1px;
}
}
section {
margin: 2*$spacer 0;
}

182
components/Profile.vue Normal file
View File

@ -0,0 +1,182 @@
<template>
<div>
<div class="mb-3 d-flex justify-content-between flex-column flex-md-row">
<h2 class="text-nowrap">
<Avatar :user="profile"/>
@{{profile.username}}
</h2>
<div class="flex-grow-1 text-lg-end">
<slot></slot>
</div>
</div>
<section v-if="profile.age || profile.description.trim().length || profile.team">
<p v-if="profile.team" class="mb-2">
<nuxt-link :to="`/${config.contact.team.route}`" class="badge bg-primary text-white">
<Icon v="collective-logo.svg" class="inverted"/>
<T>contact.team.member</T>
</nuxt-link>
</p>
<p v-for="line in profile.description.split('\n')" class="mb-1">
<Spelling escape :text="line"/>
</p>
<p v-if="profile.age">
<Icon v="birthday-cake"/>
{{ profile.age }}
</p>
</section>
<section v-if="profile.flags.length || Object.keys(profile.customFlags).length">
<ul class="list-inline">
<li v-for="flag in profile.flags" v-if="allFlags[flag]" class="list-inline-item pr-2">
<Flag :name="flag.startsWith('-') ? allFlags[flag] : $translateForPronoun(allFlags[flag], mainPronoun)"
:alt="allFlags[flag]"
:img="`/flags/${flag}.png`"
:terms="terms || []"/>
</li>
<li v-for="(desc, flag) in profile.customFlags" class="list-inline-item pr-2">
<Flag :name="desc"
:alt="desc"
:img="buildImageUrl(flag, 'flag')"
:terms="terms|| []"
custom/>
</li>
</ul>
</section>
<section v-if="profile.links.length">
<ul class="list-inline">
<li v-for="link in profile.links" class="list-inline-item pr-2">
<ProfileLink :link="link"/>
</li>
</ul>
</section>
<section class="d-flex">
<div class="w-50" v-if="Object.keys(profile.names).length">
<h3>
<Icon v="signature"/>
<T>profile.names</T>
</h3>
<ul class="list-unstyled">
<li v-for="(opinion, name) in profile.names"><Opinion :word="name" :opinion="opinion"/></li>
</ul>
</div>
<div class="w-50" v-if="Object.keys(profile.pronouns).length">
<h3>
<Icon v="tags"/>
<T>profile.pronouns</T>
</h3>
<ul class="list-unstyled">
<li v-for="{link, pronoun, opinion} in pronounOpinions">
<Opinion :word="typeof pronoun === 'string' ? pronoun : (pronoun.name(glue) + (pronoun.smallForm ? '/' + pronoun.morphemes[pronoun.smallForm] : ''))" :opinion="opinion" :link="`/${link}`"/>
</li>
</ul>
</div>
</section>
<section class="clearfix">
<h3>
<Icon v="scroll-old"/>
<T>profile.words</T>
</h3>
<div>
<div v-for="group in profile.words" v-if="Object.keys(profile.words).length" class="float-start w-50 w-md-25">
<ul class="list-unstyled">
<li v-for="(opinion, word) in group"><Opinion :word="word" :opinion="opinion"/></li>
</ul>
</div>
</div>
</section>
<section>
<OpinionLegend/>
</section>
</div>
</template>
<script>
import { pronouns } from "~/src/data";
import { buildPronoun } from "../src/buildPronoun";
export default {
props: {
profile: { required: true },
terms: { 'default': null },
},
data() {
return {
allFlags: process.env.FLAGS,
glue: ' ' + this.$t('pronouns.or') + ' ',
}
},
computed: {
pronounOpinions() {
const pronounOpinions = [];
for (let pronoun in this.profile.pronouns) {
if (!this.profile.pronouns.hasOwnProperty(pronoun)) { continue; }
let link = decodeURIComponent(
pronoun
.trim()
.replace(new RegExp('^' + this.$base), '')
.replace(new RegExp('^' + this.$base.replace(/^https?:\/\//, '')), '')
.replace(new RegExp('^/'), '')
);
if (!link.startsWith(':')) {
link = link.toLowerCase();
}
if (link === this.config.pronouns.any || link === this.config.pronouns.avoiding) {
pronounOpinions.push({
link,
pronoun: link,
opinion: this.profile.pronouns[pronoun],
});
continue;
}
const pronounEntity = buildPronoun(pronouns, link);
if (pronounEntity) {
pronounOpinions.push({
link,
pronoun: pronounEntity,
opinion: this.profile.pronouns[pronoun],
});
}
}
return pronounOpinions;
},
mainPronoun() {
let mainPronoun = buildPronoun(pronouns, this.config.profile.flags.defaultPronoun);
let mainOpinion = -1;
for (let {pronoun, opinion} of this.pronounOpinions) {
if (typeof pronoun === 'string') {
continue;
}
if (opinion === 2) {
opinion = 0.5;
}
if (opinion > mainOpinion) {
mainPronoun = pronoun;
mainOpinion = opinion;
}
}
return mainPronoun;
},
},
};
</script>
<style lang="scss" scoped>
.avatar {
width: 100%;
max-width: 5rem;
max-height: 5rem;
}
</style>

20
layouts/basic.vue Normal file
View File

@ -0,0 +1,20 @@
<template>
<main class="container vh-100">
<Nuxt/>
</main>
</template>
<script>
import dark from "../plugins/dark";
export default {
mixins: [dark],
mounted() {
this.setMode(this.detectDark());
}
}
</script>
<style lang="scss">
@import "assets/style";
</style>

View File

@ -1,5 +1,5 @@
<template>
<div class="d-flex flex-column vh-100">
<div class="d-flex flex-column vh-100 body">
<div class="flex-grow-1">
<Header/>
<main class="container">
@ -56,4 +56,13 @@
<style lang="scss">
@import "assets/style";
@import "~avris-sorter/dist/Sorter.min.css";
@include media-breakpoint-up('lg', $grid-breakpoints) {
.body {
margin-top: $header-margin;
}
.sticky-top {
top: $header-height - 1px;
}
}
</style>

View File

@ -414,6 +414,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: 'Teilen'

View File

@ -497,6 +497,9 @@ profile:
Then you can link to it in your bio or email footer.
Just create an account {/account=here}.
bannerButton: 'Create a card'
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: 'Share'

View File

@ -423,6 +423,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: 'Compartir'

View File

@ -417,6 +417,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: 'Deel'

View File

@ -977,6 +977,9 @@ profile:
Potem możesz do niej podlinkować w swoim bio czy stopce maila.
Wystarczy, że założysz konto {/konto=tutaj}.
bannerButton: 'Stwórz wizytówkę'
card:
link: 'Obrazek'
generating: 'Trwa generowanie obrazka…'
census:
header: 'Spis'

View File

@ -421,6 +421,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: 'Compartilhar'

View File

@ -956,6 +956,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
census:
header: 'Spis'

View File

@ -435,6 +435,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: 'Share'

View File

@ -402,6 +402,10 @@ profile:
# Then you can link to it in your bio or email footer.
# Just create an account {/account=here}.
# bannerButton: 'Create a card'
# TODO
card:
link: 'Card picture'
generating: 'Generation in progress…'
share: '這裡'

5
migrations/029-cards.sql Normal file
View File

@ -0,0 +1,5 @@
-- Up
ALTER TABLE profiles ADD COLUMN card TEXT NULL;
-- Down

View File

@ -230,6 +230,7 @@ export default {
if (config.profile.enabled) {
routes.push({path: '/@*', component: resolve(__dirname, 'routes/profile.vue')});
routes.push({path: '/card/@*', component: resolve(__dirname, 'routes/profileCard.vue')});
if (config.profile.editorEnabled) {
routes.push({path: '/editor', component: resolve(__dirname, 'routes/profileEditor.vue')});
}

View File

@ -34,6 +34,7 @@
"markdown-loader": "^6.0.0",
"multer": "^1.4.2",
"nuxt": "^2.15.2",
"pageres": "^6.2.3",
"rtlcss": "^3.1.2",
"sha1": "^1.1.1",
"sql-template-strings": "^2.2.2",

View File

@ -1,40 +1,5 @@
<template>
<div v-if="profile">
<ClientOnly>
<div slot="placeholder" class="my-5 text-center">
<Spinner size="5rem"/>
</div>
<div class="mb-3 d-flex justify-content-between flex-column flex-md-row">
<h2 class="text-nowrap">
<Avatar :user="profile"/>
@{{username}}
</h2>
<div class="flex-grow-1 text-lg-end">
<div>
<nuxt-link v-if="$user() && $user().username === username" to="/editor"
class="btn btn-outline-primary btn-sm mb-2 mx-1"
>
<Icon v="edit"/>
<T>profile.edit</T>
</nuxt-link>
<a :href="`https://pronouns.page/@${username}`" v-if="Object.keys(profiles).length > 1 && $user() && $user().username === username"
class="btn btn-outline-secondary btn-sm mb-2 mx-1"
>
<Icon v="external-link"/>
pronouns.page/@{{username}}
</a>
</div>
<div v-if="Object.keys(profiles).length > 1">
<LocaleLink v-for="(options, locale) in locales" :key="locale" v-if="profiles[locale] !== undefined"
:locale="locale" :link="`/@${username}`"
:class="['btn', locale === config.locale ? 'btn-primary disabled' : 'btn-outline-primary', 'btn-sm', 'mb-2 mx-1']">
{{options.name}}
</LocaleLink>
</div>
</div>
</div>
<section v-if="$isGranted('users') && profile.bannedReason">
<div class="alert alert-warning">
<p class="h4">
@ -45,91 +10,48 @@
</div>
</section>
<section v-if="profile.age || profile.description.trim().length || profile.team">
<p v-if="profile.team" class="mb-2">
<nuxt-link :to="`/${config.contact.team.route}`" class="badge bg-primary text-white">
<Icon v="collective-logo.svg" class="inverted"/>
<T>contact.team.member</T>
<Profile :profile="profile" :terms="terms">
<div v-if="Object.keys(profiles).length > 1">
<LocaleLink v-for="(options, locale) in locales" :key="locale" v-if="profiles[locale] !== undefined"
:locale="locale" :link="`/@${profile.username}`"
:class="['btn', locale === config.locale ? 'btn-primary disabled' : 'btn-outline-primary', 'btn-sm', 'mb-2 mx-1']">
{{options.name}}
</LocaleLink>
</div>
<div v-if="$user() && $user().username === profile.username">
<nuxt-link to="/editor"
class="btn btn-primary btn-sm mb-2 mx-1"
>
<Icon v="edit"/>
<T>profile.edit</T>
</nuxt-link>
</p>
<p v-for="line in profile.description.split('\n')" class="mb-1">
<Spelling escape :text="line"/>
</p>
<p v-if="profile.age">
<Icon v="birthday-cake"/>
{{ profile.age }}
</p>
</section>
<section v-if="profile.flags.length || Object.keys(profile.customFlags).length">
<ul class="list-inline">
<li v-for="flag in profile.flags" v-if="allFlags[flag]" class="list-inline-item pr-2">
<Flag :name="flag.startsWith('-') ? allFlags[flag] : $translateForPronoun(allFlags[flag], mainPronoun)"
:alt="allFlags[flag]"
:img="`/flags/${flag}.png`"
:terms="terms"/>
</li>
<li v-for="(desc, flag) in profile.customFlags" class="list-inline-item pr-2">
<Flag :name="desc"
:alt="desc"
:img="buildImageUrl(flag, 'flag')"
:terms="terms"
custom/>
</li>
</ul>
</section>
<section v-if="profile.links.length">
<ul class="list-inline">
<li v-for="link in profile.links" class="list-inline-item pr-2">
<ProfileLink :link="link"/>
</li>
</ul>
</section>
<section class="d-flex">
<div class="w-50" v-if="Object.keys(profile.names).length">
<h3>
<Icon v="signature"/>
<T>profile.names</T>
</h3>
<ul class="list-unstyled">
<li v-for="(opinion, name) in profile.names"><Opinion :word="name" :opinion="opinion"/></li>
</ul>
<a :href="`https://pronouns.page/@${profile.username}`" v-if="Object.keys(profiles).length > 1"
class="btn btn-outline-secondary btn-sm mb-2 mx-1"
>
<Icon v="external-link"/>
pronouns.page/@{{profile.username}}
</a>
</div>
<div class="w-50" v-if="Object.keys(profile.pronouns).length">
<h3>
<Icon v="tags"/>
<T>profile.pronouns</T>
</h3>
<ul class="list-unstyled">
<li v-for="{link, pronoun, opinion} in pronounOpinions">
<Opinion :word="typeof pronoun === 'string' ? pronoun : (pronoun.name(glue) + (pronoun.smallForm ? '/' + pronoun.morphemes[pronoun.smallForm] : ''))" :opinion="opinion" :link="`/${link}`"/>
</li>
</ul>
<div v-if="$user() && $user().username === profile.username">
<small>
<Icon v="id-card"/>
<T>profile.card.link</T>:
</small>
<template v-if="profile.card">
<a :href="profile.card" target="_blank" rel="noopener"
class="btn btn-outline-success btn-sm mb-2 mx-1">
<Icon v="sun"/>
<T>mode.light</T>
</a>
<a :href="profile.card.replace('.png', '-dark.png')" target="_blank" rel="noopener"
class="btn btn-outline-success btn-sm mb-2 mx-1">
<Icon v="moon"/>
<T>mode.dark</T>
</a>
</template>
<small v-else><T>profile.card.generating</T></small>
</div>
</section>
<section class="clearfix">
<h3>
<Icon v="scroll-old"/>
<T>profile.words</T>
</h3>
<div>
<div v-for="group in profile.words" v-if="Object.keys(profile.words).length" class="float-start w-50 w-md-25">
<ul class="list-unstyled">
<li v-for="(opinion, word) in group"><Opinion :word="word" :opinion="opinion"/></li>
</ul>
</div>
</div>
</section>
<section>
<OpinionLegend/>
</section>
</Profile>
<client-only>
<section v-if="$isGranted('users')">
@ -148,7 +70,6 @@
<section>
<Share/>
</section>
</ClientOnly>
</div>
<div v-else-if="Object.keys(profiles).length">
<h2 class="text-nowrap mb-3">
@ -170,18 +91,13 @@
</template>
<script>
import {head, listToDict} from "../src/helpers";
import { pronouns } from "~/src/data";
import { buildPronoun } from "../src/buildPronoun";
import { head } from "../src/helpers";
import ClientOnly from 'vue-client-only'
export default {
components: { ClientOnly },
data() {
return {
profiles: {},
glue: ' ' + this.$t('pronouns.or') + ' ',
allFlags: process.env.FLAGS,
saving: false,
terms: [],
}
@ -197,6 +113,15 @@
}
},
computed: {
profile() {
for (let locale in this.profiles) {
if (locale === this.config.locale) {
return this.profiles[locale];
}
}
return null;
},
username() {
const base = this.$route.params.pathMatch;
@ -214,70 +139,6 @@
return this.profile.username;
},
profile() {
for (let locale in this.profiles) {
if (locale === this.config.locale) {
return this.profiles[locale];
}
}
return null;
},
pronounOpinions() {
const pronounOpinions = [];
for (let pronoun in this.profile.pronouns) {
if (!this.profile.pronouns.hasOwnProperty(pronoun)) { continue; }
let link = decodeURIComponent(
pronoun
.trim()
.replace(new RegExp('^' + this.$base), '')
.replace(new RegExp('^' + this.$base.replace(/^https?:\/\//, '')), '')
.replace(new RegExp('^/'), '')
);
if (!link.startsWith(':')) {
link = link.toLowerCase();
}
if (link === this.config.pronouns.any || link === this.config.pronouns.avoiding) {
pronounOpinions.push({
link,
pronoun: link,
opinion: this.profile.pronouns[pronoun],
});
continue;
}
const pronounEntity = buildPronoun(pronouns, link);
if (pronounEntity) {
pronounOpinions.push({
link,
pronoun: pronounEntity,
opinion: this.profile.pronouns[pronoun],
});
}
}
return pronounOpinions;
},
mainPronoun() {
let mainPronoun = buildPronoun(pronouns, this.config.profile.flags.defaultPronoun);
let mainOpinion = -1;
for (let {pronoun, opinion} of this.pronounOpinions) {
if (typeof pronoun === 'string') {
continue;
}
if (opinion === 2) {
opinion = 0.5;
}
if (opinion > mainOpinion) {
mainPronoun = pronoun;
mainOpinion = opinion;
}
}
return mainPronoun;
},
},
methods: {
async ban() {
@ -305,12 +166,6 @@
<style lang="scss" scoped>
@import "assets/variables";
.avatar {
width: 100%;
max-width: 5rem;
max-height: 5rem;
}
.list-group-item-hoverable {
&:hover {
color: $primary;

41
routes/profileCard.vue Normal file
View File

@ -0,0 +1,41 @@
<template>
<Profile v-if="profile" :profile="profile" class="pb-3">
<nuxt-link to="/">
<h1 class="text-nowrap h5">
<Icon v="tags"/>
<T>title</T>
</h1>
</nuxt-link>
</Profile>
<NotFound v-else/>
</template>
<script>
import { head } from "../src/helpers";
export default {
layout: 'basic',
async asyncData({ app, route }) {
return {
profiles: await app.$axios.$get(`/profile/get/${encodeURIComponent(route.params.pathMatch)}`),
};
},
computed: {
profile() {
for (let locale in this.profiles) {
if (locale === this.config.locale) {
return this.profiles[locale];
}
}
return null;
},
},
head() {
return head({
title: `@${this.$route.params.pathMatch}`,
banner: `api/banner/@${this.$route.params.pathMatch}.png`,
});
},
}
</script>

View File

@ -1,4 +1,4 @@
export default {
module.exports = {
region: process.env.AWS_REGION,
credentials: {
accessKeyId: process.env.AWS_KEY,

46
server/cards.js Normal file
View File

@ -0,0 +1,46 @@
require('dotenv').config({ path:__dirname + '/../.env' });
const Pageres = require('pageres');
const isHighLoadTime = require('./overload');
const dbConnection = require('./db');
const locales = require('../src/locales');
const { ulid } = require('ulid');
const awsConfig = require('./aws');
const S3 = require('aws-sdk/clients/s3');
const s3 = new S3(awsConfig);
const urlBases = {}
for (let [code, , url, ] of locales) {
urlBases[code] = url + '/card/@'; // 'http://localhost:3000/card/@'
}
(async () => {
const db = await dbConnection();
for (let {id, locale, username} of await db.all('SELECT profiles.id, profiles.locale, users.username FROM profiles LEFT JOIN users on profiles.userId = users.id WHERE profiles.card IS NULL')) {
if (isHighLoadTime(locale)) {
continue;
}
// if (locale !== 'pl' || username !== 'andrea') { continue; }
const cardId = ulid();
const key = `card/${locale}/${username}-${cardId}.png`;
console.log(locale, username, cardId);
for (let dark of [false, true]) {
const [ buffer ] = await new Pageres({ darkMode: dark })
.src(urlBases[locale] + username, ['1024x300'])
.run();
const s3putResponse = await s3.putObject({
Key: dark ? key.replace('.png', '-dark.png') : key,
Body: buffer,
ContentType: 'image/png',
ACL: 'public-read',
}).promise();
}
await db.get(`UPDATE profiles SET card='https://${awsConfig.params.Bucket}.s3.${awsConfig.region}.amazonaws.com/${key}' WHERE id='${id}'`)
// '/card/@${username}.png'
}
})();

View File

@ -1,3 +1,5 @@
import isHighLoadTime from './overload';
const USER_AGENT_BOTS = /bot|crawler|baiduspider|80legs|ia_archiver|voyager|curl|wget|yahoo! slurp|mediapartners-google|facebookexternalhit|twitterbot|whatsapp|php|python/;
const USER_AGENT_BROWSERS = /mozilla|msie|gecko|firefox|edge|opera|safari|netscape|konqueror|android/;
@ -11,27 +13,9 @@ const isBrowser = (userAgent) => {
return isProbablyBrowser || !isProbablyBot;
}
const overload = {
en: [[17, 23]],
};
const isHighLoad = (timestamp, overloadPeriods) => {
if (overloadPeriods === undefined) {
return false;
}
for (let [periodStart, periodEnd] of overloadPeriods) {
if (timestamp.getUTCHours() >= periodStart && timestamp.getUTCHours() < periodEnd) {
return true;
}
}
return false;
}
export default function(req, res, next) {
if (process.env.NODE_ENV === 'production') {
res.spa = isBrowser(req.headers['user-agent']) || isHighLoad(new Date, overload[process.env.LOCALE]);
if (process.env.NODE_ENV === 'production' && !req.url.startsWith('/card/@')) {
res.spa = isBrowser(req.headers['user-agent']) || isHighLoadTime(process.env.LOCALE);
}
next();
}

17
server/overload.js Normal file
View File

@ -0,0 +1,17 @@
const overloadPeriods = {
en: [[17, 23]],
};
module.exports = (locale, timestamp = new Date) => {
if (overloadPeriods[locale] === undefined) {
return false;
}
for (let [periodStart, periodEnd] of overloadPeriods[locale]) {
if (timestamp.getUTCHours() >= periodStart && timestamp.getUTCHours() < periodEnd) {
return true;
}
}
return false;
}

View File

@ -58,6 +58,7 @@ const fetchProfiles = async (db, username, self, isAdmin) => {
footerAreas: profile.footerAreas ? profile.footerAreas.split(',') : [],
bannedReason: profile.bannedReason,
team: !!profile.roles,
card: profile.card,
};
}
return p;
@ -89,7 +90,8 @@ router.post('/profile/save', handleErrorAsync(async (req, res) => {
words = ${JSON.stringify(req.body.words)},
teamName = ${req.isGranted('users') ? req.body.teamName || null : ''},
footerName = ${req.isGranted('users') ? req.body.footerName || null : ''},
footerAreas = ${req.isGranted('users') ? req.body.footerAreas.join(',').toLowerCase() || null : ''}
footerAreas = ${req.isGranted('users') ? req.body.footerAreas.join(',').toLowerCase() || null : ''},
card = NULL
WHERE id = ${ids[0]}
`);
} else {

778
yarn.lock

File diff suppressed because it is too large Load Diff