Merge branch 'session-issues' into 'master'
Various issues Closes #823 See merge request framasoft/mobilizon!1025
This commit is contained in:
commit
d2fed8f91a
|
@ -66,10 +66,11 @@ config :mime, :types, %{
|
|||
config :mobilizon, Mobilizon.Web.Upload,
|
||||
uploader: Mobilizon.Web.Upload.Uploader.Local,
|
||||
filters: [
|
||||
Mobilizon.Web.Upload.Filter.Dedupe,
|
||||
Mobilizon.Web.Upload.Filter.AnalyzeMetadata,
|
||||
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"],
|
||||
link_name: true,
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
</template>
|
||||
<script lang="ts">
|
||||
import { IMedia } from "@/types/media.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
|
||||
|
||||
|
@ -14,7 +15,7 @@ import LazyImageWrapper from "../Image/LazyImageWrapper.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;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -7,17 +7,21 @@ import { decode } from "blurhash";
|
|||
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class extends Vue {
|
||||
export default class BlurhashImg extends Vue {
|
||||
@Prop({ type: String, required: true }) hash!: string;
|
||||
@Prop({ type: Number, default: 1 }) aspectRatio!: string;
|
||||
|
||||
@Ref("canvas") readonly canvas!: any;
|
||||
|
||||
mounted(): void {
|
||||
const pixels = decode(this.hash, 32, 32);
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
const context = this.canvas.getContext("2d");
|
||||
context.putImageData(imageData, 0, 0);
|
||||
try {
|
||||
const pixels = decode(this.hash, 32, 32);
|
||||
const imageData = new ImageData(pixels, 32, 32);
|
||||
const context = this.canvas.getContext("2d");
|
||||
context.putImageData(imageData, 0, 0);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
</template>
|
||||
<script lang="ts">
|
||||
import { IMedia } from "@/types/media.model";
|
||||
import { PropType } from "vue";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import LazyImage from "../Image/LazyImage.vue";
|
||||
|
||||
|
@ -31,7 +32,7 @@ const DEFAULT_PICTURE = {
|
|||
},
|
||||
})
|
||||
export default class LazyImageWrapper extends Vue {
|
||||
@Prop({ required: true })
|
||||
@Prop({ required: false, type: Object as PropType<IMedia | null> })
|
||||
picture!: IMedia | null;
|
||||
|
||||
get pictureOrDefault(): Partial<IMedia> {
|
||||
|
|
|
@ -7,12 +7,18 @@
|
|||
:center="[lat, lon]"
|
||||
@click="clickMap"
|
||||
@update:zoom="updateZoom"
|
||||
:options="{ zoomControl: false }"
|
||||
>
|
||||
<l-tile-layer
|
||||
:url="config.maps.tiles.endpoint"
|
||||
:attribution="attribution"
|
||||
>
|
||||
</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' }" />
|
||||
<l-marker
|
||||
:lat-lng="[lat, lon]"
|
||||
|
@ -34,7 +40,14 @@
|
|||
import { Icon, LatLng, LeafletMouseEvent, LeafletEvent } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
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 { CONFIG } from "../graphql/config";
|
||||
import { IConfig } from "../types/config.model";
|
||||
|
@ -46,6 +59,7 @@ import { IConfig } from "../types/config.model";
|
|||
LMarker,
|
||||
LPopup,
|
||||
LIcon,
|
||||
LControlZoom,
|
||||
"v-locatecontrol": Vue2LeafletLocateControl,
|
||||
},
|
||||
apollo: {
|
||||
|
|
|
@ -10,9 +10,9 @@
|
|||
* 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 "leaflet.locatecontrol";
|
||||
import Locatecontrol from "leaflet.locatecontrol";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component({
|
||||
|
@ -37,12 +37,20 @@ export default class Vue2LeafletLocateControl extends Vue {
|
|||
parentContainer: any;
|
||||
|
||||
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);
|
||||
propsBinder(this, this.mapObject, this.$props);
|
||||
this.ready = true;
|
||||
this.parentContainer = findRealParent(this.$parent);
|
||||
this.mapObject.addTo(this.parentContainer.mapObject, !this.visible);
|
||||
this.$nextTick(() => {
|
||||
this.$emit("ready", this.mapObject);
|
||||
});
|
||||
}
|
||||
|
||||
public locate(): void {
|
||||
|
|
|
@ -1125,5 +1125,8 @@
|
|||
"Booking": "Booking",
|
||||
"Filter by profile or group name": "Filter by profile or group 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"
|
||||
}
|
||||
|
|
|
@ -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>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 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 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 ?",
|
||||
|
@ -1216,5 +1216,8 @@
|
|||
"Booking": "Réservations",
|
||||
"Filter by profile or group name": "Filter par nom du profil ou du groupe",
|
||||
"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"
|
||||
}
|
||||
|
|
|
@ -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 Buefy from "buefy";
|
||||
import Component from "vue-class-component";
|
||||
|
|
|
@ -203,41 +203,47 @@ export class EventModel implements IEvent {
|
|||
}
|
||||
|
||||
toEditJSON(): IEventEditJSON {
|
||||
return {
|
||||
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 {
|
||||
if (entity?.__typename) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __typename, ...purgedEntity } = entity;
|
||||
return purgedEntity;
|
||||
}
|
||||
return entity;
|
||||
return toEditJSON(this);
|
||||
}
|
||||
}
|
||||
|
||||
function removeTypeName(entity: any): any {
|
||||
if (entity?.__typename) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { __typename, ...purgedEntity } = entity;
|
||||
return purgedEntity;
|
||||
}
|
||||
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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -463,7 +463,7 @@ import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.
|
|||
import EventMetadataList from "@/components/Event/EventMetadataList.vue";
|
||||
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.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 {
|
||||
ActorType,
|
||||
|
@ -481,7 +481,7 @@ import {
|
|||
EVENT_PERSON_PARTICIPATION,
|
||||
FETCH_EVENT,
|
||||
} from "../../graphql/event";
|
||||
import { EventModel, IEvent } from "../../types/event.model";
|
||||
import { EventModel, IEvent, toEditJSON } from "../../types/event.model";
|
||||
import {
|
||||
CURRENT_ACTOR_CLIENT,
|
||||
IDENTITIES,
|
||||
|
@ -586,6 +586,8 @@ export default class EditEvent extends Vue {
|
|||
|
||||
event: IEvent = new EventModel();
|
||||
|
||||
unmodifiedEvent: IEvent = new EventModel();
|
||||
|
||||
identities: IActor[] = [];
|
||||
|
||||
person!: IPerson;
|
||||
|
@ -687,12 +689,13 @@ export default class EditEvent extends Vue {
|
|||
if (!(this.isUpdate || this.isDuplicate)) {
|
||||
this.initializeEvent();
|
||||
} else {
|
||||
this.event = {
|
||||
this.event = new EventModel({
|
||||
...this.event,
|
||||
options: cloneDeep(this.event.options),
|
||||
description: this.event.description || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
this.unmodifiedEvent = cloneDeep(this.event);
|
||||
}
|
||||
|
||||
createOrUpdateDraft(e: Event): void {
|
||||
|
@ -813,8 +816,8 @@ export default class EditEvent extends Vue {
|
|||
}
|
||||
|
||||
get updateEventMessage(): string {
|
||||
// if (this.unmodifiedEvent.draft && !this.event.draft)
|
||||
// return this.$i18n.t("The event has been updated and published") as string;
|
||||
if (this.unmodifiedEvent.draft && !this.event.draft)
|
||||
return this.$i18n.t("The event has been updated and published") as string;
|
||||
return (
|
||||
this.event.draft
|
||||
? 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
|
||||
*/
|
||||
private async buildVariables() {
|
||||
let res = new EventModel(this.event).toEditJSON();
|
||||
let res = toEditJSON(new EventModel(this.event));
|
||||
const organizerActor = this.event.organizerActor?.id
|
||||
? this.event.organizerActor
|
||||
: this.organizerActor;
|
||||
|
@ -984,10 +987,12 @@ export default class EditEvent extends Vue {
|
|||
/**
|
||||
* Confirm cancel
|
||||
*/
|
||||
confirmGoElsewhere(callback: () => any): void {
|
||||
if (!this.isEventModified) {
|
||||
callback();
|
||||
}
|
||||
confirmGoElsewhere(): Promise<boolean> {
|
||||
// TODO: Make calculation of changes work again and bring this back
|
||||
// If the event wasn't modified, no need to warn
|
||||
// if (!this.isEventModified) {
|
||||
// return Promise.resolve(true);
|
||||
// }
|
||||
const title: string = this.isUpdate
|
||||
? (this.$t("Cancel edition") as string)
|
||||
: (this.$t("Cancel creation") as string);
|
||||
|
@ -1001,14 +1006,17 @@ export default class EditEvent extends Vue {
|
|||
{ title: this.event.title }
|
||||
) as string);
|
||||
|
||||
this.$buefy.dialog.confirm({
|
||||
title,
|
||||
message,
|
||||
confirmText: this.$t("Abandon editing") as string,
|
||||
cancelText: this.$t("Continue editing") as string,
|
||||
type: "is-warning",
|
||||
hasIcon: true,
|
||||
onConfirm: callback,
|
||||
return new Promise((resolve) => {
|
||||
this.$buefy.dialog.confirm({
|
||||
title,
|
||||
message,
|
||||
confirmText: this.$t("Abandon editing") as string,
|
||||
cancelText: this.$t("Continue editing") as string,
|
||||
type: "is-warning",
|
||||
hasIcon: true,
|
||||
onConfirm: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1016,21 +1024,29 @@ export default class EditEvent extends Vue {
|
|||
* Confirm cancel
|
||||
*/
|
||||
confirmGoBack(): void {
|
||||
this.confirmGoElsewhere(() => this.$router.go(-1));
|
||||
this.$router.go(-1);
|
||||
}
|
||||
|
||||
// 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();
|
||||
this.confirmGoElsewhere(() => next());
|
||||
if (await this.confirmGoElsewhere()) {
|
||||
return next();
|
||||
}
|
||||
return next(false);
|
||||
}
|
||||
|
||||
get isEventModified(): boolean {
|
||||
// return (
|
||||
// JSON.stringify(this.event.toEditJSON()) !==
|
||||
// JSON.stringify(this.unmodifiedEvent)
|
||||
// );
|
||||
return false;
|
||||
return (
|
||||
this.event &&
|
||||
this.unmodifiedEvent &&
|
||||
JSON.stringify(toEditJSON(this.event)) !==
|
||||
JSON.stringify(this.unmodifiedEvent)
|
||||
);
|
||||
}
|
||||
|
||||
get beginsOn(): Date {
|
||||
|
|
|
@ -20,13 +20,7 @@ defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadata do
|
|||
|> Mogrify.open()
|
||||
|> Mogrify.verbose()
|
||||
|
||||
upload =
|
||||
upload
|
||||
|> Map.put(:width, image.width)
|
||||
|> Map.put(:height, image.height)
|
||||
|> Map.put(:blurhash, get_blurhash(file))
|
||||
|
||||
{:ok, :filtered, upload}
|
||||
{:ok, :filtered, %Upload{upload | width: image.width, height: image.height}}
|
||||
rescue
|
||||
e in ErlangError ->
|
||||
Logger.warn("#{__MODULE__}: #{inspect(e)}")
|
||||
|
@ -34,14 +28,4 @@ defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadata do
|
|||
end
|
||||
|
||||
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
|
||||
|
|
31
lib/web/upload/filter/blurhash.ex
Normal file
31
lib/web/upload/filter/blurhash.ex
Normal 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
|
|
@ -6,22 +6,27 @@ defmodule Mobilizon.Web.Upload.Filter.Resize do
|
|||
"""
|
||||
|
||||
@behaviour Mobilizon.Web.Upload.Filter
|
||||
alias Mobilizon.Web.Upload
|
||||
|
||||
@maximum_width 1_920
|
||||
@maximum_height 1_080
|
||||
|
||||
def filter(%Mobilizon.Web.Upload{
|
||||
tempfile: file,
|
||||
content_type: "image" <> _,
|
||||
width: width,
|
||||
height: height
|
||||
}) do
|
||||
def filter(
|
||||
%Upload{
|
||||
tempfile: file,
|
||||
content_type: "image" <> _,
|
||||
width: width,
|
||||
height: height
|
||||
} = upload
|
||||
) do
|
||||
{new_width, new_height} = sizes = limit_sizes({width, height})
|
||||
|
||||
file
|
||||
|> Mogrify.open()
|
||||
|> Mogrify.resize(string(limit_sizes({width, height})))
|
||||
|> Mogrify.resize(string(sizes))
|
||||
|> Mogrify.save(in_place: true)
|
||||
|
||||
{:ok, :filtered}
|
||||
{:ok, :filtered, %Upload{upload | width: new_width, height: new_height}}
|
||||
end
|
||||
|
||||
def filter(_), do: {:ok, :noop}
|
||||
|
|
|
@ -62,9 +62,10 @@ defmodule Mobilizon.Web.Upload do
|
|||
path: String.t(),
|
||||
size: 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()}
|
||||
def store(upload, opts \\ []) do
|
||||
|
|
Loading…
Reference in a new issue