Merge branch 'improvements' into 'master'
Some various improvements See merge request framasoft/mobilizon!936
This commit is contained in:
commit
f97fe9403c
@ -66,6 +66,7 @@ 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.Optimize
|
||||
],
|
||||
allow_list_mime_types: ["image/gif", "image/jpeg", "image/png", "image/webp"],
|
||||
|
@ -30,6 +30,7 @@
|
||||
"@tiptap/starter-kit": "^2.0.0-beta.37",
|
||||
"@tiptap/vue-2": "^2.0.0-beta.21",
|
||||
"apollo-absinthe-upload-link": "^1.5.0",
|
||||
"blurhash": "^1.1.3",
|
||||
"buefy": "^0.9.0",
|
||||
"bulma-divider": "^0.2.0",
|
||||
"core-js": "^3.6.4",
|
||||
|
@ -16,6 +16,7 @@ import { IMember } from "@/types/actor/member.model";
|
||||
import { IComment } from "@/types/comment.model";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { IActivity } from "@/types/activity.model";
|
||||
import uniqBy from "lodash/uniqBy";
|
||||
|
||||
type possibleTypes = { name: string };
|
||||
type schemaType = {
|
||||
@ -58,7 +59,7 @@ export const typePolicies: TypePolicies = {
|
||||
Event: {
|
||||
fields: {
|
||||
participants: paginatedLimitPagination<IParticipant>(["roles"]),
|
||||
commnents: pageLimitPagination<IComment>(),
|
||||
comments: pageLimitPagination<IComment>(),
|
||||
relatedEvents: pageLimitPagination<IEvent>(),
|
||||
},
|
||||
},
|
||||
@ -124,10 +125,6 @@ export function pageLimitPagination<T = Reference>(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
merge(existing, incoming, { args }) {
|
||||
console.log("pageLimitPagination");
|
||||
console.log("existing", existing);
|
||||
console.log("incoming", incoming);
|
||||
// console.log("args", args);
|
||||
if (!incoming) return existing;
|
||||
if (!existing) return incoming; // existing will be empty the first time
|
||||
|
||||
@ -144,9 +141,6 @@ export function paginatedLimitPagination<T = Paginate<any>>(
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
merge(existing, incoming, { args }) {
|
||||
console.log("paginatedLimitPagination");
|
||||
console.log("existing", existing);
|
||||
console.log("incoming", incoming);
|
||||
if (!incoming) return existing;
|
||||
if (!existing) return incoming; // existing will be empty the first time
|
||||
|
||||
@ -168,7 +162,6 @@ function doMerge<T = any>(
|
||||
if (args) {
|
||||
// Assume an page of 1 if args.page omitted.
|
||||
const { page = 1, limit = 10 } = args;
|
||||
console.log("args, selected", { page, limit });
|
||||
for (let i = 0; i < incoming.length; ++i) {
|
||||
merged[(page - 1) * limit + i] = incoming[i];
|
||||
}
|
||||
@ -179,7 +172,8 @@ function doMerge<T = any>(
|
||||
// exception here, instead of recovering by appending incoming
|
||||
// onto the existing array.
|
||||
res = [...merged, ...incoming];
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
res = uniqBy(res, (elem: any) => elem.__ref);
|
||||
}
|
||||
console.log("doMerge returns", res);
|
||||
return res;
|
||||
}
|
||||
|
@ -64,14 +64,11 @@ $color-black: #000;
|
||||
}
|
||||
|
||||
body {
|
||||
// background: #f7f8fa;
|
||||
background: $body-background-color;
|
||||
font-family: BlinkMacSystemFont, Roboto, Oxygen, Ubuntu, Cantarell, "Segoe UI",
|
||||
"Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
|
||||
/*main {*/
|
||||
/* margin: 1rem auto 0;*/
|
||||
/*}*/
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#mobilizon > .container > .message {
|
||||
|
@ -1,10 +1,13 @@
|
||||
<template>
|
||||
<li :class="{ reply: comment.inReplyToComment }">
|
||||
<article
|
||||
class="media"
|
||||
:class="{ selected: commentSelected }"
|
||||
:id="commentId"
|
||||
<li
|
||||
:class="{
|
||||
reply: comment.inReplyToComment,
|
||||
announcement: comment.isAnnouncement,
|
||||
selected: commentSelected,
|
||||
}"
|
||||
class="comment-element"
|
||||
>
|
||||
<article class="media" :id="commentId">
|
||||
<popover-actor-card
|
||||
:actor="comment.actor"
|
||||
:inline="true"
|
||||
@ -33,14 +36,12 @@
|
||||
<strong :class="{ organizer: commentFromOrganizer }">{{
|
||||
comment.actor.name
|
||||
}}</strong>
|
||||
<small class="has-text-grey">{{
|
||||
usernameWithDomain(comment.actor)
|
||||
}}</small>
|
||||
<small>{{ usernameWithDomain(comment.actor) }}</small>
|
||||
</span>
|
||||
<a v-else class="comment-link has-text-grey" :href="commentURL">
|
||||
<a v-else class="comment-link" :href="commentURL">
|
||||
<span>{{ $t("[deleted]") }}</span>
|
||||
</a>
|
||||
<a class="comment-link has-text-grey" :href="commentURL">
|
||||
<a class="comment-link" :href="commentURL">
|
||||
<small>{{
|
||||
formatDistanceToNow(new Date(comment.updatedAt), {
|
||||
locale: $dateFnsLocale,
|
||||
@ -265,7 +266,7 @@ export default class Comment extends Vue {
|
||||
}
|
||||
|
||||
get commentSelected(): boolean {
|
||||
return this.commentId === this.$route.hash;
|
||||
return `#${this.commentId}` === this.$route.hash;
|
||||
}
|
||||
|
||||
get commentFromOrganizer(): boolean {
|
||||
@ -276,13 +277,13 @@ export default class Comment extends Vue {
|
||||
|
||||
get commentId(): string {
|
||||
if (this.comment.originComment)
|
||||
return `#comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
|
||||
return `#comment-${this.comment.uuid}`;
|
||||
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
|
||||
return `comment-${this.comment.uuid}`;
|
||||
}
|
||||
|
||||
get commentURL(): string {
|
||||
if (!this.comment.local && this.comment.url) return this.comment.url;
|
||||
return this.commentId;
|
||||
return `#${this.commentId}`;
|
||||
}
|
||||
|
||||
reportModal(): void {
|
||||
@ -368,6 +369,7 @@ form.reply {
|
||||
a.comment-link {
|
||||
text-decoration: none;
|
||||
margin-left: 5px;
|
||||
color: $text;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@ -378,6 +380,41 @@ a.comment-link {
|
||||
}
|
||||
}
|
||||
|
||||
.comment-element {
|
||||
padding: 0.25rem;
|
||||
border-radius: 5px;
|
||||
|
||||
&.announcement {
|
||||
background: $purple-2;
|
||||
|
||||
small {
|
||||
color: hsl(0, 0%, 21%);
|
||||
}
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background-color: $violet-1;
|
||||
color: $white;
|
||||
.reply-btn,
|
||||
small,
|
||||
strong,
|
||||
.icons button {
|
||||
color: $white;
|
||||
}
|
||||
a.comment-link:hover {
|
||||
text-decoration: underline;
|
||||
text-decoration-color: $white;
|
||||
small {
|
||||
color: $purple-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.media-left {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.root-comment .replies {
|
||||
display: flex;
|
||||
|
||||
@ -402,6 +439,7 @@ a.comment-link {
|
||||
}
|
||||
|
||||
.media .media-content {
|
||||
overflow-x: initial;
|
||||
.content .editor-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -433,16 +471,12 @@ a.comment-link {
|
||||
|
||||
.level-item.reply-btn {
|
||||
font-weight: bold;
|
||||
color: $primary;
|
||||
color: $violet-2;
|
||||
}
|
||||
|
||||
article {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
&.selected {
|
||||
background-color: lighten($secondary, 30%);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-replies {
|
||||
|
@ -74,7 +74,7 @@
|
||||
@delete-comment="deleteComment"
|
||||
/>
|
||||
</transition-group>
|
||||
<div class="no-comments" key="no-comments">
|
||||
<div v-else class="no-comments" key="no-comments">
|
||||
<span>{{ $t("No comments yet") }}</span>
|
||||
</div>
|
||||
</transition-group>
|
||||
@ -311,7 +311,18 @@ export default class CommentTree extends Vue {
|
||||
return this.comments
|
||||
.filter((comment) => comment.inReplyToComment == null)
|
||||
.sort((a, b) => {
|
||||
if (a.updatedAt && b.updatedAt) {
|
||||
if (a.isAnnouncement !== b.isAnnouncement) {
|
||||
return (
|
||||
(b.isAnnouncement === true ? 1 : 0) -
|
||||
(a.isAnnouncement === true ? 1 : 0)
|
||||
);
|
||||
}
|
||||
if (a.publishedAt && b.publishedAt) {
|
||||
return (
|
||||
new Date(b.publishedAt).getTime() -
|
||||
new Date(a.publishedAt).getTime()
|
||||
);
|
||||
} else if (a.updatedAt && b.updatedAt) {
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
|
@ -12,9 +12,17 @@
|
||||
</docs>
|
||||
|
||||
<template>
|
||||
<time class="datetime-container" :datetime="dateObj.getUTCSeconds()">
|
||||
<span class="month">{{ month }}</span>
|
||||
<time
|
||||
class="datetime-container"
|
||||
:class="{ small }"
|
||||
:datetime="dateObj.getUTCSeconds()"
|
||||
:style="`--small: ${smallStyle}`"
|
||||
>
|
||||
<div class="datetime-container-header" />
|
||||
<div class="datetime-container-content">
|
||||
<span class="day">{{ day }}</span>
|
||||
<span class="month">{{ month }}</span>
|
||||
</div>
|
||||
</time>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
@ -26,6 +34,7 @@ export default class DateCalendarIcon extends Vue {
|
||||
* `date` can be a string or an actual date object.
|
||||
*/
|
||||
@Prop({ required: true }) date!: string;
|
||||
@Prop({ required: false, default: false }) small!: boolean;
|
||||
|
||||
get dateObj(): Date {
|
||||
return new Date(this.$props.date);
|
||||
@ -38,28 +47,41 @@ export default class DateCalendarIcon extends Vue {
|
||||
get day(): string {
|
||||
return this.dateObj.toLocaleString(undefined, { day: "numeric" });
|
||||
}
|
||||
get smallStyle(): string {
|
||||
return this.small ? "1.2" : "2";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
time.datetime-container {
|
||||
background: $backgrounds;
|
||||
border: 1px solid $borders;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
/*height: 50px;*/
|
||||
width: 50px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
overflow-y: hidden;
|
||||
overflow-x: hidden;
|
||||
align-items: stretch;
|
||||
width: calc(40px * var(--small));
|
||||
box-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
|
||||
height: calc(40px * var(--small));
|
||||
background: #fff;
|
||||
|
||||
.datetime-container-header {
|
||||
height: calc(10px * var(--small));
|
||||
background: #f3425f;
|
||||
}
|
||||
.datetime-container-content {
|
||||
height: calc(30px * var(--small));
|
||||
}
|
||||
|
||||
span {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: $violet-3;
|
||||
|
||||
&.month {
|
||||
color: $danger;
|
||||
padding: 2px 0;
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
@ -67,9 +89,8 @@ time.datetime-container {
|
||||
}
|
||||
|
||||
&.day {
|
||||
color: $violet-3;
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
font-size: calc(1rem * var(--small));
|
||||
line-height: calc(1rem * var(--small));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
33
js/src/components/Event/EventBanner.vue
Normal file
33
js/src/components/Event/EventBanner.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div class="banner-container">
|
||||
<lazy-image-wrapper :picture="picture" />
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IMedia } from "@/types/media.model";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import LazyImageWrapper from "../Image/LazyImageWrapper.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LazyImageWrapper,
|
||||
},
|
||||
})
|
||||
export default class EventBanner extends Vue {
|
||||
@Prop({ required: true, default: null })
|
||||
picture!: IMedia | null;
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.banner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 30vh;
|
||||
}
|
||||
::v-deep img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
}
|
||||
</style>
|
@ -4,12 +4,11 @@
|
||||
:to="{ name: 'Event', params: { uuid: event.uuid } }"
|
||||
>
|
||||
<div class="card-image">
|
||||
<figure
|
||||
class="image is-16by9"
|
||||
:style="`background-image: url('${
|
||||
event.picture ? event.picture.url : '/img/mobilizon_default_card.png'
|
||||
}')`"
|
||||
>
|
||||
<figure class="image is-16by9">
|
||||
<lazy-image-wrapper
|
||||
:picture="event.picture"
|
||||
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
|
||||
/>
|
||||
<div
|
||||
class="tag-container"
|
||||
v-if="event.tags || event.status !== EventStatus.CONFIRMED"
|
||||
@ -34,6 +33,7 @@
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<date-calendar-icon
|
||||
:small="true"
|
||||
v-if="!mergedOptions.hideDate"
|
||||
:date="event.beginsOn"
|
||||
/>
|
||||
@ -103,6 +103,7 @@
|
||||
import { IEvent, IEventCardOptions } from "@/types/event.model";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
|
||||
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
|
||||
import { Actor, Person } from "@/types/actor";
|
||||
import { EventStatus, ParticipantRole } from "@/types/enums";
|
||||
import RouteName from "../../router/name";
|
||||
@ -110,6 +111,7 @@ import RouteName from "../../router/name";
|
||||
@Component({
|
||||
components: {
|
||||
DateCalendarIcon,
|
||||
LazyImageWrapper,
|
||||
},
|
||||
})
|
||||
export default class EventCard extends Vue {
|
||||
@ -220,6 +222,22 @@ a.card {
|
||||
.card-content {
|
||||
padding: 0.5rem;
|
||||
|
||||
& > .media {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > .media-left {
|
||||
margin-top: -15px;
|
||||
height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 15px;
|
||||
margin-left: 0rem;
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.25rem;
|
||||
|
@ -7,11 +7,14 @@
|
||||
{{ displayNameAndUsername(participation.actor) }}
|
||||
</div>
|
||||
<div class="list-card">
|
||||
<div class="date-component">
|
||||
<date-calendar-icon
|
||||
:date="participation.event.beginsOn"
|
||||
:small="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="title-wrapper">
|
||||
<div class="date-component">
|
||||
<date-calendar-icon :date="participation.event.beginsOn" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
@ -21,7 +24,7 @@
|
||||
<h3 class="title">{{ participation.event.title }}</h3>
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="participation-actor has-text-grey">
|
||||
<div class="participation-actor">
|
||||
<span>
|
||||
<b-icon
|
||||
icon="earth"
|
||||
@ -47,8 +50,11 @@
|
||||
"
|
||||
>{{ participation.event.physicalAddress.locality }} -</span
|
||||
>
|
||||
<span>
|
||||
<i18n tag="span" path="Organized by {name}">
|
||||
<i18n
|
||||
tag="span"
|
||||
path="Organized by {name}"
|
||||
v-if="organizerActor.id !== currentActor.id"
|
||||
>
|
||||
<popover-actor-card
|
||||
slot="name"
|
||||
:actor="organizerActor"
|
||||
@ -57,7 +63,7 @@
|
||||
{{ organizerActor.displayName() }}
|
||||
</popover-actor-card>
|
||||
</i18n>
|
||||
</span>
|
||||
<span v-else>{{ $t("Organized by you") }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
@ -113,7 +119,9 @@
|
||||
$tc(
|
||||
"{count} requests waiting",
|
||||
participation.event.participantStats.notApproved,
|
||||
{ count: participation.event.participantStats.notApproved }
|
||||
{
|
||||
count: participation.event.participantStats.notApproved,
|
||||
}
|
||||
)
|
||||
}}
|
||||
</b-button>
|
||||
@ -344,6 +352,7 @@ article.box {
|
||||
.list-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
|
||||
.actions {
|
||||
padding-right: 7.5px;
|
||||
|
@ -4,7 +4,7 @@
|
||||
<div class="content column">
|
||||
<div class="title-wrapper">
|
||||
<div class="date-component">
|
||||
<date-calendar-icon :date="event.beginsOn" />
|
||||
<date-calendar-icon :date="event.beginsOn" :small="true" />
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
|
||||
|
@ -23,7 +23,7 @@ export default class EventMetadataBlock extends Vue {
|
||||
h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
color: #f7ba30;
|
||||
color: $violet;
|
||||
}
|
||||
|
||||
div.eventMetadataBlock {
|
||||
|
@ -3,7 +3,11 @@
|
||||
class="event-minimalist-card-wrapper"
|
||||
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
|
||||
>
|
||||
<date-calendar-icon class="calendar-icon" :date="event.beginsOn" />
|
||||
<date-calendar-icon
|
||||
class="calendar-icon"
|
||||
:date="event.beginsOn"
|
||||
:small="true"
|
||||
/>
|
||||
<div class="title-info-wrapper">
|
||||
<p class="event-minimalist-title">{{ event.title }}</p>
|
||||
<p v-if="event.physicalAddress" class="has-text-grey">
|
||||
|
@ -66,7 +66,9 @@ export default class OrganizerPicker extends Vue {
|
||||
return this.value;
|
||||
}
|
||||
if (this.currentActor) {
|
||||
return this.currentActor;
|
||||
return this.identities.find(
|
||||
(identity) => identity.id === this.currentActor.id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
@ -110,6 +110,7 @@ import { IActor, IGroup, IPerson, usernameWithDomain } from "../../types/actor";
|
||||
import OrganizerPicker from "./OrganizerPicker.vue";
|
||||
import {
|
||||
CURRENT_ACTOR_CLIENT,
|
||||
IDENTITIES,
|
||||
LOGGED_USER_MEMBERSHIPS,
|
||||
} from "../../graphql/actor";
|
||||
import { Paginate } from "../../types/paginate";
|
||||
@ -152,6 +153,7 @@ const MEMBER_ROLES = [
|
||||
},
|
||||
update: (data) => data.loggedUser.memberships,
|
||||
},
|
||||
identities: IDENTITIES,
|
||||
},
|
||||
})
|
||||
export default class OrganizerPickerWrapper extends Vue {
|
||||
@ -161,6 +163,8 @@ export default class OrganizerPickerWrapper extends Vue {
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
identities!: IPerson[];
|
||||
|
||||
isComponentModalActive = false;
|
||||
|
||||
@Prop({ type: Array, required: false, default: () => [] })
|
||||
@ -200,7 +204,9 @@ export default class OrganizerPickerWrapper extends Vue {
|
||||
return this.value;
|
||||
}
|
||||
if (this.currentActor) {
|
||||
return this.currentActor;
|
||||
return this.identities.find(
|
||||
(identity) => identity.id === this.currentActor.id
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
@ -115,12 +115,13 @@ footer.footer {
|
||||
flex: 1;
|
||||
max-width: 40rem;
|
||||
@include mobile {
|
||||
max-width: 400px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
div.content {
|
||||
flex: 1;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
ul {
|
||||
@ -131,6 +132,7 @@ footer.footer {
|
||||
li {
|
||||
display: inline-flex;
|
||||
margin: auto 5px;
|
||||
padding: 2px 0;
|
||||
a {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
@ -143,9 +145,12 @@ footer.footer {
|
||||
text-decoration-color: $secondary;
|
||||
}
|
||||
|
||||
::v-deep span.select select {
|
||||
::v-deep span.select {
|
||||
select,
|
||||
option {
|
||||
background: $background-color;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
34
js/src/components/Image/BlurhashImg.vue
Normal file
34
js/src/components/Image/BlurhashImg.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<canvas ref="canvas" width="32" height="32" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { decode } from "blurhash";
|
||||
import { Component, Prop, Ref, Vue } from "vue-property-decorator";
|
||||
|
||||
@Component
|
||||
export default class 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);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
canvas {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
117
js/src/components/Image/LazyImage.vue
Normal file
117
js/src/components/Image/LazyImage.vue
Normal file
@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="wrapper" v-bind="$attrs">
|
||||
<div class="relative container">
|
||||
<!-- Show the placeholder as background -->
|
||||
<blurhash-img
|
||||
v-if="blurhash"
|
||||
:hash="blurhash"
|
||||
:aspect-ratio="height / width"
|
||||
class="top-0 left-0 transition-opacity duration-500"
|
||||
:class="isLoaded ? 'opacity-0' : 'opacity-100'"
|
||||
/>
|
||||
|
||||
<!-- Show the real image on the top and fade in after loading -->
|
||||
<img
|
||||
ref="image"
|
||||
:width="width"
|
||||
:height="height"
|
||||
class="absolute top-0 left-0 transition-opacity duration-500"
|
||||
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Prop, Component, Vue, Ref, Watch } from "vue-property-decorator";
|
||||
import BlurhashImg from "./BlurhashImg.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
BlurhashImg,
|
||||
},
|
||||
})
|
||||
export default class LazyImage extends Vue {
|
||||
@Prop({ type: String, required: true }) src!: string;
|
||||
@Prop({ type: String, required: false, default: null }) blurhash!: string;
|
||||
@Prop({ type: Number, default: 1 }) width!: number;
|
||||
@Prop({ type: Number, default: 1 }) height!: number;
|
||||
|
||||
inheritAttrs = false;
|
||||
isLoaded = false;
|
||||
|
||||
observer!: IntersectionObserver;
|
||||
|
||||
@Ref("wrapper") readonly wrapper!: any;
|
||||
@Ref("image") image!: any;
|
||||
|
||||
mounted(): void {
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
this.onEnter();
|
||||
}
|
||||
});
|
||||
|
||||
this.observer.observe(this.wrapper);
|
||||
}
|
||||
|
||||
unmounted(): void {
|
||||
this.observer.disconnect();
|
||||
}
|
||||
|
||||
onEnter(): void {
|
||||
// Image is visible (means: has entered the viewport),
|
||||
// so start loading by setting the src attribute
|
||||
this.image.src = this.src;
|
||||
|
||||
this.image.onload = () => {
|
||||
// Image is loaded, so start fading in
|
||||
this.isLoaded = true;
|
||||
};
|
||||
}
|
||||
|
||||
@Watch("src")
|
||||
updateImageWithSrcChange(): void {
|
||||
this.onEnter();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.top-0 {
|
||||
top: 0;
|
||||
}
|
||||
.left-0 {
|
||||
left: 0;
|
||||
}
|
||||
.opacity-100 {
|
||||
opacity: 100%;
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0;
|
||||
}
|
||||
.transition-opacity {
|
||||
transition-property: opacity;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.duration-500 {
|
||||
transition-duration: 0.5s;
|
||||
}
|
||||
.wrapper,
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: 50% 50%;
|
||||
}
|
||||
</style>
|
40
js/src/components/Image/LazyImageWrapper.vue
Normal file
40
js/src/components/Image/LazyImageWrapper.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<lazy-image
|
||||
v-if="pictureOrDefault.url !== undefined"
|
||||
:src="pictureOrDefault.url"
|
||||
:width="pictureOrDefault.metadata.width"
|
||||
:height="pictureOrDefault.metadata.height"
|
||||
:blurhash="pictureOrDefault.metadata.blurhash"
|
||||
/>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { IMedia } from "@/types/media.model";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import LazyImage from "../Image/LazyImage.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
LazyImage,
|
||||
},
|
||||
})
|
||||
export default class LazyImageWrapper extends Vue {
|
||||
@Prop({ required: true, default: null })
|
||||
picture!: IMedia | null;
|
||||
|
||||
get pictureOrDefault(): Partial<IMedia> {
|
||||
return {
|
||||
url:
|
||||
this?.picture?.url === null
|
||||
? "/img/mobilizon_default_card.png"
|
||||
: this?.picture?.url,
|
||||
metadata: {
|
||||
width: this?.picture?.metadata?.width || 630,
|
||||
height: this?.picture?.metadata?.height || 350,
|
||||
blurhash:
|
||||
this?.picture?.metadata?.blurhash ||
|
||||
"MCHKI4El-P-U}+={R-WWoes,Iu-P=?R,xD",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,35 +1,40 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
const $addressFragment = `
|
||||
id,
|
||||
description,
|
||||
geom,
|
||||
street,
|
||||
locality,
|
||||
postalCode,
|
||||
region,
|
||||
country,
|
||||
type,
|
||||
url,
|
||||
export const ADDRESS_FRAGMENT = gql`
|
||||
fragment AdressFragment on Address {
|
||||
id
|
||||
description
|
||||
geom
|
||||
street
|
||||
locality
|
||||
postalCode
|
||||
region
|
||||
country
|
||||
type
|
||||
url
|
||||
originId
|
||||
}
|
||||
`;
|
||||
|
||||
export const ADDRESS = gql`
|
||||
query ($query: String!, $locale: String, $type: AddressSearchType) {
|
||||
searchAddress(
|
||||
query: $query,
|
||||
locale: $locale,
|
||||
type: $type
|
||||
) {
|
||||
${$addressFragment}
|
||||
searchAddress(query: $query, locale: $locale, type: $type) {
|
||||
...AdressFragment
|
||||
}
|
||||
}
|
||||
${ADDRESS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const REVERSE_GEOCODE = gql`
|
||||
query ($latitude: Float!, $longitude: Float!, $zoom: Int, $locale: String) {
|
||||
reverseGeocode(latitude: $latitude, longitude: $longitude, zoom: $zoom, locale: $locale) {
|
||||
${$addressFragment}
|
||||
reverseGeocode(
|
||||
latitude: $latitude
|
||||
longitude: $longitude
|
||||
zoom: $zoom
|
||||
locale: $locale
|
||||
) {
|
||||
...AdressFragment
|
||||
}
|
||||
}
|
||||
${ADDRESS_FRAGMENT}
|
||||
`;
|
||||
|
@ -1,179 +1,189 @@
|
||||
import gql from "graphql-tag";
|
||||
import { ADDRESS_FRAGMENT } from "./address";
|
||||
import { TAG_FRAGMENT } from "./tags";
|
||||
|
||||
const participantQuery = `
|
||||
role,
|
||||
id,
|
||||
const PARTICIPANT_QUERY_FRAGMENT = gql`
|
||||
fragment ParticipantQuery on Participant {
|
||||
role
|
||||
id
|
||||
actor {
|
||||
preferredUsername,
|
||||
preferredUsername
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
},
|
||||
name,
|
||||
id,
|
||||
}
|
||||
name
|
||||
id
|
||||
domain
|
||||
},
|
||||
}
|
||||
event {
|
||||
id,
|
||||
id
|
||||
uuid
|
||||
},
|
||||
}
|
||||
metadata {
|
||||
cancellationToken,
|
||||
cancellationToken
|
||||
message
|
||||
},
|
||||
}
|
||||
insertedAt
|
||||
`;
|
||||
|
||||
const participantsQuery = `
|
||||
total,
|
||||
elements {
|
||||
${participantQuery}
|
||||
}
|
||||
`;
|
||||
|
||||
const physicalAddressQuery = `
|
||||
description,
|
||||
street,
|
||||
locality,
|
||||
postalCode,
|
||||
region,
|
||||
country,
|
||||
geom,
|
||||
type,
|
||||
id,
|
||||
originId
|
||||
const PARTICIPANTS_QUERY_FRAGMENT = gql`
|
||||
fragment ParticipantsQuery on PaginatedParticipantList {
|
||||
total
|
||||
elements {
|
||||
...ParticipantQuery
|
||||
}
|
||||
}
|
||||
${PARTICIPANT_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
const tagsQuery = `
|
||||
id,
|
||||
slug,
|
||||
title
|
||||
`;
|
||||
|
||||
const optionsQuery = `
|
||||
maximumAttendeeCapacity,
|
||||
remainingAttendeeCapacity,
|
||||
showRemainingAttendeeCapacity,
|
||||
anonymousParticipation,
|
||||
showStartTime,
|
||||
showEndTime,
|
||||
const EVENT_OPTIONS_FRAGMENT = gql`
|
||||
fragment EventOptions on EventOptions {
|
||||
maximumAttendeeCapacity
|
||||
remainingAttendeeCapacity
|
||||
showRemainingAttendeeCapacity
|
||||
anonymousParticipation
|
||||
showStartTime
|
||||
showEndTime
|
||||
offers {
|
||||
price,
|
||||
priceCurrency,
|
||||
price
|
||||
priceCurrency
|
||||
url
|
||||
},
|
||||
}
|
||||
participationConditions {
|
||||
title,
|
||||
content,
|
||||
title
|
||||
content
|
||||
url
|
||||
},
|
||||
attendees,
|
||||
program,
|
||||
commentModeration,
|
||||
showParticipationPrice,
|
||||
hideOrganizerWhenGroupEvent,
|
||||
__typename
|
||||
}
|
||||
attendees
|
||||
program
|
||||
commentModeration
|
||||
showParticipationPrice
|
||||
hideOrganizerWhenGroupEvent
|
||||
}
|
||||
`;
|
||||
|
||||
const FULL_EVENT_FRAGMENT = gql`
|
||||
fragment FullEvent on Event {
|
||||
id
|
||||
uuid
|
||||
url
|
||||
local
|
||||
title
|
||||
description
|
||||
beginsOn
|
||||
endsOn
|
||||
status
|
||||
visibility
|
||||
joinOptions
|
||||
draft
|
||||
picture {
|
||||
id
|
||||
url
|
||||
name
|
||||
metadata {
|
||||
width
|
||||
height
|
||||
blurhash
|
||||
}
|
||||
}
|
||||
publishAt
|
||||
onlineAddress
|
||||
phoneAddress
|
||||
physicalAddress {
|
||||
...AdressFragment
|
||||
}
|
||||
organizerActor {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
}
|
||||
preferredUsername
|
||||
domain
|
||||
name
|
||||
url
|
||||
id
|
||||
summary
|
||||
}
|
||||
contacts {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
}
|
||||
preferredUsername
|
||||
name
|
||||
summary
|
||||
domain
|
||||
url
|
||||
id
|
||||
}
|
||||
attributedTo {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
}
|
||||
preferredUsername
|
||||
name
|
||||
summary
|
||||
domain
|
||||
url
|
||||
id
|
||||
}
|
||||
participantStats {
|
||||
going
|
||||
notApproved
|
||||
participant
|
||||
}
|
||||
tags {
|
||||
...TagFragment
|
||||
}
|
||||
relatedEvents {
|
||||
id
|
||||
uuid
|
||||
title
|
||||
beginsOn
|
||||
picture {
|
||||
id
|
||||
url
|
||||
name
|
||||
metadata {
|
||||
width
|
||||
height
|
||||
blurhash
|
||||
}
|
||||
}
|
||||
physicalAddress {
|
||||
id
|
||||
description
|
||||
}
|
||||
organizerActor {
|
||||
id
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
}
|
||||
preferredUsername
|
||||
domain
|
||||
name
|
||||
}
|
||||
}
|
||||
options {
|
||||
...EventOptions
|
||||
}
|
||||
}
|
||||
${ADDRESS_FRAGMENT}
|
||||
${TAG_FRAGMENT}
|
||||
${EVENT_OPTIONS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const FETCH_EVENT = gql`
|
||||
query FetchEvent($uuid: UUID!) {
|
||||
event(uuid: $uuid) {
|
||||
id,
|
||||
uuid,
|
||||
url,
|
||||
local,
|
||||
title,
|
||||
description,
|
||||
beginsOn,
|
||||
endsOn,
|
||||
status,
|
||||
visibility,
|
||||
joinOptions,
|
||||
draft,
|
||||
picture {
|
||||
id
|
||||
url
|
||||
name
|
||||
},
|
||||
publishAt,
|
||||
onlineAddress,
|
||||
phoneAddress,
|
||||
physicalAddress {
|
||||
${physicalAddressQuery}
|
||||
}
|
||||
organizerActor {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
},
|
||||
preferredUsername,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
id,
|
||||
summary
|
||||
},
|
||||
contacts {
|
||||
avatar {
|
||||
id
|
||||
url,
|
||||
}
|
||||
preferredUsername,
|
||||
name,
|
||||
summary,
|
||||
domain,
|
||||
url,
|
||||
id
|
||||
},
|
||||
attributedTo {
|
||||
avatar {
|
||||
id
|
||||
url,
|
||||
}
|
||||
preferredUsername,
|
||||
name,
|
||||
summary,
|
||||
domain,
|
||||
url,
|
||||
id
|
||||
},
|
||||
participantStats {
|
||||
going,
|
||||
notApproved,
|
||||
participant
|
||||
},
|
||||
tags {
|
||||
${tagsQuery}
|
||||
},
|
||||
relatedEvents {
|
||||
id
|
||||
uuid,
|
||||
title,
|
||||
beginsOn,
|
||||
picture {
|
||||
id,
|
||||
url
|
||||
}
|
||||
physicalAddress {
|
||||
id
|
||||
description
|
||||
},
|
||||
organizerActor {
|
||||
id
|
||||
avatar {
|
||||
id
|
||||
url,
|
||||
},
|
||||
preferredUsername,
|
||||
domain,
|
||||
name,
|
||||
}
|
||||
},
|
||||
options {
|
||||
${optionsQuery}
|
||||
}
|
||||
...FullEvent
|
||||
}
|
||||
}
|
||||
${FULL_EVENT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const FETCH_EVENT_BASIC = gql`
|
||||
@ -240,252 +250,129 @@ export const FETCH_EVENTS = gql`
|
||||
# },
|
||||
category
|
||||
tags {
|
||||
slug
|
||||
title
|
||||
...TagFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${TAG_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CREATE_EVENT = gql`
|
||||
mutation createEvent(
|
||||
$organizerActorId: ID!,
|
||||
$attributedToId: ID,
|
||||
$title: String!,
|
||||
$description: String!,
|
||||
$beginsOn: DateTime!,
|
||||
$endsOn: DateTime,
|
||||
$status: EventStatus,
|
||||
$visibility: EventVisibility,
|
||||
$joinOptions: EventJoinOptions,
|
||||
$draft: Boolean,
|
||||
$tags: [String],
|
||||
$picture: MediaInput,
|
||||
$onlineAddress: String,
|
||||
$phoneAddress: String,
|
||||
$category: String,
|
||||
$physicalAddress: AddressInput,
|
||||
$options: EventOptionsInput,
|
||||
$organizerActorId: ID!
|
||||
$attributedToId: ID
|
||||
$title: String!
|
||||
$description: String!
|
||||
$beginsOn: DateTime!
|
||||
$endsOn: DateTime
|
||||
$status: EventStatus
|
||||
$visibility: EventVisibility
|
||||
$joinOptions: EventJoinOptions
|
||||
$draft: Boolean
|
||||
$tags: [String]
|
||||
$picture: MediaInput
|
||||
$onlineAddress: String
|
||||
$phoneAddress: String
|
||||
$category: String
|
||||
$physicalAddress: AddressInput
|
||||
$options: EventOptionsInput
|
||||
$contacts: [Contact]
|
||||
) {
|
||||
createEvent(
|
||||
organizerActorId: $organizerActorId,
|
||||
attributedToId: $attributedToId,
|
||||
title: $title,
|
||||
description: $description,
|
||||
beginsOn: $beginsOn,
|
||||
endsOn: $endsOn,
|
||||
status: $status,
|
||||
visibility: $visibility,
|
||||
joinOptions: $joinOptions,
|
||||
draft: $draft,
|
||||
tags: $tags,
|
||||
picture: $picture,
|
||||
onlineAddress: $onlineAddress,
|
||||
phoneAddress: $phoneAddress,
|
||||
category: $category,
|
||||
organizerActorId: $organizerActorId
|
||||
attributedToId: $attributedToId
|
||||
title: $title
|
||||
description: $description
|
||||
beginsOn: $beginsOn
|
||||
endsOn: $endsOn
|
||||
status: $status
|
||||
visibility: $visibility
|
||||
joinOptions: $joinOptions
|
||||
draft: $draft
|
||||
tags: $tags
|
||||
picture: $picture
|
||||
onlineAddress: $onlineAddress
|
||||
phoneAddress: $phoneAddress
|
||||
category: $category
|
||||
physicalAddress: $physicalAddress
|
||||
options: $options,
|
||||
options: $options
|
||||
contacts: $contacts
|
||||
) {
|
||||
id,
|
||||
uuid,
|
||||
title,
|
||||
url,
|
||||
local,
|
||||
description,
|
||||
beginsOn,
|
||||
endsOn,
|
||||
status,
|
||||
visibility,
|
||||
joinOptions,
|
||||
draft,
|
||||
picture {
|
||||
id
|
||||
url
|
||||
},
|
||||
publishAt,
|
||||
category,
|
||||
onlineAddress,
|
||||
phoneAddress,
|
||||
physicalAddress {
|
||||
${physicalAddressQuery}
|
||||
},
|
||||
attributedTo {
|
||||
id,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
preferredUsername,
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
}
|
||||
},
|
||||
organizerActor {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
},
|
||||
preferredUsername,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
id,
|
||||
},
|
||||
contacts {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
},
|
||||
preferredUsername,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
id,
|
||||
},
|
||||
participantStats {
|
||||
going,
|
||||
notApproved,
|
||||
participant
|
||||
},
|
||||
tags {
|
||||
${tagsQuery}
|
||||
},
|
||||
options {
|
||||
${optionsQuery}
|
||||
}
|
||||
...FullEvent
|
||||
}
|
||||
}
|
||||
${FULL_EVENT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const EDIT_EVENT = gql`
|
||||
mutation updateEvent(
|
||||
$id: ID!,
|
||||
$title: String,
|
||||
$description: String,
|
||||
$beginsOn: DateTime,
|
||||
$endsOn: DateTime,
|
||||
$status: EventStatus,
|
||||
$visibility: EventVisibility,
|
||||
$joinOptions: EventJoinOptions,
|
||||
$draft: Boolean,
|
||||
$tags: [String],
|
||||
$picture: MediaInput,
|
||||
$onlineAddress: String,
|
||||
$phoneAddress: String,
|
||||
$organizerActorId: ID,
|
||||
$attributedToId: ID,
|
||||
$category: String,
|
||||
$physicalAddress: AddressInput,
|
||||
$options: EventOptionsInput,
|
||||
$id: ID!
|
||||
$title: String
|
||||
$description: String
|
||||
$beginsOn: DateTime
|
||||
$endsOn: DateTime
|
||||
$status: EventStatus
|
||||
$visibility: EventVisibility
|
||||
$joinOptions: EventJoinOptions
|
||||
$draft: Boolean
|
||||
$tags: [String]
|
||||
$picture: MediaInput
|
||||
$onlineAddress: String
|
||||
$phoneAddress: String
|
||||
$organizerActorId: ID
|
||||
$attributedToId: ID
|
||||
$category: String
|
||||
$physicalAddress: AddressInput
|
||||
$options: EventOptionsInput
|
||||
$contacts: [Contact]
|
||||
) {
|
||||
updateEvent(
|
||||
eventId: $id,
|
||||
title: $title,
|
||||
description: $description,
|
||||
beginsOn: $beginsOn,
|
||||
endsOn: $endsOn,
|
||||
status: $status,
|
||||
visibility: $visibility,
|
||||
joinOptions: $joinOptions,
|
||||
draft: $draft,
|
||||
tags: $tags,
|
||||
picture: $picture,
|
||||
onlineAddress: $onlineAddress,
|
||||
phoneAddress: $phoneAddress,
|
||||
organizerActorId: $organizerActorId,
|
||||
attributedToId: $attributedToId,
|
||||
category: $category,
|
||||
eventId: $id
|
||||
title: $title
|
||||
description: $description
|
||||
beginsOn: $beginsOn
|
||||
endsOn: $endsOn
|
||||
status: $status
|
||||
visibility: $visibility
|
||||
joinOptions: $joinOptions
|
||||
draft: $draft
|
||||
tags: $tags
|
||||
picture: $picture
|
||||
onlineAddress: $onlineAddress
|
||||
phoneAddress: $phoneAddress
|
||||
organizerActorId: $organizerActorId
|
||||
attributedToId: $attributedToId
|
||||
category: $category
|
||||
physicalAddress: $physicalAddress
|
||||
options: $options,
|
||||
options: $options
|
||||
contacts: $contacts
|
||||
) {
|
||||
id,
|
||||
uuid,
|
||||
title,
|
||||
url,
|
||||
local,
|
||||
description,
|
||||
beginsOn,
|
||||
endsOn,
|
||||
status,
|
||||
visibility,
|
||||
joinOptions,
|
||||
draft,
|
||||
picture {
|
||||
id
|
||||
url
|
||||
},
|
||||
publishAt,
|
||||
category,
|
||||
onlineAddress,
|
||||
phoneAddress,
|
||||
physicalAddress {
|
||||
${physicalAddressQuery}
|
||||
},
|
||||
attributedTo {
|
||||
id,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
preferredUsername,
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
}
|
||||
},
|
||||
contacts {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
},
|
||||
preferredUsername,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
id,
|
||||
},
|
||||
organizerActor {
|
||||
avatar {
|
||||
id
|
||||
url
|
||||
},
|
||||
preferredUsername,
|
||||
domain,
|
||||
name,
|
||||
url,
|
||||
id,
|
||||
},
|
||||
participantStats {
|
||||
going,
|
||||
notApproved,
|
||||
participant
|
||||
},
|
||||
tags {
|
||||
${tagsQuery}
|
||||
},
|
||||
options {
|
||||
${optionsQuery}
|
||||
}
|
||||
...FullEvent
|
||||
}
|
||||
}
|
||||
${FULL_EVENT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const JOIN_EVENT = gql`
|
||||
mutation JoinEvent($eventId: ID!, $actorId: ID!, $email: String, $message: String, $locale: String) {
|
||||
mutation JoinEvent(
|
||||
$eventId: ID!
|
||||
$actorId: ID!
|
||||
$email: String
|
||||
$message: String
|
||||
$locale: String
|
||||
) {
|
||||
joinEvent(
|
||||
eventId: $eventId,
|
||||
actorId: $actorId,
|
||||
email: $email,
|
||||
message: $message,
|
||||
eventId: $eventId
|
||||
actorId: $actorId
|
||||
email: $email
|
||||
message: $message
|
||||
locale: $locale
|
||||
) {
|
||||
${participantQuery}
|
||||
...ParticipantsQuery
|
||||
}
|
||||
}
|
||||
${PARTICIPANTS_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const LEAVE_EVENT = gql`
|
||||
@ -534,20 +421,21 @@ export const DELETE_EVENT = gql`
|
||||
export const PARTICIPANTS = gql`
|
||||
query Participants($uuid: UUID!, $page: Int, $limit: Int, $roles: String) {
|
||||
event(uuid: $uuid) {
|
||||
id,
|
||||
uuid,
|
||||
title,
|
||||
id
|
||||
uuid
|
||||
title
|
||||
participants(page: $page, limit: $limit, roles: $roles) {
|
||||
${participantsQuery}
|
||||
},
|
||||
...ParticipantsQuery
|
||||
}
|
||||
participantStats {
|
||||
going,
|
||||
notApproved,
|
||||
rejected,
|
||||
going
|
||||
notApproved
|
||||
rejected
|
||||
participant
|
||||
}
|
||||
}
|
||||
}
|
||||
${PARTICIPANTS_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const EVENT_PERSON_PARTICIPATION = gql`
|
||||
|
@ -150,7 +150,7 @@ export default class EventMixin extends mixins(Vue) {
|
||||
}
|
||||
|
||||
private async deleteEvent(event: IEvent) {
|
||||
const eventTitle = event.title;
|
||||
const { title: eventTitle, id: eventId } = event;
|
||||
|
||||
try {
|
||||
await this.$apollo.mutate<IParticipant>({
|
||||
@ -159,6 +159,9 @@ export default class EventMixin extends mixins(Vue) {
|
||||
eventId: event.id,
|
||||
},
|
||||
});
|
||||
const cache = this.$apollo.getClient().cache as InMemoryCache;
|
||||
cache.evict({ id: `Event:${eventId}` });
|
||||
cache.gc();
|
||||
/**
|
||||
* When the event corresponding has been deleted (by the organizer).
|
||||
* A notification is already triggered.
|
||||
|
@ -3,6 +3,7 @@ export interface IMedia {
|
||||
url: string;
|
||||
name: string;
|
||||
alt: string;
|
||||
metadata: IMediaMetadata;
|
||||
}
|
||||
|
||||
export interface IMediaUpload {
|
||||
@ -10,3 +11,9 @@ export interface IMediaUpload {
|
||||
name: string;
|
||||
alt: string | null;
|
||||
}
|
||||
|
||||
export interface IMediaMetadata {
|
||||
width?: number;
|
||||
height?: number;
|
||||
blurhash?: string;
|
||||
}
|
||||
|
@ -58,6 +58,7 @@ $danger-invert: findColorInvert($danger);
|
||||
$link: $primary;
|
||||
$link-invert: $primary-invert;
|
||||
$text: $violet-1;
|
||||
$grey: #757575;
|
||||
|
||||
$colors: map-merge(
|
||||
$colors,
|
||||
|
@ -782,25 +782,18 @@ export default class EditEvent extends Vue {
|
||||
*/
|
||||
private postCreateOrUpdate(store: any, updateEvent: IEvent) {
|
||||
const resultEvent: IEvent = { ...updateEvent };
|
||||
resultEvent.organizerActor = this.event.organizerActor;
|
||||
resultEvent.relatedEvents = [];
|
||||
|
||||
store.writeQuery({
|
||||
query: FETCH_EVENT,
|
||||
variables: { uuid: updateEvent.uuid },
|
||||
data: { event: resultEvent },
|
||||
});
|
||||
console.log(resultEvent);
|
||||
if (!updateEvent.draft) {
|
||||
store.writeQuery({
|
||||
query: EVENT_PERSON_PARTICIPATION,
|
||||
variables: {
|
||||
eventId: updateEvent.id,
|
||||
name: this.event.organizerActor?.preferredUsername,
|
||||
eventId: resultEvent.id,
|
||||
name: resultEvent.organizerActor?.preferredUsername,
|
||||
},
|
||||
data: {
|
||||
person: {
|
||||
__typename: "Person",
|
||||
id: this.event?.organizerActor?.id,
|
||||
id: resultEvent?.organizerActor?.id,
|
||||
participations: {
|
||||
__typename: "PaginatedParticipantList",
|
||||
total: 1,
|
||||
@ -811,11 +804,11 @@ export default class EditEvent extends Vue {
|
||||
role: ParticipantRole.CREATOR,
|
||||
actor: {
|
||||
__typename: "Actor",
|
||||
id: this.event?.organizerActor?.id,
|
||||
id: resultEvent?.organizerActor?.id,
|
||||
},
|
||||
event: {
|
||||
__typename: "Event",
|
||||
id: updateEvent.id,
|
||||
id: resultEvent.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
@ -859,7 +852,7 @@ export default class EditEvent extends Vue {
|
||||
* Build variables for Event GraphQL creation query
|
||||
*/
|
||||
private async buildVariables() {
|
||||
let res = this.event.toEditJSON();
|
||||
let res = new EventModel(this.event).toEditJSON();
|
||||
const organizerActor = this.event.organizerActor?.id
|
||||
? this.event.organizerActor
|
||||
: this.organizerActor;
|
||||
|
@ -1,18 +1,14 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<transition appear name="fade" mode="out-in">
|
||||
<div>
|
||||
<div
|
||||
class="header-picture"
|
||||
v-if="event.picture"
|
||||
:style="`background-image: url('${event.picture.url}')`"
|
||||
/>
|
||||
<div class="header-picture-default" v-else />
|
||||
<section class="section intro">
|
||||
<div class="columns">
|
||||
<div class="column is-1-tablet">
|
||||
<div class="wrapper">
|
||||
<event-banner :picture="event.picture" />
|
||||
<div class="intro-wrapper">
|
||||
<div class="date-calendar-icon-wrapper">
|
||||
<date-calendar-icon :date="event.beginsOn" />
|
||||
</div>
|
||||
<section class="intro">
|
||||
<div class="columns">
|
||||
<div class="column">
|
||||
<h1 class="title" style="margin: 0">{{ event.title }}</h1>
|
||||
<div class="organizer">
|
||||
@ -294,6 +290,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="event-description-wrapper">
|
||||
<aside class="event-metadata">
|
||||
<div class="sticky">
|
||||
@ -662,6 +659,7 @@ import { IConfig } from "../../types/config.model";
|
||||
import Subtitle from "../../components/Utils/Subtitle.vue";
|
||||
import Tag from "../../components/Tag.vue";
|
||||
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
|
||||
import EventBanner from "../../components/Event/EventBanner.vue";
|
||||
import ActorCard from "../../components/Account/ActorCard.vue";
|
||||
import PopoverActorCard from "../../components/Account/PopoverActorCard.vue";
|
||||
import { IParticipant } from "../../types/participant.model";
|
||||
@ -683,6 +681,7 @@ import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
Tag,
|
||||
ActorCard,
|
||||
PopoverActorCard,
|
||||
EventBanner,
|
||||
"map-leaflet": () =>
|
||||
import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
|
||||
ShareEventModal: () =>
|
||||
@ -1308,18 +1307,6 @@ export default class Event extends EventMixin {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.header-picture,
|
||||
.header-picture-default {
|
||||
height: 400px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.header-picture-default {
|
||||
background-image: url("../../../public/img/mobilizon_default_card.png");
|
||||
}
|
||||
|
||||
div.sidebar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@ -1353,7 +1340,7 @@ div.sidebar {
|
||||
}
|
||||
}
|
||||
|
||||
.intro.section {
|
||||
.intro {
|
||||
background: white;
|
||||
|
||||
.is-3-tablet {
|
||||
@ -1570,4 +1557,30 @@ a.participations-link {
|
||||
border: 0;
|
||||
cursor: auto;
|
||||
}
|
||||
|
||||
.wrapper,
|
||||
.intro-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.intro-wrapper {
|
||||
position: relative;
|
||||
padding: 0 16px 16px;
|
||||
background: #fff;
|
||||
|
||||
.date-calendar-icon-wrapper {
|
||||
margin-top: 16px;
|
||||
height: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-self: flex-start;
|
||||
margin-bottom: 7px;
|
||||
margin-left: 0rem;
|
||||
}
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
</style>
|
||||
|
@ -189,10 +189,7 @@
|
||||
}}</b-message>
|
||||
</section>
|
||||
<!-- Your upcoming events -->
|
||||
<section
|
||||
v-if="currentActor.id && goingToEvents.size > 0"
|
||||
class="container"
|
||||
>
|
||||
<section v-if="canShowMyUpcomingEvents" class="container">
|
||||
<h3 class="title">{{ $t("Your upcoming events") }}</h3>
|
||||
<b-loading :active.sync="$apollo.loading" />
|
||||
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
|
||||
@ -236,7 +233,7 @@
|
||||
</span>
|
||||
</section>
|
||||
<!-- Last week events -->
|
||||
<section v-if="currentActor && lastWeekEvents.length > 0">
|
||||
<section v-if="canShowLastWeekEvents">
|
||||
<h3 class="title">{{ $t("Last week") }}</h3>
|
||||
<b-loading :active.sync="$apollo.loading" />
|
||||
<div>
|
||||
@ -250,7 +247,7 @@
|
||||
</div>
|
||||
</section>
|
||||
<!-- Events close to you -->
|
||||
<section class="events-close" v-if="closeEvents.total > 0">
|
||||
<section class="events-close" v-if="canShowCloseEvents">
|
||||
<h2 class="is-size-2 has-text-weight-bold">
|
||||
{{ $t("Events nearby") }}
|
||||
</h2>
|
||||
@ -285,7 +282,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<hr class="home-separator" />
|
||||
<hr
|
||||
class="home-separator"
|
||||
v-if="
|
||||
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
|
||||
"
|
||||
/>
|
||||
<section class="events-recent">
|
||||
<h2 class="is-size-2 has-text-weight-bold">
|
||||
{{ $t("Last published events") }}
|
||||
@ -586,6 +588,18 @@ export default class Home extends Vue {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get canShowMyUpcomingEvents(): boolean {
|
||||
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
|
||||
}
|
||||
|
||||
get canShowLastWeekEvents(): boolean {
|
||||
return this.currentActor && this.lastWeekEvents.length > 0;
|
||||
}
|
||||
|
||||
get canShowCloseEvents(): boolean {
|
||||
return this.closeEvents.total > 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
@ -15,6 +15,7 @@ import { CommentModeration } from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import {
|
||||
eventCommentThreadsMock,
|
||||
eventNoCommentThreadsMock,
|
||||
newCommentForEventMock,
|
||||
newCommentForEventResponse,
|
||||
} from "../../mocks/event";
|
||||
@ -35,7 +36,7 @@ const eventData = {
|
||||
};
|
||||
describe("CommentTree", () => {
|
||||
let wrapper: Wrapper<Vue>;
|
||||
let mockClient: MockApolloClient;
|
||||
let mockClient: MockApolloClient | null;
|
||||
let apolloProvider;
|
||||
let requestHandlers: Record<string, RequestHandler>;
|
||||
const cache = new InMemoryCache({ addTypename: false });
|
||||
@ -83,24 +84,10 @@ describe("CommentTree", () => {
|
||||
});
|
||||
};
|
||||
|
||||
it("renders an empty comment tree", async () => {
|
||||
generateWrapper();
|
||||
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(wrapper.find(".loading").text()).toBe("Loading comments…");
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick(); // because of the <transition>
|
||||
|
||||
expect(wrapper.find(".no-comments").text()).toBe("No comments yet");
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders a comment tree with comments", async () => {
|
||||
generateWrapper();
|
||||
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick(); // because of the <transition>
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(
|
||||
@ -150,4 +137,21 @@ describe("CommentTree", () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("renders an empty comment tree", async () => {
|
||||
generateWrapper({
|
||||
eventCommentThreadsQueryHandler: jest
|
||||
.fn()
|
||||
.mockResolvedValue(eventNoCommentThreadsMock),
|
||||
});
|
||||
expect(requestHandlers.eventCommentThreadsQueryHandler).toHaveBeenCalled();
|
||||
|
||||
expect(wrapper.exists()).toBe(true);
|
||||
expect(wrapper.find(".loading").text()).toBe("Loading comments…");
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find(".no-comments").text()).toBe("No comments yet");
|
||||
expect(wrapper.html()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
@ -2,25 +2,62 @@
|
||||
|
||||
exports[`CommentTree renders a comment tree with comments 1`] = `
|
||||
<div>
|
||||
<form class="new-comment">
|
||||
<!---->
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<identity-picker-wrapper-stub value="[object Object]"></identity-picker-wrapper-stub>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="field">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<editor-stub mode="comment" value=""></editor-stub>
|
||||
</p>
|
||||
<!---->
|
||||
</div>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<div class="send-comment">
|
||||
<b-button-stub type="is-primary" iconleft="send" nativetype="submit" tag="button" aria-label="Post a comment" class="comment-button-submit"></b-button-stub>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
<transition-group-stub name="comment-empty-list" mode="out-in">
|
||||
<transition-group-stub tag="ul" name="comment-list" class="comment-list">
|
||||
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
|
||||
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
|
||||
</transition-group-stub>
|
||||
<div class="no-comments"><span>No comments yet</span></div>
|
||||
</transition-group-stub>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`CommentTree renders an empty comment tree 1`] = `
|
||||
<div>
|
||||
<form class="new-comment">
|
||||
<!---->
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<identity-picker-wrapper-stub value="[object Object]"></identity-picker-wrapper-stub>
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="field">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<editor-stub mode="comment" value=""></editor-stub>
|
||||
</p>
|
||||
<!---->
|
||||
</div>
|
||||
<!---->
|
||||
</div>
|
||||
</div>
|
||||
<div class="send-comment">
|
||||
<b-button-stub type="is-primary" iconleft="send" nativetype="submit" tag="button" aria-label="Post a comment" class="comment-button-submit"></b-button-stub>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
<transition-group-stub name="comment-empty-list" mode="out-in">
|
||||
<transition-group-stub tag="ul" name="comment-list" class="comment-list">
|
||||
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
|
||||
<comment-stub comment="[object Object]" event="[object Object]" class="root-comment"></comment-stub>
|
||||
</transition-group-stub>
|
||||
<div class="no-comments"><span>No comments yet</span></div>
|
||||
</transition-group-stub>
|
||||
</div>
|
||||
|
@ -63,6 +63,17 @@ export const joinEventMock = {
|
||||
locale: "en_US",
|
||||
};
|
||||
|
||||
export const eventNoCommentThreadsMock = {
|
||||
data: {
|
||||
event: {
|
||||
__typename: "Event",
|
||||
id: "1",
|
||||
uuid: "f37910ea-fd5a-4756-9679-00971f3f4106",
|
||||
comments: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const eventCommentThreadsMock = {
|
||||
data: {
|
||||
event: {
|
||||
|
@ -3416,6 +3416,11 @@ bluebird@^3.1.1, bluebird@^3.7.2:
|
||||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||
|
||||
blurhash@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e"
|
||||
integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw==
|
||||
|
||||
body-parser@1.19.0:
|
||||
version "1.19.0"
|
||||
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
|
||||
|
@ -352,18 +352,14 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
|
||||
end
|
||||
|
||||
def make_media_data(media) when is_map(media) do
|
||||
with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <-
|
||||
with {:ok, %{url: url} = uploaded} <-
|
||||
Mobilizon.Web.Upload.store(media.file),
|
||||
{:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
|
||||
{:ok, %Media{file: _file} = media} <-
|
||||
Mobilizon.Medias.create_media(%{
|
||||
"file" => %{
|
||||
"url" => url,
|
||||
"name" => media.name,
|
||||
"content_type" => content_type,
|
||||
"size" => size
|
||||
},
|
||||
"actor_id" => media.actor_id
|
||||
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
|
||||
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
|
||||
actor_id: media.actor_id
|
||||
}) do
|
||||
Converter.Media.model_to_as(media)
|
||||
else
|
||||
|
@ -143,7 +143,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Actor do
|
||||
when code in 200..299 <- RemoteMediaDownloaderClient.get(url),
|
||||
name <- name || Parser.get_filename_from_response(response_headers, url) || default_name,
|
||||
{:ok, file} <- Upload.store(%{body: body, name: name}) do
|
||||
file
|
||||
Map.take(file, [:content_type, :name, :url, :size])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -40,17 +40,13 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Media do
|
||||
)
|
||||
when is_binary(media_url) do
|
||||
with {:ok, %{body: body}} <- Tesla.get(media_url, opts: @http_options),
|
||||
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
|
||||
{:ok, %{url: url} = uploaded} <-
|
||||
Upload.store(%{body: body, name: name}),
|
||||
{:media_exists, nil} <- {:media_exists, Medias.get_media_by_url(url)} do
|
||||
Medias.create_media(%{
|
||||
"file" => %{
|
||||
"url" => url,
|
||||
"name" => name,
|
||||
"content_type" => content_type,
|
||||
"size" => size
|
||||
},
|
||||
"actor_id" => actor_id
|
||||
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
|
||||
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
|
||||
actor_id: actor_id
|
||||
})
|
||||
else
|
||||
{:media_exists, %MediaModel{file: _file} = media} ->
|
||||
|
@ -144,7 +144,7 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
@spec find_webfinger_endpoint(String.t()) :: String.t()
|
||||
def find_webfinger_endpoint(domain) when is_binary(domain) do
|
||||
with {:ok, %{body: body}} <- fetch_document("http://#{domain}/.well-known/host-meta"),
|
||||
link_template <- find_link_from_template(body) do
|
||||
link_template when is_binary(link_template) <- find_link_from_template(body) do
|
||||
{:ok, link_template}
|
||||
end
|
||||
end
|
||||
@ -203,6 +203,9 @@ defmodule Mobilizon.Federation.WebFinger do
|
||||
xpath(doc, ~x"//Link[@rel=\"lrdd\"][@type=\"application/json\"]/@template"s),
|
||||
res when res in [nil, ""] <- xpath(doc, ~x"//Link[@rel=\"lrdd\"]/@template"s),
|
||||
do: {:error, :link_not_found}
|
||||
catch
|
||||
:exit, _e ->
|
||||
{:error, :link_not_found}
|
||||
end
|
||||
|
||||
@spec fetch_document(String.t()) :: Tesla.Env.result()
|
||||
|
@ -52,14 +52,17 @@ defmodule Mobilizon.GraphQL.API.Events do
|
||||
defp process_picture(%{media_id: _picture_id} = args, _), do: args
|
||||
|
||||
defp process_picture(%{media: media}, %Actor{id: actor_id}) do
|
||||
%{
|
||||
file:
|
||||
with uploaded when is_map(uploaded) <-
|
||||
media
|
||||
|> Map.get(:file)
|
||||
|> Utils.make_media_data(description: Map.get(media, :name)),
|
||||
|> Utils.make_media_data(description: Map.get(media, :name)) do
|
||||
%{
|
||||
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
|
||||
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
|
||||
actor_id: actor_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@spec extract_pictures_from_event_body(map(), Actor.t()) :: map()
|
||||
defp extract_pictures_from_event_body(
|
||||
|
@ -47,7 +47,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
|
||||
%{context: %{current_user: %User{} = user}}
|
||||
) do
|
||||
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
|
||||
{:ok, %{name: _name, url: url, content_type: content_type, size: size}} <-
|
||||
{:ok,
|
||||
%{
|
||||
name: _name,
|
||||
url: url,
|
||||
content_type: content_type,
|
||||
size: size
|
||||
} = uploaded} <-
|
||||
Mobilizon.Web.Upload.store(file),
|
||||
args <-
|
||||
args
|
||||
@ -55,7 +61,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
|
||||
|> Map.put(:size, size)
|
||||
|> Map.put(:content_type, content_type),
|
||||
{:ok, media = %Media{}} <-
|
||||
Medias.create_media(%{"file" => args, "actor_id" => actor_id}) do
|
||||
Medias.create_media(%{
|
||||
file: args,
|
||||
actor_id: actor_id,
|
||||
metadata: Map.take(uploaded, [:width, :height, :blurhash])
|
||||
}) do
|
||||
{:ok, transform_media(media)}
|
||||
else
|
||||
{:error, :mime_type_not_allowed} ->
|
||||
@ -124,13 +134,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Media do
|
||||
def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
|
||||
|
||||
@spec transform_media(Media.t()) :: map()
|
||||
defp transform_media(%Media{id: id, file: file}) do
|
||||
defp transform_media(%Media{id: id, file: file, metadata: metadata}) do
|
||||
%{
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
id: id,
|
||||
content_type: file.content_type,
|
||||
size: file.size
|
||||
size: file.size,
|
||||
metadata: metadata
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -215,14 +215,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
|
||||
defp process_picture(%{media_id: _picture_id} = args, _), do: args
|
||||
|
||||
defp process_picture(%{media: media}, %Actor{id: actor_id}) do
|
||||
%{
|
||||
file:
|
||||
with uploaded when is_map(uploaded) <-
|
||||
media
|
||||
|> Map.get(:file)
|
||||
|> Utils.make_media_data(description: Map.get(media, :name)),
|
||||
|> Utils.make_media_data(description: Map.get(media, :name)) do
|
||||
%{
|
||||
file: Map.take(uploaded, [:url, :name, :content_type, :size]),
|
||||
metadata: Map.take(uploaded, [:width, :height, :blurhash]),
|
||||
actor_id: actor_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@spec extract_pictures_from_post_body(map(), String.t()) :: map()
|
||||
defp extract_pictures_from_post_body(%{body: body} = args, actor_id) do
|
||||
|
@ -14,6 +14,7 @@ defmodule Mobilizon.GraphQL.Schema.MediaType do
|
||||
field(:url, :string, description: "The media's full URL")
|
||||
field(:content_type, :string, description: "The media's detected content type")
|
||||
field(:size, :integer, description: "The media's size")
|
||||
field(:metadata, :media_metadata, description: "The media's metadata")
|
||||
end
|
||||
|
||||
@desc """
|
||||
@ -24,6 +25,15 @@ defmodule Mobilizon.GraphQL.Schema.MediaType do
|
||||
field(:total, :integer, description: "The total number of medias in the list")
|
||||
end
|
||||
|
||||
@desc """
|
||||
Some metadata associated with a media
|
||||
"""
|
||||
object :media_metadata do
|
||||
field(:width, :integer, description: "The media width (if a picture)")
|
||||
field(:height, :integer, description: "The media width (if a height)")
|
||||
field(:blurhash, :string, description: "The media blurhash (if a picture")
|
||||
end
|
||||
|
||||
@desc "An attached media or a link to a media"
|
||||
input_object :media_input do
|
||||
# Either a full media object
|
||||
|
@ -80,11 +80,12 @@ defmodule Mobilizon.Discussions do
|
||||
# However, it also excludes all top-level comments with deleted replies from being selected
|
||||
# |> where([_, r], is_nil(r.deleted_at))
|
||||
|> group_by([c], c.id)
|
||||
|> order_by([c], desc: :is_announcement, asc: :published_at)
|
||||
|> select([c, r], %{c | total_replies: count(r.id)})
|
||||
end
|
||||
|
||||
def query(Comment, _) do
|
||||
order_by(Comment, [c], asc: :published_at)
|
||||
order_by(Comment, [c], asc: :is_announcement, asc: :published_at)
|
||||
end
|
||||
|
||||
def query(queryable, _) do
|
||||
|
@ -256,7 +256,7 @@ defmodule Mobilizon.Events.Event do
|
||||
|
||||
# In case it's a new picture
|
||||
defp put_picture(%Changeset{} = changeset, _attrs) do
|
||||
cast_assoc(changeset, :picture)
|
||||
cast_assoc(changeset, :picture, with: &Media.changeset/2)
|
||||
end
|
||||
|
||||
# Created or updated with draft parameter: don't publish
|
||||
|
@ -5,21 +5,32 @@ defmodule Mobilizon.Medias.Media do
|
||||
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset, only: [cast: 3, cast_embed: 2]
|
||||
import Ecto.Changeset, only: [cast: 3, cast_embed: 2, cast_embed: 3]
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Medias.File
|
||||
alias Mobilizon.Medias.Media.Metadata
|
||||
alias Mobilizon.Posts.Post
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
file: File.t(),
|
||||
metadata: Metadata.t(),
|
||||
actor: Actor.t()
|
||||
}
|
||||
|
||||
@metadata_attrs [:height, :width, :blurhash]
|
||||
|
||||
schema "medias" do
|
||||
embeds_one(:file, File, on_replace: :update)
|
||||
|
||||
embeds_one :metadata, Metadata, on_replace: :update do
|
||||
field(:height, :integer)
|
||||
field(:width, :integer)
|
||||
field(:blurhash, :string)
|
||||
end
|
||||
|
||||
belongs_to(:actor, Actor)
|
||||
has_many(:event_picture, Event, foreign_key: :picture_id)
|
||||
many_to_many(:events, Event, join_through: "events_medias")
|
||||
@ -36,5 +47,13 @@ defmodule Mobilizon.Medias.Media do
|
||||
media
|
||||
|> cast(attrs, [:actor_id])
|
||||
|> cast_embed(:file)
|
||||
|> cast_embed(:metadata, with: &metadata_changeset/2)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec changeset(struct(), map) :: Ecto.Changeset.t()
|
||||
def metadata_changeset(metadata, attrs) do
|
||||
metadata
|
||||
|> cast(attrs, @metadata_attrs)
|
||||
end
|
||||
end
|
||||
|
47
lib/web/upload/filter/analyze_metadata.ex
Normal file
47
lib/web/upload/filter/analyze_metadata.ex
Normal file
@ -0,0 +1,47 @@
|
||||
# Portions of this file are derived from Pleroma:
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Upstream: https://git.pleroma.social/pleroma/pleroma/-/blob/develop/lib/pleroma/upload/filter/analyze_metadata.ex
|
||||
|
||||
defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadata do
|
||||
@moduledoc """
|
||||
Extracts metadata about the upload, such as width/height
|
||||
"""
|
||||
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
|
||||
image =
|
||||
file
|
||||
|> 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}
|
||||
rescue
|
||||
e in ErlangError ->
|
||||
Logger.warn("#{__MODULE__}: #{inspect(e)}")
|
||||
{:ok, :noop}
|
||||
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
|
@ -73,12 +73,9 @@ defmodule Mobilizon.Web.Upload do
|
||||
{:ok, upload} <- Filter.filter(opts.filters, upload),
|
||||
{:ok, url_spec} <- Uploader.put_file(opts.uploader, upload) do
|
||||
{:ok,
|
||||
%{
|
||||
name: Map.get(opts, :description) || upload.name,
|
||||
url: url_from_spec(upload, opts.base_url, url_spec),
|
||||
content_type: upload.content_type,
|
||||
size: upload.size
|
||||
}}
|
||||
upload
|
||||
|> Map.put(:name, Map.get(opts, :description) || upload.name)
|
||||
|> Map.put(:url, url_from_spec(upload, opts.base_url, url_spec))}
|
||||
else
|
||||
{:error, error} ->
|
||||
Logger.error(
|
||||
|
3
mix.exs
3
mix.exs
@ -162,6 +162,9 @@ defmodule Mobilizon.Mixfile do
|
||||
{:sweet_xml, "~> 0.6.6"},
|
||||
{:web_push_encryption,
|
||||
git: "https://github.com/tcitworld/elixir-web-push-encryption", branch: "otp-24"},
|
||||
{:eblurhash,
|
||||
git: "https://github.com/zotonic/eblurhash",
|
||||
ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"},
|
||||
# Dev and test dependencies
|
||||
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
|
||||
{:ex_machina, "~> 2.3", only: [:dev, :test]},
|
||||
|
1
mix.lock
1
mix.lock
@ -25,6 +25,7 @@
|
||||
"dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"},
|
||||
"earmark": {:hex, :earmark, "1.4.15", "2c7f924bf495ec1f65bd144b355d0949a05a254d0ec561740308a54946a67888", [:mix], [{:earmark_parser, ">= 1.4.13", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "3b1209b85bc9f3586f370f7c363f6533788fb4e51db23aa79565875e7f9999ee"},
|
||||
"earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"},
|
||||
"eblurhash": {:git, "https://github.com/zotonic/eblurhash", "04a0b76eadf4de1be17726f39b6313b88708fd12", [ref: "04a0b76eadf4de1be17726f39b6313b88708fd12"]},
|
||||
"ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"},
|
||||
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm", "a3cc73211f2e75b89a03332183812ebe1ac08be2e25a1df5aa3d1422f92c45c3"},
|
||||
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
|
||||
|
@ -0,0 +1,9 @@
|
||||
defmodule Mobilizon.Storage.Repo.Migrations.AddMetadataToMedia do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:medias) do
|
||||
add(:metadata, :map)
|
||||
end
|
||||
end
|
||||
end
|
20
test/web/upload/filter/analyze_metadata_test.exs
Normal file
20
test/web/upload/filter/analyze_metadata_test.exs
Normal file
@ -0,0 +1,20 @@
|
||||
# Portions of this file are derived from Pleroma:
|
||||
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Upstream: https://git.pleroma.social/pleroma/pleroma/-/blob/develop/test/pleroma/upload/filter/analyze_metadata_test.exs
|
||||
|
||||
defmodule Mobilizon.Web.Upload.Filter.AnalyzeMetadataTest do
|
||||
use Mobilizon.DataCase, async: true
|
||||
alias Mobilizon.Web.Upload.Filter.AnalyzeMetadata
|
||||
|
||||
test "adds the image dimensions" do
|
||||
upload = %Mobilizon.Web.Upload{
|
||||
name: "an… image.jpg",
|
||||
content_type: "image/jpeg",
|
||||
path: Path.absname("test/fixtures/image.jpg"),
|
||||
tempfile: Path.absname("test/fixtures/image.jpg")
|
||||
}
|
||||
|
||||
assert {:ok, :filtered, %{width: 266, height: 67}} = AnalyzeMetadata.filter(upload)
|
||||
end
|
||||
end
|
Loading…
Reference in New Issue
Block a user