Merge branch 'session-issues' into 'master'

Various issues

Closes #823

See merge request framasoft/mobilizon!1025
This commit is contained in:
Thomas Citharel 2021-08-12 09:30:42 +00:00
commit d2fed8f91a
15 changed files with 184 additions and 108 deletions

View file

@ -66,10 +66,11 @@ config :mime, :types, %{
config :mobilizon, Mobilizon.Web.Upload, config :mobilizon, Mobilizon.Web.Upload,
uploader: Mobilizon.Web.Upload.Uploader.Local, uploader: Mobilizon.Web.Upload.Uploader.Local,
filters: [ filters: [
Mobilizon.Web.Upload.Filter.Dedupe,
Mobilizon.Web.Upload.Filter.AnalyzeMetadata, Mobilizon.Web.Upload.Filter.AnalyzeMetadata,
Mobilizon.Web.Upload.Filter.Resize, Mobilizon.Web.Upload.Filter.Resize,
Mobilizon.Web.Upload.Filter.Optimize Mobilizon.Web.Upload.Filter.Optimize,
Mobilizon.Web.Upload.Filter.BlurHash,
Mobilizon.Web.Upload.Filter.Dedupe
], ],
allow_list_mime_types: ["image/gif", "image/jpeg", "image/png", "image/webp"], allow_list_mime_types: ["image/gif", "image/jpeg", "image/png", "image/webp"],
link_name: true, link_name: true,

View file

@ -5,6 +5,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { IMedia } from "@/types/media.model"; import { IMedia } from "@/types/media.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import LazyImageWrapper from "../Image/LazyImageWrapper.vue"; import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
@ -14,7 +15,7 @@ import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
}, },
}) })
export default class EventBanner extends Vue { export default class EventBanner extends Vue {
@Prop({ required: true, default: null }) @Prop({ required: true, default: null, type: Object as PropType<IMedia> })
picture!: IMedia | null; picture!: IMedia | null;
} }
</script> </script>

View file

@ -7,17 +7,21 @@ import { decode } from "blurhash";
import { Component, Prop, Ref, Vue } from "vue-property-decorator"; import { Component, Prop, Ref, Vue } from "vue-property-decorator";
@Component @Component
export default class extends Vue { export default class BlurhashImg extends Vue {
@Prop({ type: String, required: true }) hash!: string; @Prop({ type: String, required: true }) hash!: string;
@Prop({ type: Number, default: 1 }) aspectRatio!: string; @Prop({ type: Number, default: 1 }) aspectRatio!: string;
@Ref("canvas") readonly canvas!: any; @Ref("canvas") readonly canvas!: any;
mounted(): void { mounted(): void {
try {
const pixels = decode(this.hash, 32, 32); const pixels = decode(this.hash, 32, 32);
const imageData = new ImageData(pixels, 32, 32); const imageData = new ImageData(pixels, 32, 32);
const context = this.canvas.getContext("2d"); const context = this.canvas.getContext("2d");
context.putImageData(imageData, 0, 0); context.putImageData(imageData, 0, 0);
} catch (e) {
console.error(e);
}
} }
} }
</script> </script>

View file

