Refactor picture-upload and take into account picture size limits

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2021-04-12 10:43:04 +02:00
parent 947d0b0cdb
commit e2721af456
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
6 changed files with 214 additions and 37 deletions

View file

@ -1,15 +1,35 @@
<template> <template>
<div class="root"> <div class="root">
<figure class="image" v-if="imageSrc"> <figure class="image" v-if="imageSrc && !imagePreviewLoadingError">
<img :src="imageSrc" /> <img :src="imageSrc" @error="showImageLoadingError" />
</figure> </figure>
<figure class="image is-128x128" v-else> <figure class="image is-128x128" v-else>
<div class="image-placeholder"> <div
<span class="has-text-centered">{{ textFallback }}</span> class="image-placeholder"
:class="{ error: imagePreviewLoadingError }"
>
<span class="has-text-centered" v-if="imagePreviewLoadingError">{{
$t("Error while loading the preview")
}}</span>
<span class="has-text-centered" v-else>{{ textFallback }}</span>
</div> </div>
</figure> </figure>
<div class="action-buttons"> <div class="action-buttons">
<p v-if="pictureFile" class="metadata">
<span class="name" :title="pictureFile.name">{{
pictureFile.name
}}</span>
<span class="size">({{ formatBytes(pictureFile.size) }})</span>
</p>
<p v-if="pictureTooBig" class="picture-too-big">
{{
$t(
"The selected picture is too heavy. You need to select a file smaller than {size}.",
{ size: formatBytes(maxSize) }
)
}}
</p>
<b-field class="file is-primary"> <b-field class="file is-primary">
<b-upload @input="onFileChanged" :accept="accept" class="file-label"> <b-upload @input="onFileChanged" :accept="accept" class="file-label">
<span class="file-cta"> <span class="file-cta">
@ -47,6 +67,10 @@ figure.image {
justify-content: center; justify-content: center;
align-items: center; align-items: center;
&.error {
border: 2px solid red;
}
span { span {
flex: 1; flex: 1;
color: #eee; color: #eee;
@ -56,6 +80,27 @@ figure.image {
.action-buttons { .action-buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.file {
justify-content: center;
}
.metadata {
display: inline-flex;
.name {
max-width: 200px;
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-right: 5px;
}
}
}
.picture-too-big {
color: $danger;
} }
</style> </style>
@ -87,30 +132,32 @@ export default class PictureUpload extends Vue {
}) })
textFallback!: string; textFallback!: string;
imageSrc: string | null = this.defaultImage ? this.defaultImage.url : null; @Prop({ type: Number, required: false, default: 10_485_760 })
maxSize!: number;
file!: File | null; file!: File | null;
mounted(): void { imagePreviewLoadingError = false;
if (this.pictureFile) {
this.updatePreview(this.pictureFile); get pictureTooBig(): boolean {
} return this.pictureFile?.size > this.maxSize;
} }
@Watch("pictureFile") get imageSrc(): string | null {
onPictureFileChanged(val: File): void { if (this.pictureFile !== undefined) {
this.updatePreview(val); if (this.pictureFile === null) return null;
try {
return URL.createObjectURL(this.pictureFile);
} catch (e) {
console.error(e);
} }
}
@Watch("defaultImage") return this.defaultImage ? this.defaultImage.url : null;
onDefaultImageChange(defaultImage: IMedia): void {
this.imageSrc = defaultImage ? defaultImage.url : null;
} }
onFileChanged(file: File | null): void { onFileChanged(file: File | null): void {
this.$emit("change", file); this.$emit("change", file);
this.updatePreview(file);
this.file = file; this.file = file;
} }
@ -118,13 +165,23 @@ export default class PictureUpload extends Vue {
this.onFileChanged(null); this.onFileChanged(null);
} }
private updatePreview(file?: File | null) { @Watch("imageSrc")
if (file) { resetImageLoadingError(): void {
this.imageSrc = URL.createObjectURL(file); this.imagePreviewLoadingError = false;
return;
} }
this.imageSrc = null; showImageLoadingError(): void {
this.imagePreviewLoadingError = true;
}
// https://gist.github.com/zentala/1e6f72438796d74531803cc3833c039c
formatBytes(bytes: number, decimals: number): string {
if (bytes == 0) return "0 Bytes";
const k = 1024,
dm = decimals || 2,
sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"],
i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
} }
} }
</script> </script>