@ -9,6 +9,7 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { IMedia } from "@/types/media.model"; import { IMedia } from "@/types/media.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import LazyImage from "../Image/LazyImage.vue"; import LazyImage from "../Image/LazyImage.vue";
@ -31,7 +32,7 @@ const DEFAULT_PICTURE = {
}, },
}) })
export default class LazyImageWrapper extends Vue { export default class LazyImageWrapper extends Vue {
@Prop({ required: true }) @Prop({ required: false, type: Object as PropType<IMedia | null> })
picture!: IMedia | null; picture!: IMedia | null;
get pictureOrDefault(): Partial<IMedia> { get pictureOrDefault(): Partial<IMedia> {

View file

@ -7,12 +7,18 @@
:center="[lat, lon]" :center="[lat, lon]"
@click="clickMap" @click="clickMap"
@update:zoom="updateZoom" @update:zoom="updateZoom"
:options="{ zoomControl: false }"
> >
<l-tile-layer <l-tile-layer
:url="config.maps.tiles.endpoint" :url="config.maps.tiles.endpoint"
:attribution="attribution" :attribution="attribution"
> >
</l-tile-layer> </l-tile-layer>
<l-control-zoom
position="topleft"
:zoomInTitle="$t('Zoom in')"
:zoomOutTitle="$t('Zoom out')"
></l-control-zoom>
<v-locatecontrol :options="{ icon: 'mdi mdi-map-marker' }" /> <v-locatecontrol :options="{ icon: 'mdi mdi-map-marker' }" />
<l-marker <l-marker
:lat-lng="[lat, lon]" :lat-lng="[lat, lon]"
@ -34,7 +40,14 @@
import { Icon, LatLng, LeafletMouseEvent, LeafletEvent } from "leaflet"; import { Icon, LatLng, LeafletMouseEvent, LeafletEvent } from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
import { LMap, LTileLayer, LMarker, LPopup, LIcon } from "vue2-leaflet"; import {
LMap,
LTileLayer,
LMarker,
LPopup,
LIcon,
LControlZoom,
} from "vue2-leaflet";
import Vue2LeafletLocateControl from "@/components/Map/Vue2LeafletLocateControl.vue"; import Vue2LeafletLocateControl from "@/components/Map/Vue2LeafletLocateControl.vue";
import { CONFIG } from "../graphql/config"; import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model"; import { IConfig } from "../types/config.model";
@ -46,6 +59,7 @@ import { IConfig } from "../types/config.model";
LMarker, LMarker,
LPopup, LPopup,
LIcon, LIcon,
LControlZoom,
"v-locatecontrol": Vue2LeafletLocateControl, "v-locatecontrol": Vue2LeafletLocateControl,
}, },
apollo: { apollo: {

View file

@ -10,9 +10,9 @@
* to try to trigger location manually (not done ATM) * to try to trigger location manually (not done ATM)
*/ */
import L, { DomEvent } from "leaflet"; import { DomEvent } from "leaflet";
import { findRealParent, propsBinder } from "vue2-leaflet"; import { findRealParent, propsBinder } from "vue2-leaflet";
import "leaflet.locatecontrol"; import Locatecontrol from "leaflet.locatecontrol";
import { Component, Prop, Vue } from "vue-property-decorator"; import { Component, Prop, Vue } from "vue-property-decorator";
@Component({ @Component({
@ -37,12 +37,20 @@ export default class Vue2LeafletLocateControl extends Vue {
parentContainer: any; parentContainer: any;
mounted(): void { mounted(): void {
this.mapObject = L.control.locate(this.options); // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.mapObject = new Locatecontrol({
...this.options,
strings: { title: this.$t("Show me where I am") as string },
});
DomEvent.on(this.mapObject, this.$listeners as any); DomEvent.on(this.mapObject, this.$listeners as any);
propsBinder(this, this.mapObject, this.$props); propsBinder(this, this.mapObject, this.$props);
this.ready = true; this.ready = true;
this.parentContainer = findRealParent(this.$parent); this.parentContainer = findRealParent(this.$parent);
this.mapObject.addTo(this.parentContainer.mapObject, !this.visible); this.mapObject.addTo(this.parentContainer.mapObject, !this.visible);
this.$nextTick(() => {
this.$emit("ready", this.mapObject);
});
} }
public locate(): void { public locate(): void {

View file

@ -1125,5 +1125,8 @@
"Booking": "Booking", "Booking": "Booking",
"Filter by profile or group name": "Filter by profile or group name", "Filter by profile or group name": "Filter by profile or group name",
"Filter by name": "Filter by name", "Filter by name": "Filter by name",
"Redirecting in progress…": "Redirecting in progress…" "Redirecting in progress…": "Redirecting in progress…",
"Zoom in": "Zoom in",
"Zoom out": "Zoom out",
"Show me where I am": "Show me where I am"
} }

View file

@ -99,7 +99,7 @@
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet événement ? Cette action n'est pas réversible. Vous voulez peut-être engager la discussion avec le créateur de l'événement ou bien modifier son événement à la place.", "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet événement ? Cette action n'est pas réversible. Vous voulez peut-être engager la discussion avec le créateur de l'événement ou bien modifier son événement à la place.",
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Tous les membres - y compris ceux·elles sur d'autres instances - seront notifié·e·s et supprimé·e·s du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.", "Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Tous les membres - y compris ceux·elles sur d'autres instances - seront notifié·e·s et supprimé·e·s du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Comme ce groupe provient de l'instance {instance}, cela supprimera seulement les membres locaux et supprimera les données locales, et rejettera également toutes les données futures.", "Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Comme ce groupe provient de l'instance {instance}, cela supprimera seulement les membres locaux et supprimera les données locales, et rejettera également toutes les données futures.",
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Étes-vous certain⋅e de vouloir annuler la création de l'événement ? Vous allez perdre toutes vos modifications.", "Are you sure you want to cancel the event creation? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la création de l'événement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la modification de l'événement ? Vous allez perdre toutes vos modifications.", "Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la modification de l'événement ? Vous allez perdre toutes vos modifications.",
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'événement « {title} » ?", "Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'événement « {title} » ?",
"Are you sure you want to delete this entire discussion?": "Êtes-vous certain⋅e de vouloir supprimer l'entièreté de cette discussion ?", "Are you sure you want to delete this entire discussion?": "Êtes-vous certain⋅e de vouloir supprimer l'entièreté de cette discussion ?",
@ -1216,5 +1216,8 @@
"Booking": "Réservations", "Booking": "Réservations",
"Filter by profile or group name": "Filter par nom du profil ou du groupe", "Filter by profile or group name": "Filter par nom du profil ou du groupe",
"Filter by name": "Filtrer par nom", "Filter by name": "Filtrer par nom",
"Redirecting in progress…": "Redirection en cours…" "Redirecting in progress…": "Redirection en cours…",
"Zoom in": "Zoomer",
"Zoom out": "Dézoomer",
"Show me where I am": "Afficher ma position"
} }

View file

@ -1,5 +1,3 @@
// The Vue build version to load with the `import` command
// (runtime-only or standalone) has been set in webpack.base.conf with an alias.
import Vue from "vue"; import Vue from "vue";
import Buefy from "buefy"; import Buefy from "buefy";
import Component from "vue-class-component"; import Component from "vue-class-component";

View file

@ -203,36 +203,11 @@ export class EventModel implements IEvent {
} }
toEditJSON(): IEventEditJSON { toEditJSON(): IEventEditJSON {
return { return toEditJSON(this);
id: this.id, }
title: this.title,
description: this.description,
beginsOn: this.beginsOn.toISOString(),
endsOn: this.endsOn ? this.endsOn.toISOString() : null,
status: this.status,
visibility: this.visibility,
joinOptions: this.joinOptions,
draft: this.draft,
tags: this.tags.map((t) => t.title),
onlineAddress: this.onlineAddress,
phoneAddress: this.phoneAddress,
physicalAddress: this.removeTypeName(this.physicalAddress),
options: this.removeTypeName(this.options),
metadata: this.metadata.map(({ key, value, type, title }) => ({
key,
value,
type,
title,
})),
attributedToId:
this.attributedTo && this.attributedTo.id ? this.attributedTo.id : null,
contacts: this.contacts.map(({ id }) => ({
id,
})),
};
} }
private removeTypeName(entity: any): any { function removeTypeName(entity: any): any {
if (entity?.__typename) { if (entity?.__typename) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { __typename, ...purgedEntity } = entity; const { __typename, ...purgedEntity } = entity;
@ -240,4 +215,35 @@ export class EventModel implements IEvent {
} }
return entity; return entity;
} }
export function toEditJSON(event: IEvent): IEventEditJSON {
return {
id: event.id,
title: event.title,
description: event.description,
beginsOn: event.beginsOn.toISOString(),
endsOn: event.endsOn ? event.endsOn.toISOString() : null,
status: event.status,
visibility: event.visibility,
joinOptions: event.joinOptions,
draft: event.draft,
tags: event.tags.map((t) => t.title),
onlineAddress: event.onlineAddress,
phoneAddress: event.phoneAddress,
physicalAddress: removeTypeName(event.physicalAddress),
options: removeTypeName(event.options),
metadata: event.metadata.map(({ key, value, type, title }) => ({
key,
value,
type,
title,
})),
attributedToId:
event.attributedTo && event.attributedTo.id
? event.attributedTo.id
: null,
contacts: event.contacts.map(({ id }) => ({
id,
})),
};
} }

View file

@ -463,7 +463,7 @@ import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.
import EventMetadataList from "@/components/Event/EventMetadataList.vue"; import EventMetadataList from "@/components/Event/EventMetadataList.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue"; import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import Subtitle from "@/components/Utils/Subtitle.vue"; import Subtitle from "@/components/Utils/Subtitle.vue";
import { Route } from "vue-router"; import { RawLocation, Route } from "vue-router";
import { formatList } from "@/utils/i18n"; import { formatList } from "@/utils/i18n";
import { import {
ActorType, ActorType,
@ -481,7 +481,7 @@ import {
EVENT_PERSON_PARTICIPATION, EVENT_PERSON_PARTICIPATION,
FETCH_EVENT, FETCH_EVENT,
} from "../../graphql/event"; } from "../../graphql/event";
import { EventModel, IEvent } from "../../types/event.model"; import { EventModel, IEvent, toEditJSON } from "../../types/event.model";
import { import {
CURRENT_ACTOR_CLIENT, CURRENT_ACTOR_CLIENT,
IDENTITIES, IDENTITIES,
@ -586,6 +586,8 @@ export default class EditEvent extends Vue {
event: IEvent = new EventModel(); event: IEvent = new EventModel();
unmodifiedEvent: IEvent = new EventModel();
identities: IActor[] = []; identities: IActor[] = [];
person!: IPerson; person!: IPerson;
@ -687,12 +689,13 @@ export default class EditEvent extends Vue {
if (!(this.isUpdate || this.isDuplicate)) { if (!(this.isUpdate || this.isDuplicate)) {
this.initializeEvent(); this.initializeEvent();
} else { } else {
this.event = { this.event = new EventModel({
...this.event, ...this.event,
options: cloneDeep(this.event.options), options: cloneDeep(this.event.options),
description: this.event.description || "", description: this.event.description || "",
}; });
} }
this.unmodifiedEvent = cloneDeep(this.event);
} }
createOrUpdateDraft(e: Event): void { createOrUpdateDraft(e: Event): void {
@ -813,8 +816,8 @@ export default class EditEvent extends Vue {
} }
get updateEventMessage(): string { get updateEventMessage(): string {
// if (this.unmodifiedEvent.draft && !this.event.draft) if (this.unmodifiedEvent.draft && !this.event.draft)
// return this.$i18n.t("The event has been updated and published") as string; return this.$i18n.t("The event has been updated and published") as string;
return ( return (
this.event.draft this.event.draft
? this.$i18n.t("The draft event has been updated") ? this.$i18n.t("The draft event has been updated")
@ -910,7 +913,7 @@ export default class EditEvent extends Vue {
* Build variables for Event GraphQL creation query * Build variables for Event GraphQL creation query
*/ */
private async buildVariables() { private async buildVariables() {
let res = new EventModel(this.event).toEditJSON(); let res = toEditJSON(new EventModel(this.event));
const organizerActor = this.event.organizerActor?.id const organizerActor = this.event.organizerActor?.id
? this.event.organizerActor ? this.event.organizerActor
: this.organizerActor; : this.organizerActor;
@ -984,10 +987,12 @@ export default class EditEvent extends Vue {
/** /**
* Confirm cancel * Confirm cancel
*/ */
confirmGoElsewhere(callback: () => any): void { confirmGoElsewhere(): Promise<boolean> {
if (!this.isEventModified) { // TODO: Make calculation of changes work again and bring this back
callback(); // If the event wasn't modified, no need to warn
} // if (!this.isEventModified) {
// return Promise.resolve(true);
// }
const title: string = this.isUpdate const title: string = this.isUpdate
? (this.$t("Cancel edition") as string) ? (this.$t("Cancel edition") as string)
: (this.$t("Cancel creation") as string); : (this.$t("Cancel creation") as string);
@ -1001,6 +1006,7 @@ export default class EditEvent extends Vue {
{ title: this.event.title } { title: this.event.title }
) as string); ) as string);
return new Promise((resolve) => {
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
title, title,
message, message,
@ -1008,7 +1014,9 @@ export default class EditEvent extends Vue {
cancelText: this.$t("Continue editing") as string, cancelText: this.$t("Continue editing") as string,
type: "is-warning", type: "is-warning",
hasIcon: true, hasIcon: true,
onConfirm: callback, onConfirm: () => resolve(true),
onCancel: () => resolve(false),
});
}); });
} }
@ -1016,21 +1024,29 @@ export default class EditEvent extends Vue {
* Confirm cancel * Confirm cancel
*/ */
confirmGoBack(): void { confirmGoBack(): void {
this.confirmGoElsewhere(() => this.$router.go(-1)); this.$router.go(-1);
} }
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
beforeRouteLeave(to: Route, from: Route, next: () => void): void { async beforeRouteLeave(
to: Route,
from: Route,
next: (to?: RawLocation | false | ((vm: any) => void)) => void
): Promise<void> {
if (to.name === RouteName.EVENT) return next(); if (to.name === RouteName.EVENT) return next();
this.confirmGoElsewhere(() => next()); if (await this.confirmGoElsewhere()) {
return next();
}
return next(false);
} }
get isEventModified(): boolean { get isEventModified(): boolean {
// return ( return (
// JSON.stringify(this.event.toEditJSON()) !== this.event &&
// JSON.stringify(this.unmodifiedEvent) this.unmodifiedEvent &&
// ); JSON.stringify(toEditJSON(this.event)) !==
return false; JSON.stringify(this.unmodifiedEvent)
);
} }
get beginsOn(): Date { get beginsOn(): Date {

View file

@ -20,13 +20,7 @@ defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadata do
|> Mogrify.open() |> Mogrify.open()
|> Mogrify.verbose() |> Mogrify.verbose()
upload = {:ok, :filtered, %Upload{upload | width: image.width, height: image.height}}
upload
|> Map.put(:width, image.width)
|> Map.put(:height, image.height)
|> Map.put(:blurhash, get_blurhash(file))
{:ok, :filtered, upload}
rescue rescue
e in ErlangError -> e in ErlangError ->
Logger.warn("#{__MODULE__}: #{inspect(e)}") Logger.warn("#{__MODULE__}: #{inspect(e)}")
@ -34,14 +28,4 @@ defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadata do
end end
def filter(_), do: {:ok, :noop} def filter(_), do: {:ok, :noop}
defp get_blurhash(file) do
case :eblurhash.magick(to_charlist(file)) do
{:ok, blurhash} ->
to_string(blurhash)
_ ->
nil
end
end
end end

View file

@ -0,0 +1,31 @@
defmodule Mobilizon.Web.Upload.Filter.BlurHash do
@moduledoc """
Computes blurhash from the upload
"""
require Logger
alias Mobilizon.Web.Upload
@behaviour Mobilizon.Web.Upload.Filter
@spec filter(Upload.t()) ::
{:ok, :filtered, Upload.t()} | {:ok, :noop} | {:error, String.t()}
def filter(%Upload{tempfile: file, content_type: "image" <> _} = upload) do
{:ok, :filtered, %Upload{upload | blurhash: generate_blurhash(file)}}
rescue
e in ErlangError ->
Logger.warn("#{__MODULE__}: #{inspect(e)}")
{:ok, :noop}
end
def filter(_), do: {:ok, :noop}
defp generate_blurhash(file) do
case :eblurhash.magick(to_charlist(file)) do
{:ok, blurhash} ->
to_string(blurhash)
_ ->
nil
end
end
end

View file

@ -6,22 +6,27 @@ defmodule Mobilizon.Web.Upload.Filter.Resize do
""" """
@behaviour Mobilizon.Web.Upload.Filter @behaviour Mobilizon.Web.Upload.Filter
alias Mobilizon.Web.Upload
@maximum_width 1_920 @maximum_width 1_920
@maximum_height 1_080 @maximum_height 1_080
def filter(%Mobilizon.Web.Upload{ def filter(
%Upload{
tempfile: file, tempfile: file,
content_type: "image" <> _, content_type: "image" <> _,
width: width, width: width,
height: height height: height
}) do } = upload
) do
{new_width, new_height} = sizes = limit_sizes({width, height})
file file
|> Mogrify.open() |> Mogrify.open()
|> Mogrify.resize(string(limit_sizes({width, height}))) |> Mogrify.resize(string(sizes))
|> Mogrify.save(in_place: true) |> Mogrify.save(in_place: true)
{:ok, :filtered} {:ok, :filtered, %Upload{upload | width: new_width, height: new_height}}
end end
def filter(_), do: {:ok, :noop} def filter(_), do: {:ok, :noop}

View file

@ -62,9 +62,10 @@ defmodule Mobilizon.Web.Upload do
path: String.t(), path: String.t(),
size: integer(), size: integer(),
width: integer(), width: integer(),
height: integer() height: integer(),
blurhash: String.t()
} }
defstruct [:id, :name, :tempfile, :content_type, :path, :size, :width, :height] defstruct [:id, :name, :tempfile, :content_type, :path, :size, :width, :height, :blurhash]
@spec store(source, options :: [option()]) :: {:ok, map()} | {:error, any()} @spec store(source, options :: [option()]) :: {:ok, map()} | {:error, any()}
def store(upload, opts \\ []) do def store(upload, opts \\ []) do