View file

@ -979,5 +979,10 @@
"Personal feeds": "Personal feeds", "Personal feeds": "Personal feeds",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.", "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.",
"The event will show as attributed to this profile.": "The event will show as attributed to this profile.", "The event will show as attributed to this profile.": "The event will show as attributed to this profile.",
"You may show some members as contacts.": "You may show some members as contacts." "You may show some members as contacts.": "You may show some members as contacts.",
"The selected picture is too heavy. You need to select a file smaller than {size}.": "The selected picture is too heavy. You need to select a file smaller than {size}.",
"Unable to create the group. One of the pictures may be too heavy.": "Unable to create the group. One of the pictures may be too heavy.",
"Unable to update the profile. The avatar picture may be too heavy.": "Unable to update the profile. The avatar picture may be too heavy.",
"Unable to create the profile. The avatar picture may be too heavy.": "Unable to create the profile. The avatar picture may be too heavy.",
"Error while loading the preview": "Error while loading the preview"
} }

View file

@ -1073,5 +1073,10 @@
"Personal feeds": "Flux personnels", "Personal feeds": "Flux personnels",
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.", "These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.",
"The event will show as attributed to this profile.": "L'événement sera affiché comme attribué à ce profil.", "The event will show as attributed to this profile.": "L'événement sera affiché comme attribué à ce profil.",
"You may show some members as contacts.": "Vous pouvez afficher certain⋅es membres en tant que contacts." "You may show some members as contacts.": "Vous pouvez afficher certain⋅es membres en tant que contacts.",
"The selected picture is too heavy. You need to select a file smaller than {size}.": "L'image sélectionnée est trop lourde. Vous devez sélectionner un fichier de moins de {size}.",
"Unable to create the group. One of the pictures may be too heavy.": "Impossible de créer le groupe. Une des images est trop lourde.",
"Unable to update the profile. The avatar picture may be too heavy.": "Impossible de mettre à jour le profil. L'image d'avatar est probablement trop lourde.",
"Unable to create the profile. The avatar picture may be too heavy.": "Impossible de créer le profil. L'image d'avatar est probablement trop lourde.",
"Error while loading the preview": "Erreur lors du chargement de l'aperçu"
} }

View file

@ -32,6 +32,7 @@
<picture-upload <picture-upload
v-model="avatarFile" v-model="avatarFile"
:defaultImage="identity.avatar" :defaultImage="identity.avatar"
:maxSize="avatarMaxSize"
class="picture-upload" class="picture-upload"
/> />
@ -231,6 +232,9 @@ import {
DELETE_FEED_TOKEN, DELETE_FEED_TOKEN,
} from "@/graphql/feed_tokens"; } from "@/graphql/feed_tokens";
import { IFeedToken } from "@/types/feedtoken.model"; import { IFeedToken } from "@/types/feedtoken.model";
import { ServerParseError } from "apollo-link-http-common";
import { IConfig } from "@/types/config.model";
import { CONFIG } from "@/graphql/config";
@Component({ @Component({
components: { components: {
@ -256,6 +260,7 @@ import { IFeedToken } from "@/types/feedtoken.model";
this.handleErrors(graphQLErrors); this.handleErrors(graphQLErrors);
}, },
}, },
config: CONFIG,
}, },
}) })
export default class EditIdentity extends mixins(identityEditionMixin) { export default class EditIdentity extends mixins(identityEditionMixin) {
@ -263,6 +268,8 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
@Prop({ type: String }) identityName!: string; @Prop({ type: String }) identityName!: string;
config!: IConfig;
errors: string[] = []; errors: string[] = [];
avatarFile: File | null = null; avatarFile: File | null = null;
@ -450,6 +457,10 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
} }
} }
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
async generateFeedTokens(): Promise<void> { async generateFeedTokens(): Promise<void> {
const newToken = await this.createNewFeedToken(); const newToken = await this.createNewFeedToken();
this.identity.feedTokens.push(newToken); this.identity.feedTokens.push(newToken);
@ -528,6 +539,21 @@ export default class EditIdentity extends mixins(identityEditionMixin) {
private handleError(err: any) { private handleError(err: any) {
console.error(err); console.error(err);
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
const errorMessage = this.isUpdate
? this.$t(
"Unable to update the profile. The avatar picture may be too heavy."
)
: this.$t(
"Unable to create the profile. The avatar picture may be too heavy."
);
this.errors.push(errorMessage as string);
}
}
if (err.graphQLErrors !== undefined) { if (err.graphQLErrors !== undefined) {
err.graphQLErrors.forEach(({ message }: { message: string }) => { err.graphQLErrors.forEach(({ message }: { message: string }) => {
this.$notifier.error(message); this.$notifier.error(message);

View file

@ -58,12 +58,20 @@
<div> <div>
<b>{{ $t("Avatar") }}</b> <b>{{ $t("Avatar") }}</b>
<picture-upload :textFallback="$t('Avatar')" v-model="avatarFile" /> <picture-upload
:textFallback="$t('Avatar')"
v-model="avatarFile"
:maxSize="avatarMaxSize"
/>
</div> </div>
<div> <div>
<b>{{ $t("Banner") }}</b> <b>{{ $t("Banner") }}</b>
<picture-upload :textFallback="$t('Banner')" v-model="bannerFile" /> <picture-upload
:textFallback="$t('Banner')"
v-model="bannerFile"
:maxSize="bannerMaxSize"
/>
</div> </div>
<button class="button is-primary" native-type="submit"> <button class="button is-primary" native-type="submit">
@ -84,6 +92,10 @@ import { MemberRole } from "@/types/enums";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { convertToUsername } from "../../utils/username"; import { convertToUsername } from "../../utils/username";
import PictureUpload from "../../components/PictureUpload.vue"; import PictureUpload from "../../components/PictureUpload.vue";
import { ErrorResponse } from "apollo-link-error";
import { ServerParseError } from "apollo-link-http-common";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
@Component({ @Component({
components: { components: {
@ -93,6 +105,7 @@ import PictureUpload from "../../components/PictureUpload.vue";
currentActor: { currentActor: {
query: CURRENT_ACTOR_CLIENT, query: CURRENT_ACTOR_CLIENT,
}, },
config: CONFIG,
}, },
}) })
export default class CreateGroup extends mixins(IdentityEditionMixin) { export default class CreateGroup extends mixins(IdentityEditionMixin) {
@ -100,6 +113,8 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
group = new Group(); group = new Group();
config!: IConfig;
avatarFile: File | null = null; avatarFile: File | null = null;
bannerFile: File | null = null; bannerFile: File | null = null;
@ -110,6 +125,7 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
async createGroup(): Promise<void> { async createGroup(): Promise<void> {
try { try {
this.errors = [];
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: CREATE_GROUP, mutation: CREATE_GROUP,
variables: this.buildVariables(), variables: this.buildVariables(),
@ -154,6 +170,14 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
return window.location.hostname; return window.location.hostname;
} }
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
get bannerMaxSize(): number | undefined {
return this?.config?.uploadLimits?.banner;
}
@Watch("group.name") @Watch("group.name")
updateUsername(groupName: string): void { updateUsername(groupName: string): void {
this.group.preferredUsername = convertToUsername(groupName); this.group.preferredUsername = convertToUsername(groupName);
@ -194,9 +218,22 @@ export default class CreateGroup extends mixins(IdentityEditionMixin) {
}; };
} }
private handleError(err: any) { private handleError(err: ErrorResponse) {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
this.errors.push( this.errors.push(
...err.graphQLErrors.map(({ message }: { message: string }) => message) this.$t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
}
this.errors.push(
...(err.graphQLErrors || []).map(
({ message }: { message: string }) => message
)
); );
} }
} }

View file

@ -47,6 +47,7 @@
:textFallback="$t('Avatar')" :textFallback="$t('Avatar')"
v-model="avatarFile" v-model="avatarFile"
:defaultImage="group.avatar" :defaultImage="group.avatar"
:maxSize="avatarMaxSize"
/> />
</b-field> </b-field>
@ -55,6 +56,7 @@
:textFallback="$t('Banner')" :textFallback="$t('Banner')"
v-model="bannerFile" v-model="bannerFile"
:defaultImage="group.banner" :defaultImage="group.banner"
:maxSize="bannerMaxSize"
/> />
</b-field> </b-field>
<p class="label">{{ $t("Group visibility") }}</p> <p class="label">{{ $t("Group visibility") }}</p>
@ -158,6 +160,9 @@
}}</b-button> }}</b-button>
</div> </div>
</form> </form>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
{{ value }}
</b-message>
</section> </section>
<b-message v-else> <b-message v-else>
{{ $t("You are not an administrator for this group.") }} {{ $t("You are not an administrator for this group.") }}
@ -177,6 +182,10 @@ import RouteName from "../../router/name";
import { UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group"; import { UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { ErrorResponse } from "apollo-link-error";
import { ServerParseError } from "apollo-link-http-common";
@Component({ @Component({
components: { components: {
@ -184,14 +193,21 @@ import { Address, IAddress } from "../../types/address.model";
PictureUpload, PictureUpload,
editor: () => import("../../components/Editor.vue"), editor: () => import("../../components/Editor.vue"),
}, },
apollo: {
config: CONFIG,
},
}) })
export default class GroupSettings extends mixins(GroupMixin) { export default class GroupSettings extends mixins(GroupMixin) {
loading = true; loading = true;
RouteName = RouteName; RouteName = RouteName;
config!: IConfig;
newMemberUsername = ""; newMemberUsername = "";
errors: string[] = [];
avatarFile: File | null = null; avatarFile: File | null = null;
bannerFile: File | null = null; bannerFile: File | null = null;
@ -205,12 +221,16 @@ export default class GroupSettings extends mixins(GroupMixin) {
showCopiedTooltip = false; showCopiedTooltip = false;
async updateGroup(): Promise<void> { async updateGroup(): Promise<void> {
try {
const variables = this.buildVariables(); const variables = this.buildVariables();
await this.$apollo.mutate<{ updateGroup: IGroup }>({ await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP, mutation: UPDATE_GROUP,
variables, variables,
}); });
this.$notifier.success(this.$t("Group settings saved") as string); this.$notifier.success(this.$t("Group settings saved") as string);
} catch (err) {
this.handleError(err);
}
} }
confirmDeleteGroup(): void { confirmDeleteGroup(): void {
@ -299,5 +319,32 @@ export default class GroupSettings extends mixins(GroupMixin) {
get currentAddress(): IAddress { get currentAddress(): IAddress {
return new Address(this.group.physicalAddress); return new Address(this.group.physicalAddress);
} }
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
get bannerMaxSize(): number | undefined {
return this?.config?.uploadLimits?.banner;
}
private handleError(err: ErrorResponse) {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
this.errors.push(
this.$t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
}
this.errors.push(
...(err.graphQLErrors || []).map(
({ message }: { message: string }) => message
)
);
}
} }
</script> </script>